Skip to content

feat: i18n: --lang translates report titles, headings, month names and the ui/web interfaces (#1025, #231) - #2736

Draft
acinader wants to merge 11 commits into
hledgerorg:mainfrom
acinader:i18n-poc
Draft

acinader wants to merge 11 commits into
hledgerorg:mainfrom
acinader:i18n-poc

Conversation

@acinader

@acinader acinader commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Translations for hledger's own output text, following the list discussion
(https://groups.google.com/g/hledger/c/PmcY3J8WiDc), #1025 and #231. German is
the second language.

What it does

hledger bs --lang de:

Bilanz 2008-12-31

                    || 2008-12-31
====================++============
 Vermögen           ||
--------------------++------------
 assets:bank:saving ||         $1
 assets:cash        ||        $-2
--------------------++------------
                    ||        $-1
====================++============
 Verbindlichkeiten  ||
--------------------++------------
 liabilities:debts  ||        $-1
--------------------++------------
                    ||        $-1
====================++============
 Überschuss:        ||          0
  • A new general option --lang=LANG (usable in config files) selects a
    translation catalog. It translates the compound reports' titles, section
    titles and interval words, the balance and budget report titles and their
    valuation descriptions, the Total/Average column headings and the Net: row
    in text output, month names in period headings, and FODS sheet names.
  • hledger-ui translates its menu and screen names.
  • hledger-web serves each request in the viewer's language: ?_LANG=de
    (remembered in a cookie), the _LANG cookie, the browser's Accept-Language,
    then the server's --lang. Pages, the add form and its validation messages,
    the help dialog and the file management pages are covered.
  • Translations are gettext PO files in hledger-lib/locale/, embedded at
    build time. A user can override or add a language by dropping
    ~/.config/hledger/locale/LANG.po in place, without rebuilding: a
    translator can work against a release binary.
  • English output is unchanged byte for byte: with no --lang, every lookup
    is the identity. Checked by diffing the previous build's output for a few
    dozen report invocations across output formats, and by the existing test
    suites, which pass unchanged.

Design, against the points raised on the list

  • No i18n library. haskell-gettext, hgettext and i18n are all absent from the
    Stackage snapshot, and haskell-gettext last shipped in 2019. The mechanism
    is one module, Hledger.Utils.I18n, with no new dependency: a small PO
    parser (including the Plural-Forms rule), lookup functions tr, trc
    (contexts), trf ({placeholders}), trn (plurals), and language tag
    handling. The library is agent-generated (Claude Session) and
    should be agent-maintained. Goal is to make implementation compliant
    with existing tools for localization.
  • Reports first, complete translations. The German catalog translates all
    180 strings. The commits are split so that the hledger-lib and CLI parts
    (commits 2 and 3) stand on their own; the hledger-ui and hledger-web
    commits can be deferred.
  • --lang plus a field on ReportOpts. The field, translations_, holds the
    loaded catalog rather than a tag, because the renderers are pure and the
    catalog is loaded once in IO; the tag is inside it.
  • English by default. --lang=auto, which follows LANGUAGE, LC_ALL,
    LC_MESSAGES and LANG with gettext's rules, is implemented but opt-in; I am
    happy to drop it from this PR if you would rather defer it.
  • Config files: documented, with an example, in the new Languages section of
    the manual.
  • --title and --subreport-titles win over translation, and number formats
    are untouched (they come from the journal).
  • Titles are report data shared by every output format, so like --title
    they are translated in csv/tsv/json title rows too; column headings in
    those formats stay English. If you would rather csv stay entirely English,
    the alternative is keeping titles structured until rendering, as
    translate report texts to user language using haskell-gettext #2735
    does; that is a bigger refactor and can follow.

One deliberate divergence: PO catalogs rather than Shakespeare's .msg files.
hledger-web does use Shakespeare's I18N interface (RenderMessage, _{...}
in templates), with these catalogs behind it. PO was chosen because complete
translations come from translators, and PO is what their tools (Poedit,
Weblate) speak: it gives contexts, translator notes, a fuzzy workflow, and
lets someone test a catalog against a release without a Haskell build. My POV being
that gettext is mature and we can reliably maintain the functionality we want
with agents maintaing the i18n core code.

Docs and tooling

  • Manual: a Languages section, the environment variables, the general
    options list; hledger-web's manual: a Language section.
  • doc/TRANSLATING.md: a step-by-step guide for translators who are neither
    programmers nor professionals, with a worked example.
  • tools/i18n-extract.py and just i18n-pot, i18n-check, i18n-merge,
    i18n-pseudo maintain the catalogs.

Tests

hledger functional suite (9 new cases in hledger/test/i18n.test),
hledger-lib unit tests for the parser, plural rules and tag handling,
hledger-web yesod tests for language selection, the cookie and a hostile
catalog rendered as text, and a Playwright spec for the German UI. All four
packages build warning-free.

Caveats

  • I have not personally walked through the steps in doc/TRANSLATING.md as
    a translator would; the guide was checked mechanically (a throwaway French
    catalog placed in the config directory, exercised through --lang fr), not
    by a person following it cold.
  • No performance testing. With no --lang option nothing is parsed or read,
    so the default path should be unaffected. Any --lang value, including
    en and auto, lists the override directory and parses the built-in
    catalogs (a few hundred entries). I have not measured either path.

Follow-ups

  • The manual links to hledger.org/TRANSLATING.html; that needs the usual
    hledger_site symlink and SUMMARY entry, which I will open separately.
  • Skills and documentation to help Agents assist with translation and
    code contributions that touch localizable strings.

Not in this PR

The hledger-ui help dialog and register labels (need a layout rework),
stats label widths, html/fods column headings, and translating error
messages or manuals.

Terminology

The German catalog uses the terms Henning chose in #2735 (Einnahmen,
Ausgaben, Einnahmenüberschussrechnung, Vermögen, Gesamt, Überschuss,
Einheit), so the two agree; see hledger-lib/locale/README.md. The one
place I kept a different word is hledger-web's account column (Konto).
examples/i18n/de.journal names its accounts aktiva and passiva, which
German speakers may want to reconcile.

AI usage: designed and implemented with Claude Code, reviewed, tested, and edited by the author.

🤖 Generated with Claude Code

https://claude.ai/code/session_01G2VXnprjHmXZR8tgWPV3vz

@acinader

acinader commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Please see https://github.com/acinader/hledger/blob/i18n-poc/doc/TRANSLATING.md as an entry point for reviewing this draft pr.

@acinader
acinader force-pushed the i18n-poc branch 3 times, most recently from 0697094 to 2f50272 Compare September 17, 2026 23:27
@acinader

acinader commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

I don't speak French, so it's hard to tell....

Poedit tool for editing translation files.

image

Some screen shots

image image image image image image

pretty cool.

@acinader

This comment was marked as off-topic.

acinader added a commit to acinader/hledger that referenced this pull request Sep 20, 2026
"Monthly Balance Sheet" was an interval word plus a report name, joined
by a space in the code. One adjective form had to fit every report, and
the space could not be dropped. Now each compound report lists its own
title per interval, so a translator renders the whole phrase as the
language needs, with no grammar logic in Haskell. The German catalog
grows from 14 to 44 title entries. English output is unchanged.

this should bring hledgerorg@8ddb03f changes into hledgerorg#2736

should bring hledgerorg#2736 upto parity with hledgerorg#2735

AI usage: drafted with Claude Code, reviewed and edited by the author.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…y ago"

"Biannual" means twice a year; the title of a report with a two-year
interval now says "Biennial". And stats no longer says "1 days ago".

The interval word is about to become a translation key, and every later
change to an English string invalidates its translations, so it is fixed
first; the stats wording is fixed while looking at it.

AI usage: drafted with Claude Code, reviewed and edited by the author.

Claude-Session: https://claude.ai/code/session_01G2VXnprjHmXZR8tgWPV3vz
A Translations value maps English text to a translation; tr, trc, trf
and trn look text up, with contexts, {name} placeholders and plural
forms. Catalogs are gettext PO files, so the usual translator tools can
edit them; a German catalog is built in (hledger-lib/locale/de.po,
embedded at build time), and a user can override or add a language with
$XDG_CONFIG_HOME/hledger/locale/LANG.po. The parser is ours, since no
Haskell gettext library is in the snapshot, and handles what Poedit,
Weblate and msgmerge write, including the Plural-Forms expression.

Lookups are pure functions of the Translations value: there is no
process-global locale, so hledger-web can serve each request in its own
language. With the default (English) value every lookup is the identity,
so existing output is unchanged byte for byte.

hledger-lib/locale/README.md explains the catalogs to translators, with
the German terminology choices.

ReportOpts gains translations_ (English by default), and
showPeriodAbbrevWith and showDateSpanAbbrevWith render month names from a
given TimeLocale; the --lang option and the report renderers that use
these follow in the next commit. Language tags are normalized and matched
with RFC 4647 truncation (de-CH falls back to de), and the "auto" setting
follows LANGUAGE, LC_ALL, LC_MESSAGES and LANG with gettext's rules, so
LC_ALL=C still means English.

AI usage: drafted with Claude Code, reviewed and edited by the author.

Note: I made no real effort to read or understand
    hledger-lib/Hledger/Utils/I18n.hs (yet?). I anticipate that it
    will be maintained by agent and covered with appropriate tests.

Claude-Session: https://claude.ai/code/session_01G2VXnprjHmXZR8tgWPV3vz
A new general option, --lang=LANG (also usable in config files), selects
a translation catalog: a language tag like de, auto (from the
environment), or en, the default. It translates the compound reports'
titles, section titles and interval words, the balance and budget report
titles and their valuation descriptions, the Total/Average column
headings and the Net: row in text output, month names in period
headings (reportPeriodName now takes the ReportOpts and uses the
translated TimeLocale), and the sheet names of FODS output.

Titles are built once and shared by every output format, so like
--title they are translated in CSV, TSV and JSON output too; column
headings in those formats stay English, and an explicit --title or
--subreport-titles is used as given. The composed titles are now single
templates with placeholders, so a translator can reorder them.

Docs: a Languages section, the environment variables, and the general
options list.

AI usage: drafted with Claude Code, reviewed and edited by the author.

Claude-Session: https://claude.ai/code/session_01G2VXnprjHmXZR8tgWPV3vz
The help dialog and the register screen's composed labels are left for
a follow-up; they need a layout rework to fit longer text.

AI usage: drafted with Claude Code, reviewed and edited by the author.

Claude-Session: https://claude.ai/code/session_01G2VXnprjHmXZR8tgWPV3vz
hledger-web now loads every available translation catalog at startup and
chooses one per request: a _LANG query parameter (remembered in a
SameSite cookie, only when it names an available catalog), the _LANG
cookie, the browser's Accept-Language header (each preference tried with
its subtags dropped, so de-CH gets de), then the server's --lang. Pages
say which language they are in (<html lang>) and that they vary by it
(Vary), for caches in front of a shared server.

Templates use _{HMsg "..."} and _{HMsgc "context" "..."}, which look up
the request's catalog through Yesod's RenderMessage; handlers get the
same Translations from getViewData. The add form's validation messages
and yesod-form's own messages follow too, and the placeholders that
hledger.js writes on added rows come from the page.

Translations are viewer-controlled text and are rendered as text,
in content and in attributes; a test with a hostile catalog checks it.

AI usage: drafted with Claude Code, reviewed and edited by the author.

Claude-Session: https://claude.ai/code/session_01G2VXnprjHmXZR8tgWPV3vz
tools/i18n-extract.py collects the translatable strings (tr, trc, trf,
trn, i18n, i18nc, HMsg, HMsgc) into hledger-lib/locale/hledger.pot with
source references, translator comments and the python-brace-format flag
that makes Poedit and Weblate check placeholders. It can also write a
pseudo-locale catalog, which shows any output that is still English, and
check the catalogs for stale and untranslated entries.

just i18n-pot, i18n-check, i18n-merge (msgmerge), i18n-pseudo.

AI usage: drafted with Claude Code, reviewed and edited by the author.
Note: I anticipate that i18n-extract.py will be agent maintained and I
    made no effort to understad it.
Claude-Session: https://claude.ai/code/session_01G2VXnprjHmXZR8tgWPV3vz
A step-by-step guide for contributing a language, written for people
who are neither programmers nor professional translators: creating a
catalog from the template with Poedit or a text editor, what
placeholders, contexts and the developer notes mean, trying the file
against an installed hledger without building anything, sending it in,
and keeping it current. Ends with the rules and tooling for developers.
Linked from the developer docs index, the contributor quick start, the
locale directory and the manual.

AI usage: drafted with Claude Code, reviewed and edited by the author.

Claude-Session: https://claude.ai/code/session_01G2VXnprjHmXZR8tgWPV3vz
The journal and register handlers still set their titles with plain
setTitle, so a French or German viewer got "journal - hledger-web" in
the tab while the rest of the page was translated. The edit and upload
pages already used setTitleI. Both titles are now catalog entries, with
a translator note, and the German catalog has them. The browser test
for German checks the tab titles too; since the English ones are lower
case, the check cannot pass on an untranslated title.

AI usage: drafted with Claude Code, reviewed and edited by the author.
So that the cost of catalogs can be kept an eye on as they grow, --debug
now reports, once, how long parsing the built-in catalogs took, and for
each language loaded: where its catalog came from (built-in and/or the
path of the user's file), how many entries it has, roughly how much text
it holds, and how long loading and merging took. The size is measured
after the timer stops, and none of it is computed without --debug.

  translations: parsed 1 built-in catalogs, 1.8 ms
  translations: loaded de (built-in, ~/.config/hledger/locale/de.po): 179 entries, ~8 KB of text, 0.5 ms

The built-in parse is timed in availableLanguages, because listing the
built-in languages is what forces it.

AI usage: drafted with Claude Code, reviewed and edited by the author.
quotedString collected every character of every quoted string into a
String one at a time, then packed it; since nearly all of a catalog is
quoted strings, that was most of the parse. It now takes runs of
ordinary characters as slices of the input and handles only escapes a
character at a time.

A 920 KB catalog of 4000 entries parses in 36 ms instead of 137 ms
(25 MB/s instead of 7), with a quarter of the transient allocation.
The built-in German catalog goes from about 1.8 ms to about 0.9 ms.

AI usage: drafted with Claude Code, reviewed and edited by the author.
"Monthly Balance Sheet" was an interval word plus a report name, joined
by a space in the code. One adjective form had to fit every report, and
the space could not be dropped. Now each compound report lists its own
title per interval, so a translator renders the whole phrase as the
language needs, with no grammar logic in Haskell. The German catalog
grows from 14 to 44 title entries. English output is unchanged.

this should bring hledgerorg@8ddb03f changes into hledgerorg#2736

should bring hledgerorg#2736 upto parity with hledgerorg#2735

AI usage: drafted with Claude Code, reviewed and edited by the author.
acinader added a commit to acinader/hledger that referenced this pull request Sep 20, 2026
"Monthly Balance Sheet" was an interval word plus a report name, joined
by a space in the code. One adjective form had to fit every report, and
the space could not be dropped. Now each compound report lists its own
title per interval, so a translator renders the whole phrase as the
language needs, with no grammar logic in Haskell. The German catalog
grows from 14 to 44 title entries. English output is unchanged.

this should bring hledgerorg@8ddb03f changes into hledgerorg#2736

should bring hledgerorg#2736 upto parity with hledgerorg#2735

AI usage: drafted with Claude Code, reviewed and edited by the author.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@acinader
acinader force-pushed the i18n-poc branch 2 times, most recently from 19b2d02 to b0251c0 Compare September 20, 2026 23:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant