Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Build Status

on:
push:
branches: [master]
pull_request:
branches: [master]

jobs:
test:
runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
node-version: ['24', '26']

name: node ${{ matrix.node-version }}

steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}
cache: npm

- run: npm ci

- run: npm test
54 changes: 50 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,51 @@
node_modules
npm-debug.log
# Ingore bun and yarn locks
bun.lockb
yarn.lock

#IDEs
.idea
## Build
.env
dist/
build/
*.new.js
*.old.js
*.mo
tmp/

### General ###
.DS_Store
Thumbs.db
report/
out/
temp/
tmp/
.tmp
*.tmp
*.tmp.*
log.txt
*.log
*.*~
nohup.out

### SublimeText ###
*.sublime-project
*.sublime-workspace
sftp-config.json

### Intellij IDE ###
.idea/
atlassian-ide-plugin.xml

### VisualStudioCode ###
.vscode/*
.vscode-upload.json

### TextMate ###
*.tmproj
*.tmproject
tmtags

### Node ###
.nodemonignore
npm-debug.log*
.npmignore
node_modules/
9 changes: 0 additions & 9 deletions .travis.yml

This file was deleted.

87 changes: 77 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,26 @@ Parse a PO buffer to JSON
* `fallback-to-msgid` If `true`, for those entries that would be omitted (fuzzy entries without the fuzzy flag) and for those
that are empty, the msgid will be used as translation in the json file. If the entry is plural, msgid_plural will be used for
msgstr[1]. This means that this option makes sense only for those languages that have nplurals=2.
* `mfOptions` Options handed to [gettext-to-messageformat](https://github.com/eemeli/gettext-to-messageformat). Only has
effect if `format: 'mf'`. Defaults to `{}`, anything given here wins over the options po2json sets itself.
* `escape-params` Whether `{`, `}`, `#` and `\` are escaped so that MessageFormat reads them as literals. Set it to
`false` to write a `{{error}}` placeholder out as-is instead of `\{\{error\}\}`. Only has effect if `format: 'mf'`.
Defaults to `true`. Turn it off for consumers that read the JSON as plain strings and use braces for their own
translation parameters, such as [ngx-translate](https://github.com/ngx-translate/core).

#### Braces, hashes and backslashes (`format: 'mf'`)

gettext-to-messageformat escapes `{`, `}`, `#` and `\` so that MessageFormat reads them as literals, which turns a
`{{error}}` placeholder into `\{\{error\}\}` (issue #77). `escape-params: false` drops that one rule while keeping the `%s`,
`%d`, `%(name)s` and `%%` conversion:

```
po2json.parseFileSync('messages.po', { format: 'mf', 'escape-params': false });
```

Note that a literal `#` inside a plural is then read by MessageFormat as the plural number, so leave the escaping on when
the JSON is handed to a MessageFormat compiler. The replacement list behind the option is exported as
`po2json.mfReplacements` to build a list of your own from, for use with `mfOptions.replacements`.

Parse a PO file to JSON

Expand All @@ -71,6 +91,7 @@ default options.
* --full-mf, -M: return full messageformat output (instead of only translations)
* --domain, -d: same as domain in function options
* --fallback-to-msgid': 'use msgid if translation is missing (nplurals must match)
* --no-escape-params: same as 'escape-params' = false in function options

Note: `'format': 'mf'` means the json format used by messageFormatter in github.com/SlexAxton/messageformat.js
and `jedold` refers to Jed formats below 1.1.0
Expand Down Expand Up @@ -106,28 +127,52 @@ try {
```

### Parse a PO file to messageformat format
`messageformat@2` was renamed to [`@messageformat/core`](https://messageformat.github.io/), which
compiles one message at a time instead of a whole object, so translations are walked by hand:

```
var po2json = require('po2json'),
MessageFormat = require('messageformat');
MessageFormat = require('@messageformat/core');

function compileAll(mf, translations) {
return Object.keys(translations).reduce(function (messages, key) {
var message = translations[key];
// nested objects are msgctxt contexts
messages[key] = typeof message === 'string' ? mf.compile(message) : compileAll(mf, message);
return messages;
}, {});
}

po2json.parseFile('es.po', { format: 'mf' }, function (err, translations) {
var pFunc = function (n) {
var es = function (n) {
return (n==1 ? 'p0' : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 'p1' : 'p2');
};
pFunc.cardinal = [ 'p0', 'p1', 'p2' ];
var mf = new MessageFormat(
{
'es': pFunc
}
);
var i18n = mf.compile( translations );
es.cardinals = [ 'p0', 'p1', 'p2' ];
var mf = new MessageFormat(es);
var i18n = compileAll(mf, translations);
});
```

The locale is taken from the name of the plural function, so name it after the locale it describes.

### Parse a PO file to messageformat format using the full format
```
var po2json = require('po2json'),
MessageFormat = require('messageformat');
MessageFormat = require('@messageformat/core');

po2json.parseFile('messages.po', { format: 'mf', fullMF: true }, function (err, jsonData) {
var mf = new MessageFormat(jsonData.pluralFunction);
var i18n = compileAll(mf, jsonData.translations);
});
```

#### Still on messageformat@2?
The output is unchanged, only the calls into messageformat differ. `pluralFunction` carries both the
`cardinal` (messageformat@2) and `cardinals` (`@messageformat/core`) plural category lists, so the
legacy form keeps working:

```
var MessageFormat = require('messageformat'); // messageformat@2

po2json.parseFile('messages.po', { format: 'mf', fullMF: true }, function (err, jsonData) {
var mf = new MessageFormat(
Expand All @@ -137,6 +182,9 @@ po2json.parseFile('messages.po', { format: 'mf', fullMF: true }, function (err,
});
```

Note that `messageformat@4` is *not* a newer `messageformat@2`: it is a polyfill for the upcoming
`Intl.MessageFormat` and does not read the format produced here.

### Parse a PO file to Jed >= 1.1.0 format
```
var po2json = require('po2json'),
Expand Down Expand Up @@ -165,6 +213,25 @@ npm test
In lieu of a formal styleguide, take care to maintain the existing coding style. Add unit tests for any new or changed functionality. Lint and test your code using [grunt](https://github.com/gruntjs/grunt).

## Release History
### 1.0.1 / 2026-07-23
Dependency refresh, existing output is unchanged.

* Added the `escape-params` option (`--no-escape-params` on the command line). Set it to `false` to keep `{`, `}`, `#` and
`\` unescaped in `format: 'mf'`, so that a `{{error}}` placeholder is not written out as `\{\{error\}\}` (issue #77).
Escaping stays on by default, turning it off is meant for consumers reading the JSON as plain strings, such as
ngx-translate.
* Fixed the command line `--full-mf`/`-M` and `--fallback-to-msgid` flags, which reach `parse` again. commander
camel-cases dashed flags, so both had been silently ignored.
* Updated to gettext-parser 9, which is ESM only and needs node >= 20.19 to be `require`d. Its second argument became
an options object (`{defaultCharset}`), and it now reports header names in canonical case (`Plural-Forms`) which
po2json lower-cases again on the way out.
* Updated to gettext-to-messageformat 0.4, and `pluralFunction` now carries both `cardinal` and `cardinals` so it can
be handed to either messageformat generation.
* Fixed the executable against commander 15, which stopped exporting the program as the module itself.
* Documented `mfOptions`, and moved the messageformat examples to `@messageformat/core` (`messageformat@2` usage is
still documented, `messageformat@4` is an `Intl.MessageFormat` polyfill and does not read this format).
* Tests moved from jest 25 to jest 30 and from messageformat 2 to `@messageformat/core` 3.

### 1.0.0 / 2018-09-24
* Updated dependencies.
* Replaced nomnom with commander.
Expand Down
14 changes: 10 additions & 4 deletions bin/po2json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ var po2json = require('../'),
fs = require('fs'),
version = require('../package.json').version;

require("commander")
require("commander").program
.version(version)
.arguments('<input> <output>')
.option('-p --pretty', 'pretty-print JSON')
Expand All @@ -13,11 +13,17 @@ require("commander")
.option('-M --full-mf', 'return full messageformat output (instead of only translations)')
.option('-d --domain [domain]', 'domain')
.option('--fallback-to-msgid', 'use msgid if translation is missing (nplurals must match)')
.option('--no-escape-params', 'keep braces, hashes and backslashes unescaped (only for --format mf)')
.action(function (input, output, options) {
options.stringify = true;
var result = po2json.parseFileSync(input, options),
// commander camel-cases dashed flags, parse() expects its own option names
var result = po2json.parseFileSync(input, Object.assign({}, options, {
stringify: true,
fullMF: options.fullMf,
'fallback-to-msgid': options.fallbackToMsgid,
'escape-params': options.escapeParams !== false
})),
stream = fs.createWriteStream(output, {});

stream.write(result);
stream.end(result);
})
.parse(process.argv);
3 changes: 2 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
module.exports = {
parse: require('./lib/parse'),
parseFile: require('./lib/parseFile'),
parseFileSync: require('./lib/parseFileSync')
parseFileSync: require('./lib/parseFileSync'),
mfReplacements: require('./lib/mfReplacements')
};
28 changes: 28 additions & 0 deletions lib/mfReplacements.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* All of the gettext-to-messageformat default replacements except for
* {pattern: /[\\{}#]/g, replacement: '\\$&'}, which escapes braces, hashes and
* backslashes so MessageFormat reads them as literals.
*
* Used when the `escape-params` option is off, to keep placeholders such as
* {{error}} as they were written (see issue #77). Also exported as
* `po2json.mfReplacements` to build a replacement list of your own from.
*/
module.exports = [
{
pattern: /%(\d+)(?:\$\w)?/g,
replacement: (_, n) => `{${n - 1}}`
},
{
pattern: /%\((\w+)\)\w/g,
replacement: '{$1}'
},
{
pattern: /%\w/g,
replacement: function () { return `{${this.n++}}` },
state: {n: 0}
},
{
pattern: /%%/g,
replacement: '%'
}
];
43 changes: 34 additions & 9 deletions lib/parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@
* @return {Object|String} Translation JSON
*/
const g2m = require('gettext-to-messageformat');
const mfReplacements = require('./mfReplacements');

// gettext-parser >= 4 keeps the canonical header case ("Plural-Forms"), while
// po2json has always emitted lower-cased header names. Keep our output stable.
function lowerCaseHeaders(headers) {
return Object.keys(headers || {}).reduce(function (lowerCased, header) {
lowerCased[header.toLowerCase()] = headers[header];
return lowerCased;
}, {});
}

module.exports = function (buffer, options) {

Expand All @@ -20,6 +30,7 @@ module.exports = function (buffer, options) {
charset: 'utf8',
fullMF: false,
mfOptions: {},
'escape-params': true,
};

for (const property in defaults) {
Expand All @@ -30,17 +41,30 @@ module.exports = function (buffer, options) {
let mfTranslations = {};
let result = {};

// defer to gettext-to-messageformat for the 'mf' format option
// use all g2m default replacements except for: pattern: /[\\{}#]/g, replacement: '\\$&'
// defer to gettext-to-messageformat for the 'mf' format option, it escapes
// braces, hashes and backslashes unless 'escape-params' is off (issue #77),
// any mfOptions given by the caller still win over both
if (options.format === 'mf') {
const poString = buffer.toString();
// if the Plural-Forms header is missing, g2m needs a function or will throw an error
const mfOptions = (poString.includes('"Plural-Forms:')) ? options.mfOptions : Object.assign({}, {
pluralFunction: () => 0
}, options.mfOptions);
const mfOptions = Object.assign(
{},
options['escape-params'] ? null : {replacements: mfReplacements},
(poString.includes('"Plural-Forms:')) ? null : {pluralFunction: () => 0},
options.mfOptions
);
result = Object.keys(mfOptions).length > 0 ? g2m.parsePo(buffer, mfOptions) : g2m.parsePo(buffer);

if (options.fullMF) {
if (result && result.headers) {
result.headers = lowerCaseHeaders(result.headers);
}
// messageformat >= 3 (@messageformat/core) reads the plural categories
// from `cardinals`, gettext-to-messageformat only sets `cardinal`
if (result && result.pluralFunction && !result.pluralFunction.cardinals) {
result.pluralFunction.cardinals = result.pluralFunction.cardinal;
}

return options.stringify ? JSON.stringify(result, null, options.pretty ? ' ' : null) : result;
}

Expand All @@ -67,14 +91,15 @@ module.exports = function (buffer, options) {
}

// Parse the PO file
const parsed = require('gettext-parser').po.parse(buffer, defaults.charset);
const parsed = require('gettext-parser').po.parse(buffer, {defaultCharset: options.charset});
const headers = parsed.headers ? lowerCaseHeaders(parsed.headers) : null;

// Create gettext/Jed compatible JSON from parsed data
const contexts = parsed.translations;

Object.keys(contexts).forEach(function (context) {
const translations = parsed.translations[context];
const pluralForms = parsed.headers ? parsed.headers['plural-forms'] : '';
const pluralForms = headers ? headers['plural-forms'] : '';

Object.keys(translations).forEach(function (key, i) {
const t = translations[key],
Expand Down Expand Up @@ -104,8 +129,8 @@ module.exports = function (buffer, options) {
});

// Attach headers (overwrites any empty translation keys that may have somehow gotten in)
if (parsed.headers) {
result[''] = parsed.headers;
if (headers) {
result[''] = headers;
}

if (options.format === 'mf') {
Expand Down
Loading