Framework
Features

Internationalization

Every user-facing string lives in a per-language JSON file and is read through NLS, so the same code renders in whatever language the request is in.

Configuration

IntlConfig is where the layer is set up. In a config file it sets the default language and points at your copy directories — strings, plus the email and notification content:

config/Intl.config.php
use Framework\Intl\IntlConfig;

IntlConfig::setDefaultLanguage("en");
IntlConfig::setStringsDir("nls/strings");
IntlConfig::setEmailsDir("nls/emails");
IntlConfig::setNotificationsDir("nls/notifications");

The defaults are those same nls/… paths, so you only call the setters to move them. The default language is the fallback used whenever no language is set on the request.

Two more take the directories the check reads: addScriptDir() for the ones where your own apps keep their strings, and addSourceDir() for the ones where the strings are used. Nothing else reads either of them.

The string files

The languages live inside the strings directory set in the config, and nowhere else. Each is one JSON file named by its code, and the code is the file name: en.json is the language en. The NAME key holds the name of the language, the one a picker shows, and the rest of the keys are shared across the files — you translate the values:

nls/strings/en.json
{
    "NAME":                   "English",
    "GENERAL_ACCEPT":         "Accept",
    "PRODUCT_CREATED":        "The product was created",
    "PRODUCT_COUNT_SINGULAR": "{0} product",
    "PRODUCT_COUNT_PLURAL":   "{0} products"
}
nls/strings/es.json
{
    "NAME":                   "Español",
    "GENERAL_ACCEPT":         "Aceptar",
    "PRODUCT_CREATED":        "El producto fue creado",
    "PRODUCT_COUNT_SINGULAR": "{0} producto",
    "PRODUCT_COUNT_PLURAL":   "{0} productos"
}

Only the files with a NAME key become languages. A file without one can still be read through NLS by naming its code — strings for a tool, or a variant not offered to anyone — it just is not listed as a language anywhere.

The files are written to mirror each other, key for key and line for line, so a key sits at the same place in all of them. Checking them is what says whether they still do.

The Language class

Language is generated, not written. At build time the framework lists the files of the strings directory and reads each one, and every file with a NAME key becomes a language: the file name is the code, and the NAME is the name. The result is written as a class of your app, so the languages it offers are known at compile time rather than kept in a list someone has to maintain:

The generated Language
public static function getRootCode(): string {
    return "en";
}

public static function getAll(): array {
    return [
        "en" => "English",
        "es" => "Español",
    ];
}

Three rules decide what comes out:

  • The root is the default language of the config. If no file carries that code, the first language found becomes the root instead.
  • The order puts the root first and sorts the rest by name, so a picker opens on the language most people want.
  • With no languages at all — an empty directory, or files that all lack a NAME — the list is created with English alone, so there is always one to answer with.

Rebuild after adding a file, renaming one, or changing a NAME: until then the class still holds the languages of the last build. What the class offers:

use Framework\System\Language;

Language::getAll();          // [ "en" => "English", "es" => "Español" ]
Language::getRootCode();     // the root language code
Language::isValid("es");     // whether it is one of the languages
Language::getCode("es");     // the code, or the root one for a code not listed
Language::getSelect();       // a Select list, ready for a dropdown

getSelect() is the quickest way to render a language picker; feed the chosen code back into the credential with Credential::setLanguage() and every later request follows it.

The current language

You normally never set the language yourself. When a request is authenticated, Auth reads the language stored on the signed-in credential and calls NLS::setLanguage() with it — so from that point on every string resolves in the user's language automatically. While an admin is signed in as another user, the admin's language wins, so the interface stays in the language they read. With no user, the configured default applies.

use Framework\Intl\NLS;

NLS::getLanguage();       // the language in effect for this request
NLS::setLanguage("es");   // override it — rarely needed

The catch is that this only happens when there is a user. An API request authenticates with a static token and carries no credential, and the same is true of a console command or a cron run — so nothing sets a language and every string falls back to the configured default. In those contexts pass the language explicitly, either per call or once for the whole run:

// Per call — the second argument of every read method
NLS::getString("PRODUCT_CREATED", $language);
NLS::format("HOME_WELCOME", [ $name ], $language);

// …or set it once, from whatever the API request carries
NLS::setLanguage($request->getString("language"));
A credential request sends its language as xLangcode, which Framework::execute() applies for you. An API request has no such step — decide where the language comes from (a request field, the account you are acting for) and set it yourself.

Reading strings

NLS is the class you read through — every method is static and takes an optional language as its last argument. NLS::getString() resolves a key in the current language, so most calls take just the key:

use Framework\Intl\NLS;

NLS::getString("GENERAL_ACCEPT");        // "Accept"
NLS::getString("GENERAL_ACCEPT", "es");  // force a language

Keys can hold more than a single string:

MethodReturns
NLS::getString()One translated string.
NLS::getList()A key holding an array of strings.
NLS::getIndex()One entry of such a list — how enums resolve their labels.
NLS::getMap()A key holding a map of values.
NLS::getSelect()A list turned into Select options.
NLS::getAll()The raw value behind a key.

Placeholders

Strings interpolate with numbered {0} placeholders, filled in order by NLS::format(). Numbering them means a translation can reorder the values when its grammar needs a different order:

// en: "Welcome back, {0}. You have {1} messages."
// es: "Tiene {1} mensajes, {0}."
NLS::format("HOME_WELCOME", [ $name, $count ]);

Two more helpers finish a value before it reaches a string — NLS::toYesNo() renders a boolean in the user's language, and NLS::formatNumber() formats a number for it:

NLS::toYesNo(true);            // "Yes" — "Sí" in Spanish
NLS::formatNumber(1234.5);     // the number in the language's format

// …then drop them into a string
NLS::format("ORDER_SUMMARY", [ NLS::formatNumber($total), NLS::toYesNo($isPaid) ]);

Plurals

Declare a _SINGULAR and a _PLURAL key, and NLS::pluralize() picks between them by count — passing the count in as {0}, so you never build the sentence by hand:

// "PRODUCT_COUNT_SINGULAR": "{0} product"
// "PRODUCT_COUNT_PLURAL":   "{0} products"

NLS::pluralize("PRODUCT_COUNT", 1);   // "1 product"
NLS::pluralize("PRODUCT_COUNT", 7);   // "7 products"

When the count comes from a list you already have, NLS::pluralizeList() takes the list itself and uses its size, passing the joined values in as the placeholder:

// "TAG_ADDED_SINGULAR": "Added the tag {0}"
// "TAG_ADDED_PLURAL":   "Added the tags {0}"

NLS::pluralizeList("TAG_ADDED", [ "New" ]);                 // "Added the tag New"
NLS::pluralizeList("TAG_ADDED", [ "New", "Hot", "Sale" ]);  // "Added the tags New, Hot and Sale"

Joining values

Listing values in a sentence is language-specific — the separator and the final conjunction change. NLS::join() builds that list for you:

NLS::join([ "New", "Hot", "Sale" ]);            // "New, Hot and Sale"
NLS::joinWithAnd([ "New", "Hot" ]);             // "New and Hot"
NLS::joinWithOr([ "New", "Hot" ]);              // "New or Hot"

To put that list inside a string, NLS::formatJoin() does both steps at once — it joins the values and drops the result into the key as {0}:

// "FILTER_APPLIED": "Filtering by {0}"

NLS::formatJoin("FILTER_APPLIED", [ "New", "Hot", "Sale" ]);
// "Filtering by New, Hot and Sale"

NLS::formatJoin("FILTER_APPLIED", $tags, useOr: true);
// "Filtering by New, Hot or Sale"

Localized urls

NLS::url() and NLS::urlPath() build a url whose segments are themselves translated keys, so a link reads naturally in each language:

NLS::url([ "URL_PRODUCTS", "URL_NEW" ]);   // /productos/nuevo in Spanish

Checking the files

The files drift apart with use: a key is added to one and forgotten in the other, a line moves, a Select loses an option. The nlsCheck command compares the files of a directory against each other and fails when they no longer match, so it can run alongside the other checks before merging:

./framework nlsCheck

It reads the three directories of the config, plus every app directory added to it. What is compared depends on what the files hold:

DirectoryCompared by
StringsThe keys, the keys inside each of them, and the line each key is written at.
Emails and notificationsThe keys, the keys inside each of them, and the order of the blocks.
App directoriesThe same as the strings, since they are written the same way.

The keys inside a key are the options of a Select, the entries of a list, and the subject and message an email is made of. The lines are compared where the files mirror each other line for line, and the order where a block is as long as its copy needs. The NAME key is skipped, since each strings file names its own language.

No file is taken to be the right one. The error is reported for the files that are on their own, against the ones that agree, and when only two of them disagree the file of the default language is the one taken to be right. There is a section per directory, naming the path it read, and a directory with no files is left out:

Strings: nls/strings
- Found 2 errors in es.json
  - GENERAL_ACCEPT is missing, and is in en.json and pt.json
  - SELECT_STATUS is a string, and a Select in en.json and pt.json
- Found 1 error in pt.json
  - PRODUCT_CREATED is at line 49, and at line 48 in en.json and es.json

Emails: nls/emails
- Found 1 error in es.json
  - position 3 holds Reset, and Welcome in en.json

Notifications: nls/notifications
- Compared 2 files, there are no errors

The keys are what everything else rests on, so the lines and the order are only reported once they match: a key that only one file holds moves every key below it in that file, and reporting those as well would bury the one thing to fix.

The keys that are used

The files agreeing with each other says nothing about whether the code still asks for what they hold. Point the check at the directories where the strings are used and it answers two more questions: which keys nothing uses, and which keys the code asks for that no file defines. Nothing below runs until at least one is added:

config/Intl.config.php
IntlConfig::addSourceDir("src");
IntlConfig::addSourceDir("../desktop/src");

Every .php, .js, .jsx, .mjs, .ts and .tsx file under them is read, the language files themselves excluded. The framework's own source is always read as well, since a key like GENERAL_AND is used by NLS::join() without your app ever naming it.

A key counts as used wherever its name shows up — in a call, in an attribute of a component, or on its own, since the answer of the server is often a key the app renders. Two conventions are followed: a plural pair is used by the key both halves are written from, so NLS::pluralize("PRODUCT_COUNT") covers PRODUCT_COUNT_SINGULAR and PRODUCT_COUNT_PLURAL; and a key the code completes is only ever named by its start, so "AUTOMATION_ERROR_" . $type covers every key of that family.

A key is asked for by name in the places that take one, and those are what a missing key is reported from:

WhereWhat is read
NLS::getString() and every other method of NLS, in both languagesEvery key given to it, a list of them included.
Response::success(), warning() and error()The key of the message.
$errors->field = "KEY" and $errors->add()The key of the error, which the app renders.
<Button message="KEY" />, in an app fileOnly when the key holds an underscore and its family already exists.

That last rule is what keeps the report readable: an attribute takes an action of the component as readily as a key — action="DELETE" — and a bare word is not enough to tell them apart. A key that would start a family of its own is the price of that, and it shows up as an unused key on the other side instead.

The keys nothing uses are reported under the directory that defines them, and the ones nothing defines under the sources that ask for them, with the file and line of the first one:

Strings: nls/strings
- Found 2 keys that no source uses
  - CAMPAIGN_ERROR_EXTERNAL_ID
  - GENERAL_APPLY

Sources: src and ../desktop/src
- Found 2 keys that no file defines
  - GENERA_ERROR_STATUS, used in src/Flow/Node/AssignDataNode.php:315
  - GENERAL_PHONE, used in ../desktop/src/Components/App/Client/User/UserDetails.jsx:57

Both count as errors, so the command fails on them the way it does on a key two files disagree on.

The app files

The apps around the backend — a front end, an admin — keep their own strings beside their source, one file per language written as a script that exports an object. Add each directory by name, and the check reads it as one more section. Nothing else reads them, since the app that owns them is the one that does:

config/Intl.config.php
use Framework\Intl\IntlConfig;

IntlConfig::addScriptDir("Desktop Strings", "../desktop/src/NLS/Strings");
IntlConfig::addScriptDir("Desktop Urls", "../desktop/src/NLS/Urls");

The name is what heads the section of the report, and the directory is read from the app base, so a path that climbs out of it with ../ is how a sibling app is reached. The files are named by language code the same way, en.js beside es.js:

../desktop/src/NLS/Strings/en.js
const strings = {

    // General
    GENERAL_ACCEPT : "Accept",
    GENERAL_CANCEL : "Cancel",

    SELECT_STATUS  : {
        "active" : "Active",
        "paused" : "Paused",
    },
    DATE_TIME_DAYS : [
        "Sunday",
        "Monday",
    ],
};

export default strings;

A file is either a JSON or a script like that one, and both are read the same way — the difference is only in what a script is allowed to write: a key without quotes, a comma after the last one, and comments. What surrounds the object is ignored, so how it is declared and exported is up to the app.