@3sln/bab-extract
v0.2.0
Published
Extract translatable messages from source for @3sln/bab.
Maintainers
Readme
@3sln/bab-extract
Pull the translatable messages out of a codebase that uses
@3sln/bab, with a parser rather than a
pattern.
npx @3sln/bab-extract 'src/**/*.{js,jsx}' -x '**/*.test.js' -o messages.json@3sln/bab itself has no dependencies and never will — this is a separate
package so that an application that only renders strings never installs a
JavaScript parser.
Why a parser
bab's msgid is the source text, so an untranslated build already works and extraction is never a step you have to run before the app renders. What it is for is finding out what there is to translate — and getting that wrong is expensive, because a string that quietly fails to be extracted is a string nobody ever translates.
A regex over tr\( cannot tell a translator from a variable that happens to be
called tr, cannot see that t in one file is the same translator as tr in
another, and cannot read a scope that was fixed three modules away. This reads
the source with acorn and follows the
actual bindings:
// src/i18n.js
import { createTranslator } from '@3sln/bab';
export const tr = createTranslator({ locale });
export const player = tr.scope('player');
// src/components/Header.jsx
import { player } from '../lib/i18n.js';
const stats = player.scope('stats');
stats('Goals'); // extracted under the scope `player.stats`Imports, re-exports (export {tr as t} from …, export *), namespace imports,
default exports, destructuring and CommonJS require/module.exports are all
followed, across as many modules as it takes, as long as those modules are in
the include set. Shadowing is respected: a parameter named tr is not the
translator, and is not extracted.
Anything it cannot prove is a warning rather than a silent omission:
src/components/Row.jsx:5:46: message id is not a literal; it cannot be extractedOutput
--format json (the default) is a bab catalogue — the object
ObjectCatalogue takes:
{
"": { "Sign out": null },
"player": { "{#} days": { "one": null, "other": null } }
}Untranslated entries are null and not "", because null is what
ObjectCatalogue reads as "no translation" and falls back to source text
from. An empty string is a translation to nothing, and would blank the
string wherever it is used. The plural categories are the ones the locale
actually has, so --locale ru writes one / few / many / other.
--format pot is gettext, for the translation platforms that speak it. bab
keys a plural message on its plural form, which is the reverse of gettext's
convention, so msgid carries the singular given to .singular() and
msgid_plural carries the catalogue key. The header says as much
(X-Bab-Plural-Key) for whatever puts the PO back into a catalogue.
#. the button that ends a session, a verb
#: src/components/Header.jsx:7
msgid "Sign out"
msgstr ""Messages come out sorted by scope and id, and references sorted by file, so re-running over an unchanged tree produces an unchanged file.
Notes to translators
A comment tagged translators: on the line above a call is carried through to
the output — #. in a POT.
// translators: the button that ends a session, a verb
tr('Sign out');--comment-tag changes the tag.
The command
bab-extract [options] [include-glob...]
-i, --include <glob> Files to read. Repeatable; also accepted positionally.
Default: **/*.{js,mjs,cjs,jsx}
-x, --exclude <glob> Files to skip. Repeatable. node_modules is always skipped.
-f, --format <name> json (a bab catalogue) or pot (gettext). Default: json
-o, --out <file> Write here instead of stdout.
-l, --locale <tag> Locale whose plural categories the json template gets.
--fill <mode> What untranslated entries hold: null (default), source
or empty.
-m, --module <spec> Specifier that means bab itself. Repeatable.
-k, --keyword <name> Treat a call to this bare name as a translator call
even when it cannot be traced to one. Repeatable.
-c, --cwd <dir> Resolve globs and report paths relative to here.
--comment-tag <s> Prefix marking a comment as a note to translators.
--strict Exit non-zero if anything was warned about.
-q, --quiet Do not print warnings.Two of those are worth expanding on.
--keyword is for a translator that arrives as a parameter or a prop —
function Row({tr}) { return tr('Hello') }. Nothing static can trace that back
to where it was made, so -k tr says "trust me, a call to tr is a message".
Its scope is unknowable, so such messages are extracted unscoped, which is also
the bucket every scope falls back to at lookup time.
--module is the set of specifiers that mean bab itself, @3sln/bab and
bab by default. Add to it if your build aliases bab to something else.
Globs are matched by tinyglobby.
node_modules and .git are always excluded.
Exit codes: 0 fine, 1 warnings under --strict, 2 bad usage.
As a library
import { extract, format, formatJSON, formatPOT } from '@3sln/bab-extract';
const { messages, warnings, files } = await extract({
include: ['src/**/*.js'],
exclude: ['**/*.test.js'],
cwd: process.cwd(),
keywords: ['tr'],
});
await writeFile('messages.json', formatJSON(messages, { locale: 'es' }));Each message is
{
scope: 'player',
id: '{#} days',
plural: true,
singular: '{#} day', // or null
comments: ['a note to translators'],
references: [{file: 'src/x.js', line: 12, column: 3}],
}extractSources([{file, code}], options) runs the same analysis over sources
already in hand — a bundler plugin, an editor, a test — resolving imports
between them without touching the filesystem. extractSource(code, {file}) is
the one-module version; nothing can be followed across an import there, so the
translator has to be created in that module or named with keywords.
What it does not do
TypeScript. Acorn parses JavaScript and JSX; a .ts file needs a different
parser, and pretending otherwise would mean falling back to guessing. Run it
over build output, or over the .js/.jsx part of a mixed codebase.
A module that is not in the include set is not read, so a translator imported
from one is not followed — put the module that calls createTranslator in the
globs.
Writing translations back. This extracts; merging a returned PO or JSON into a catalogue is a separate job with different opinions in it.
