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