-
Notifications
You must be signed in to change notification settings - Fork 43
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
2 changed files
with
181 additions
and
145 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,140 +1,159 @@ | ||
const path = require("path"); | ||
const glob = require("glob"); | ||
const fs = require("fs"); | ||
const gettext_parser = require('gettext-parser'); | ||
const Jed = require('jed'); | ||
const webpack = require('webpack'); | ||
import fs from "fs"; | ||
import glob from "glob"; | ||
import path from "path"; | ||
|
||
const srcdir = process.env.SRCDIR || path.resolve(__dirname, '..', '..'); | ||
import Jed from "jed"; | ||
import gettext_parser from "gettext-parser"; | ||
|
||
module.exports = class { | ||
constructor(options) { | ||
if (!options) | ||
options = {}; | ||
this.subdir = options.subdir || ''; | ||
this.reference_patterns = options.reference_patterns; | ||
this.wrapper = options.wrapper || 'cockpit.locale(PO_DATA);'; | ||
} | ||
const config = {}; | ||
|
||
get_po_files(compilation) { | ||
try { | ||
const linguas_file = path.resolve(srcdir, "po/LINGUAS"); | ||
const linguas = fs.readFileSync(linguas_file, 'utf8').match(/\S+/g); | ||
compilation.fileDependencies.add(linguas_file); // Only after reading the file | ||
return linguas.map(lang => path.resolve(srcdir, 'po', lang + '.po')); | ||
} catch (error) { | ||
if (error.code !== 'ENOENT') { | ||
throw error; | ||
} | ||
const DEFAULT_WRAPPER = 'cockpit.locale(PO_DATA);'; | ||
|
||
/* No LINGUAS file? Fall back to globbing. | ||
* Note: we won't detect .po files being added in this case. | ||
*/ | ||
return glob.sync(path.resolve(srcdir, 'po/*.po')); | ||
function get_po_files() { | ||
try { | ||
const linguas_file = path.resolve(config.srcdir, "po/LINGUAS"); | ||
const linguas = fs.readFileSync(linguas_file, 'utf8').match(/\S+/g); | ||
return linguas.map(lang => path.resolve(config.srcdir, 'po', lang + '.po')); | ||
} catch (error) { | ||
if (error.code !== 'ENOENT') { | ||
throw error; | ||
} | ||
} | ||
|
||
apply(compiler) { | ||
compiler.hooks.thisCompilation.tap('CockpitPoPlugin', compilation => { | ||
compilation.hooks.processAssets.tapPromise( | ||
{ | ||
name: 'CockpitPoPlugin', | ||
stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL, | ||
}, | ||
() => Promise.all(this.get_po_files(compilation).map(f => this.buildFile(f, compilation))) | ||
); | ||
}); | ||
/* No LINGUAS file? Fall back to globbing. | ||
* Note: we won't detect .po files being added in this case. | ||
*/ | ||
return glob.sync(path.resolve(config.srcdir, 'po/*.po')); | ||
} | ||
|
||
get_plural_expr(statement) { | ||
try { | ||
/* Check that the plural forms isn't being sneaky since we build a function here */ | ||
Jed.PF.parse(statement); | ||
} catch (ex) { | ||
console.error("bad plural forms: " + ex.message); | ||
process.exit(1); | ||
} | ||
|
||
const expr = statement.replace(/nplurals=[1-9]; plural=([^;]*);?$/, '(n) => $1'); | ||
if (expr === statement) { | ||
console.error("bad plural forms: " + statement); | ||
process.exit(1); | ||
} | ||
|
||
return expr; | ||
} | ||
|
||
function get_plural_expr(statement) { | ||
try { | ||
/* Check that the plural forms isn't being sneaky since we build a function here */ | ||
Jed.PF.parse(statement); | ||
} catch (ex) { | ||
console.error("bad plural forms: " + ex.message); | ||
process.exit(1); | ||
} | ||
|
||
build_patterns(compilation, extras) { | ||
const patterns = [ | ||
// all translations for that page, including manifest.json and *.html | ||
`pkg/${this.subdir}.*`, | ||
]; | ||
|
||
// add translations from libraries outside of page directory | ||
compilation.getStats().compilation.fileDependencies.forEach(path => { | ||
if (path.startsWith(srcdir) && path.indexOf('node_modules/') < 0) | ||
patterns.push(path.slice(srcdir.length + 1)); | ||
}); | ||
|
||
Array.prototype.push.apply(patterns, extras); | ||
|
||
return patterns.map((p) => new RegExp(`^${p}:[0-9]+$`)); | ||
const expr = statement.replace(/nplurals=[1-9]; plural=([^;]*);?$/, '(n) => $1'); | ||
if (expr === statement) { | ||
console.error("bad plural forms: " + statement); | ||
process.exit(1); | ||
} | ||
|
||
check_reference_patterns(patterns, references) { | ||
for (const reference of references) { | ||
for (const pattern of patterns) { | ||
if (reference.match(pattern)) { | ||
return true; | ||
return expr; | ||
} | ||
|
||
function buildFile(po_file, subdir, webpack_module, webpack_compilation, filename, filter) { | ||
return new Promise((resolve, reject) => { | ||
// Read the PO file, remove fuzzy/disabled lines to avoid tripping up the validator | ||
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[""][""]; // second header copy | ||
|
||
const rtl_langs = ["ar", "fa", "he", "ur"]; | ||
const dir = rtl_langs.includes(parsed.headers.Language) ? "rtl" : "ltr"; | ||
|
||
// cockpit.js only looks at "plural-forms" and "language" | ||
const chunks = [ | ||
'{\n', | ||
' "": {\n', | ||
` "plural-forms": ${get_plural_expr(parsed.headers['Plural-Forms'])},\n`, | ||
` "language": "${parsed.headers.Language}",\n`, | ||
` "language-direction": "${dir}"\n`, | ||
' }' | ||
]; | ||
for (const [msgctxt, context] of Object.entries(parsed.translations)) { | ||
const context_prefix = msgctxt ? msgctxt + '\u0004' : ''; /* for cockpit.ngettext */ | ||
|
||
for (const [msgid, translation] of Object.entries(context)) { | ||
/* Only include msgids which appear in this source directory */ | ||
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); | ||
// cockpit.js always ignores the first item | ||
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); | ||
if (webpack_compilation) | ||
webpack_compilation.emitAsset(out_path, new webpack_module.sources.RawSource(output)); | ||
else | ||
fs.writeFileSync(path.resolve(config.outdir, out_path), output); | ||
return resolve(); | ||
}); | ||
} | ||
|
||
function init(options) { | ||
config.srcdir = process.env.SRCDIR || './'; | ||
config.subdirs = options.subdirs || ['']; | ||
config.src_directory = options.src_directory || 'src'; | ||
config.wrapper = options.wrapper; | ||
config.outdir = options.outdir || './dist'; | ||
} | ||
|
||
function run(webpack_module, webpack_compilation) { | ||
const promises = []; | ||
for (const subdir of config.subdirs) { | ||
for (const po_file of get_po_files()) { | ||
if (webpack_compilation) | ||
webpack_compilation.fileDependencies.add(po_file); | ||
const lang = path.basename(po_file).slice(0, -3); | ||
promises.push(Promise.all([ | ||
// Separate translations for the manifest.json file and normal pages | ||
buildFile(po_file, subdir, webpack_module, webpack_compilation, | ||
`po.${lang}.js`, str => !str.includes('manifest.json')), | ||
buildFile(po_file, subdir, webpack_module, webpack_compilation, | ||
`po.manifest.${lang}.js`, str => str.includes('manifest.json')) | ||
])); | ||
} | ||
} | ||
return Promise.all(promises); | ||
} | ||
|
||
export const cockpitPoEsbuildPlugin = options => ({ | ||
name: 'cockpitPoEsbuildPlugin', | ||
setup(build) { | ||
init({ ...options, outdir: build.initialOptions.outdir }); | ||
build.onEnd(async result => { result.errors.length === 0 && await run() }); | ||
}, | ||
}); | ||
|
||
export class CockpitPoWebpackPlugin { | ||
constructor(options) { | ||
init(options || {}); | ||
} | ||
|
||
buildFile(po_file, compilation) { | ||
compilation.fileDependencies.add(po_file); | ||
|
||
return new Promise((resolve, reject) => { | ||
const patterns = this.build_patterns(compilation, this.reference_patterns); | ||
|
||
const parsed = gettext_parser.po.parse(fs.readFileSync(po_file), 'utf8'); | ||
delete parsed.translations[""][""]; // second header copy | ||
|
||
// cockpit.js only looks at "plural-forms" and "language" | ||
const chunks = [ | ||
'{\n', | ||
' "": {\n', | ||
` "plural-forms": ${this.get_plural_expr(parsed.headers['plural-forms'])},\n`, | ||
` "language": "${parsed.headers.language}"\n`, | ||
' }' | ||
]; | ||
for (const [msgctxt, context] of Object.entries(parsed.translations)) { | ||
const context_prefix = msgctxt ? msgctxt + '\u0004' : ''; /* for cockpit.ngettext */ | ||
|
||
for (const [msgid, translation] of Object.entries(context)) { | ||
const references = translation.comments.reference.split(/\s/); | ||
if (!this.check_reference_patterns(patterns, references)) | ||
continue; | ||
|
||
if (translation.comments.flag && translation.comments.flag.match(/\bfuzzy\b/)) | ||
continue; | ||
|
||
const key = JSON.stringify(context_prefix + msgid); | ||
// cockpit.js always ignores the first item | ||
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 output = this.wrapper.replace('PO_DATA', chunks.join('')) + '\n'; | ||
|
||
const lang = path.basename(po_file).slice(0, -3); | ||
compilation.emitAsset(this.subdir + 'po.' + lang + '.js', new webpack.sources.RawSource(output)); | ||
resolve(); | ||
apply(compiler) { | ||
compiler.hooks.thisCompilation.tap('CockpitPoWebpackPlugin', async compilation => { | ||
const webpack = (await import('webpack')).default; | ||
compilation.hooks.processAssets.tapPromise( | ||
{ | ||
name: 'CockpitPoWebpackPlugin', | ||
stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL, | ||
}, | ||
() => run(webpack, compilation) | ||
); | ||
}); | ||
} | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,32 +1,49 @@ | ||
const child_process = require("child_process"); | ||
import child_process from "child_process"; | ||
|
||
module.exports = class { | ||
constructor(options) { | ||
if (!options) | ||
options = {}; | ||
this.dest = options.dest || ""; | ||
this.source = options.source || "dist/"; | ||
const config = {}; | ||
|
||
function init(options) { | ||
config.dest = options.dest || ""; | ||
config.source = options.source || "dist/"; | ||
config.ssh_host = process.env.RSYNC || process.env.RSYNC_DEVEL; | ||
|
||
// ensure the target directory exists | ||
if (process.env.RSYNC) | ||
child_process.spawnSync("ssh", [process.env.RSYNC, "mkdir", "-p", "/usr/local/share/cockpit/"], { stdio: "inherit" }); | ||
// ensure the target directory exists | ||
if (config.ssh_host) { | ||
config.rsync_dir = process.env.RSYNC ? "/usr/local/share/cockpit/" : "~/.local/share/cockpit/"; | ||
child_process.spawnSync("ssh", [config.ssh_host, "mkdir", "-p", config.rsync_dir], { stdio: "inherit" }); | ||
} | ||
} | ||
|
||
apply(compiler) { | ||
compiler.hooks.afterEmit.tapAsync('WebpackHookPlugin', (compilation, callback) => { | ||
if (process.env.RSYNC) { | ||
const proc = child_process.spawn("rsync", ["--recursive", "--info=PROGRESS2", "--delete", | ||
this.source, process.env.RSYNC + ":/usr/local/share/cockpit/" + this.dest], { stdio: "inherit" }); | ||
proc.on('close', (code) => { | ||
if (code !== 0) { | ||
process.exit(1); | ||
} else { | ||
callback(); | ||
} | ||
}); | ||
function run(callback) { | ||
if (config.ssh_host) { | ||
const proc = child_process.spawn("rsync", ["--recursive", "--info=PROGRESS2", "--delete", | ||
config.source, config.ssh_host + ":" + config.rsync_dir + config.dest], { stdio: "inherit" }); | ||
proc.on('close', (code) => { | ||
if (code !== 0) { | ||
process.exit(1); | ||
} else { | ||
callback(); | ||
} | ||
}); | ||
} else { | ||
callback(); | ||
} | ||
} | ||
|
||
export const cockpitRsyncEsbuildPlugin = options => ({ | ||
name: 'cockpitRsyncPlugin', | ||
setup(build) { | ||
init(options || {}); | ||
build.onEnd(result => result.errors.length === 0 ? run(() => {}) : {}); | ||
}, | ||
}); | ||
|
||
export class CockpitRsyncWebpackPlugin { | ||
constructor(options) { | ||
init(options || {}); | ||
} | ||
|
||
apply(compiler) { | ||
compiler.hooks.afterEmit.tapAsync('WebpackHookPlugin', (_compilation, callback) => run(callback)); | ||
} | ||
}; | ||
} |