lexora
v1.6.0
Published
A lightweight TypeScript helper for managing multi-language strings with optional grammatical support.
Downloads
300
Maintainers
Readme
Lexora 🌍
What is Lexora
Lexora is a lightweight, pipeline-based translation and formatting library for TypeScript. It is designed for applications that need more than simple key-value translations, but still want to stay small, explicit and easy to reason about.
Lexora supports:
- language based string resources
- nested placeholders
- translation metadata
- value pipelines
- plural/form selection
- grammatical articles
- number, date, time, currency and list formatting
- watchable reactive strings
- composable grammar-aware pipelines
Installation
npm install lexoraUsage
Basic
import { LexoraContext } from "lexora";
const ctx = LexoraContext.createWithDefaults();
ctx.loadMultipleStringResourceTranslations({
house: {
en: "house",
de: "Haus",
},
greeting: {
en: "Hello {{house}}",
de: "Hallo {{house}}",
},
});
ctx.language = "de";
ctx.get("greeting");
// "Hallo Haus"String Resources
A string resource can be either a plain string:
house: {
en: "house",
de: "Haus",
}or a tuple with metadata:
house: {
en: "house",
de: ["Haus", { gender: "neuter" }],
}Metadata is useful for language-specific pipeline functions, for example German articles.
Templates
Templates can reference other resources with {{key}}.
ctx.translate("Hello {{name}}", {
name: "Luca",
});Output:
Hello LucaTemplates can also be stored as resources:
ctx.loadMultipleStringResourceTranslations({
welcome: {
en: "Welcome {{name}}",
de: "Willkommen {{name}}",
},
});Pipelines
Pipelines are written with ->.
ctx.translate("{{house->upper}}");Example:
HOUSEPipelines can be chained:
ctx.translate("{{house->prefix('My ')->capitalize}}");Output:
My houseBuilt-in Pipelines
Lexora includes common default pipelines:
upper
lower
trim
capitalize
prefix
suffix
number
currency
date
time
boolean
list
form
switchLanguage packs can add additional pipelines, such as articles.
Plural and Forms
Lexora supports forms for grammatical variants like singular and plural.
point: {
en: [{ _: "point", other: "points" }],
de: [{ _: "Punkt", other: "Punkte" }, { gender: "masculine" }],
}_ is the default form. Use the form pipeline to select the correct form:
ctx.translate("{{count}} {{point->form(count)}}", {
count: 1,
});Output:
1 pointctx.translate("{{count}} {{point->form(count)}}", {
count: 5,
});Output:
5 pointsYou can also select a form explicitly:
ctx.translate("{{point->form(:other)}}");German Articles
With metadata and language packs, Lexora can apply grammatical articles.
house: {
de: ["Haus", { gender: "neuter" }],
}ctx.language = "de";
ctx.translate("{{house->article(nominative)}}");Output:
das HausForms also work together with articles:
ctx.translate("{{point->form(count)->article(nominative)}}", {
count: 5,
});Output:
die PunkteSwitch Pipeline
Use switch for semantic choices, for example gender-based labels.
ctx.loadMultipleStringResourceTranslations({
maleUser: {
en: "user",
de: "Benutzer",
},
femaleUser: {
en: "user",
de: "Benutzerin",
},
});ctx.translate(
"{{gender->switch('male:{{maleUser}}','female:{{femaleUser}}')}}",
{
gender: "female",
}
);Output: Output:
BenutzerinSwitch results can contain nested placeholders and pipelines:
ctx.translate(
"{{gender->switch('male:{{maleUser->upper}}','female:{{femaleUser->upper}}')}}",
{
gender: "female",
}
);Output:
BENUTZERINFormatting
Numbers:
ctx.translate("{{value->number}}", {
value: 1234.56,
});Dates:
ctx.translate("{{value->date(long)}}", {
value: new Date(),
});Currency:
ctx.translate("{{value->currency(USD)}}", {
value: 1234.56,
});Lists:
ctx.translate("{{items->list}}", {
items: ["apple", "banana", "cherry"],
});Call Context
Values passed in the call context override resources.
ctx.translate("{{house}}", {
house: {
en: "villa",
de: "Villa",
},
});Context values can also include metadata:
ctx.translate("{{house->article(nominative)}}", {
house: ["Villa", { gender: "feminine" }],
});Output:
die VillaWatchable Strings
Lexora can create reactive strings that update when the language changes.
import { LexoraContext } from "lexora";
const ctx = LexoraContext.createWithDefaults();
ctx.loadMultipleStringResourceTranslations({
greeting: {
en: "Hello {{user}}",
de: "Hallo {{user}}",
},
});
ctx.language = "en";
const watch = ctx.translateWatch("{{greeting}}", {
user: "Luca",
});
console.log(watch.value);
// -> Hello Luca
watch.on("update", (value) => {
console.log("Updated:", value);
});
ctx.language = "de";
// console:
// Updated: Hallo Luca
console.log(watch.value);
// -> Hallo LucaA watchable string never throws. If a translation fails, the last good value is kept and the
error is reported through the error event and the error property:
const watch = ctx.translateWatch("{{count}} {{point->form(count)}}", { count });
watch.on("error", (error) => {
console.warn("Translation failed:", error);
});
// Also readable synchronously, for example right after creation.
watch.error;An unhandled error event is silent, so register a listener if you want to know about failures.
Error Handling
A translation error is a display problem. Lexora tells them apart by where they come from:
| Kind | Caused by | Default |
|------|-----------|---------|
| value | runtime data - a NaN count, a value of the wrong type, a switch value without a matching case | degrades and warns |
| author | a template or a string resource - a missing pipeline parameter, an unknown key, a syntax error | throws |
Nobody can rule out a bad value at build time, so bad data must never be able to break a render. A mistake in your translations should.
LexoraError
Every error Lexora throws is a LexoraError carrying the context you need to find it:
import { LexoraError } from "lexora";
try {
ctx.translate("{{count}} {{point->form(count)}}", { count: NaN });
}
catch (error) {
if (error instanceof LexoraError) {
error.code; // "INVALID_COUNT" - stable, branch on this instead of the message
error.kind; // "value"
error.key; // "point"
error.pipeline; // "form"
error.parameter; // "count"
error.language; // "en"
error.template; // "{{count}} {{point->form(count)}}"
error.path; // ["[template]", "point"]
error.callContext; // the call context of the translation
error.baseMessage; // the message without the appended context
}
}Value codes: INVALID_COUNT, INVALID_VALUE_TYPE, NO_MATCHING_CASE, MISSING_GENDER, EMPTY_VALUE.
Author codes: MISSING_PARAMETER, INVALID_PARAMETER, INVALID_CASE, MISSING_KEY,
MISSING_TEMPLATE, CIRCULAR_REFERENCE, PIPELINE_NOT_FOUND, PARSE_ERROR, PIPELINE_FAILED.
How pipelines degrade
| Pipeline | Degrades to |
|----------|-------------|
| form | the default form _ |
| switch | the _: case, otherwise an empty string |
| number, currency | the unformatted value |
| date, time | the unformatted value |
| boolean | the unformatted value |
| article | the noun without its article |
Number coercion
All number oriented pipelines (number, currency, form, date, time) accept the same
values: a finite number, a bigint and numeric strings like "5". Everything else -
null, undefined, NaN, Infinity and non numeric strings - counts as missing and degrades.
Options
const ctx = LexoraContext.createWithDefaults({
// Missing keys render defaultValueForMissingKeys instead of throwing.
ignoreMissingKeys: true,
defaultValueForMissingKeys: "?",
// Unknown pipeline functions are skipped instead of throwing.
ignoreMissingPipelineFunctions: false,
// Invalid runtime values degrade instead of throwing.
ignoreInvalidValues: true,
// Every failing pipeline function is skipped, including author errors.
skipFailedPipelineFunctions: false,
// Log degradations. Defaults to true outside of production.
warnOnDegradation: true,
});Set ignoreInvalidValues: false in tests and CI to make bad data loud, and keep it on in
production. To decide per error, use onPipelineError:
const ctx = LexoraContext.createWithDefaults({
onPipelineError: (error, context) => {
reportToMonitoring(error);
if (error.kind === "author") return "throw";
if (error.pipeline === "currency") return { value: "-" };
return "skip";
},
});Returning nothing falls back to the behavior of the options above. When the handler decides, Lexora does not warn on its own - reporting is yours.
Custom pipeline functions
A plain Error thrown by a custom pipeline function is wrapped as PIPELINE_FAILED, an author
error, and keeps throwing. Throw a LexoraError with a value code to opt into degradation, and
declare a fallback to say what your function degrades to:
ctx.loadDefaultPipelineFunction({
name: "percent",
type: "value",
phase: "format",
process: ({ value }) => {
if (typeof value !== "number")
throw new LexoraError("INVALID_VALUE_TYPE", "percent: value must be a number");
return `${Math.round(value * 100)}%`;
},
fallback: ({ value }) => String(value),
});Without a fallback the current value is passed through unchanged.
