README.org

August 17, 2025 · View on GitHub

#+title: Fluent

This is a Common Lisp implementation of [[https://projectfluent.org/][Fluent]], a modern localisation system.

See also Fluent's [[https://projectfluent.org/fluent/guide/index.html][Syntax Guide]].

With Fluent, localisations are defined in per-language =.ftl= files as key-value pairs. Many localisations are just simple lookups:

#+begin_example check-start = Validating your system. #+end_example

But Fluent's strength is in the ability to inject values into the line, as well as perform "selections" based on grammatical rules and plural categories:

#+begin_example check-pconf-pacnew-old = { path } is older than its .pacnew by { days -> [one] 1 day. *[many] {$days} days. } #+end_example

#+begin_src lisp :exports both (in-package :fluent) (let* ((loc (parse (uiop:read-file-string "tests/data/aura.ftl"))) (ctx (localisation->fluent loc :en))) (resolve ctx "check-pconf-pacnew-old" :path "pacman.conf" :days 1)) #+end_src

#+RESULTS: : pacman.conf is older than its .pacnew by 1 day.

Per-locale plural rules are provided by the [[https://github.com/fosskers/plurals][plurals]] library.

  • Table of Contents :TOC_5_gh:noexport:
  • [[#compatibility][Compatibility]]
  • [[#usage][Usage]]
    • [[#reading-localisations-from-disk][Reading localisations from disk]]
    • [[#localisation-lookups][Localisation lookups]]
    • [[#fallback][Fallback]]
  • [[#limitations][Limitations]]
  • Compatibility

| Compiler | Status | |-----------+--------| | SBCL | ✅ | | ECL | ✅ | | CMUCL | ✅ | | ABCL | ✅ | | Clasp | ✅ | | CCL | ✅ | | Clisp | ❌ | |-----------+--------| | Allegro | ✅ | | LispWorks | ❓ |

  • Usage

The examples below use =(in-package :fluent)= for brevity, but it's assumed you'll use a nickname in your own code, perhaps =f=.

** Reading localisations from disk

Your localisation files must have the extension =.ftl= and be separated into different subdirectories by their locale:

#+begin_example i18n ├── ar-SA │   └── your-project.ftl ├── bn-BD │   └── your-project.ftl ├── cs-CZ │   └── your-project.ftl ├── de-DE │   └── your-project.ftl ├── en-US │   └── your-project.ftl #+end_example

Each subdirectory can contain as many =.ftl= files as is convenient to you; their contents will be fused when read.

#+begin_src lisp :exports both (in-package :fluent) (fluent (read-all-localisations #p"i18n")) #+end_src

#+RESULTS: : #S(FLUENT : :LOCALE :EN-US : :LOCALE-LANG :EN : :FALLBACK :EN-US : :FALLBACK-LANG :EN : :LOCS #<HASH-TABLE :TEST EQ :COUNT 5 {120DD70B23}>)

As you can see, you pass a parent directory (=i18n/= here), and all =.ftl= files are automatically detected.

** Localisation lookups

Once you have a fully formed =fluent= context, you can perform localisation lookups. Input args into the localisation line are passed as keyword arguments. For example, the following message:

#+begin_example check-pconf-pacnew-old = { path } is older than its .pacnew by { days -> [one] 1 day. *[many] {$days} days. } #+end_example

can be resolved like so:

#+begin_src lisp :exports both (in-package :fluent) (let* ((ctx (fluent (read-all-localisations #p"tests")))) (resolve ctx "check-pconf-pacnew-old" :path "pacman.conf" :days 1)) #+end_src

#+RESULTS: : pacman.conf is older than its .pacnew by 1 day.

A condition will be raised if:

  • The requested locale doesn't exist in the =fluent= context.
  • The requested localisation line doesn't exist in the locale.
  • Expected line inputs were missing (e.g. the =path= and =days= args above).

** Fallback

A "fallback locale" was mentioned above, which can be set when you first create a =fluent= context:

#+begin_src lisp :exports both (in-package :fluent) (let* ((ctx (fluent (read-all-localisations #p"tests") :fallback :ja-jp))) (resolve ctx "sonzai-shinai")) #+end_src

#+RESULTS: : 大変!

In this case, the line =sonzai-shinai= had no localisation within the default =:en-us= locale, so it defaulted to looking within the Japanese locale. More than likely English will be your fallback, with your initial =:locale= being some other localisation target, as in:

#+begin_src lisp :exports both (in-package :fluent) (fluent (read-all-localisations #p"tests") :locale :ja-jp :fallback :en-us) #+end_src

#+RESULTS: : #S(FLUENT : :LOCALE :JA-JP : :LOCALE-LANG :JA : :FALLBACK :EN-US : :FALLBACK-LANG :EN : :LOCS #<HASH-TABLE :TEST EQ :COUNT 2 {12078F9753}>)

You are free to mutate this =fluent= struct at runtime or call =resolve-with= directly to match a user's locale settings in a more dynamic way. For instance, if they change language settings within your app after opening it.

  • Limitations
  • Gap lines in multiline text are not supported.
  • Preservation of clever indenting in multiline text is not supported.
  • For the =NUMBER= function, only the =minimumFractionDigits=, =maximumFractionDigits=, and =type= arguments are supported.
  • The =DATETIME= function has not been implementation.
  • Attributes are not available, so the following is not possible:

#+begin_example -brand-name = Aurora .gender = feminine

update-successful = { -brand-name.gender -> [masculine] { -brand-name } został zaktualizowany. [feminine] { -brand-name } została zaktualizowana. *[other] Program { -brand-name } został zaktualizowany. } #+end_example