Skip to content
loclizr
loclizr.config.ts
import { defineConfig } from 'loclizr'
export default defineConfig({
locales: ['en', 'de', 'de-AT'],
sourceLocale: 'en',
groups: { errors: 'errors' },
severity: { 'ambiguous-source': 'error' },
})

Optional: the defaults below apply without one. Groups and format styles need a file.

  • defineConfig only adds the type. Export the object, not a promise. A missing, null or undefined default export is LZ1001.

  • Discovery takes the first of loclizr.config.ts, .mts, .js, .mjs at the project root, unless --config names one. TypeScript loads through jiti.

  • Fields are re-checked at load time: a failure is LZ1001 config-invalid, exit 2, hint a pasteable example.

  • A field the config does not have is LZ1001 too, so a typo in a JavaScript config, which gets no type check, cannot fall back to the default in silence. The hint names the nearest field:

    outdir: 'src/i18n'
    error LZ1001 config-invalid loclizr.config.js
    `outdir` is not a config field.
    fix did you mean `outDir`?
  • Paths are POSIX, relative to the project root (the CLI’s directory, or --cwd).

Field Type Default Meaning
locales string[] every locale catalogs expands to Locales to compile, one arm each in every message function.
sourceLocale string 'en' if found, else the single locale Defines the message set, argument types and the end of every fallback chain.
catalogs string 'locales/{locale}.json' Path with tokens, never a glob.
catalogFormat 'auto' | 'icu' | 'i18next' 'auto' How each file is read.
i18nextMarkup 'literal' | 'tags' 'literal' Tags in i18next files: text, or markup arguments.
meta string | false 'locales/{sourceLocale}.meta.json' Description sidecar.
Field Type Default Meaning
outDir string 'src/loclizr' Generated tree, inside the project root.
record string | false 'locales/loclizr.context.json' Context record.
augmentLocale boolean true Augment LocaleRegistry in loclizr, which types useLocale().
groups Record<string, string> {} Group name to key prefix, each a typed record in groups.js.
identifiers Record<string, string> {} Catalog key to generated identifier.
Field Type Default Meaning
cookie string 'locale' Cookie setLocale writes and the server reads; pass m.cookie in the server options.
fallback 'bcp47' | Record<string, string[]> 'bcp47' Where a locale finds a message it lacks.
formats.timeZone string unset IANA zone for every date and time format.
formats.number Record<string, IntlOptions> {} Named Intl.NumberFormat styles.
formats.dateTime Record<string, IntlOptions> {} Named Intl.DateTimeFormat styles.
Field Type Default Meaning
scan.include string[] ['src/**/*.{ts,tsx,js,jsx,mts,mjs,svelte,vue,astro}'] Globs for the usage scan.
scan.exclude string[] ['**/node_modules/**', '**/dist/**'] Skipped globs; outDir always added.
severity Partial<Record<RuleName, Severity>> {} Rule name to 'off', 'warn' or 'error'.

IntlOptions is Readonly<Record<string, string | number | boolean>>.

Case Result
locales unset every locale catalogs expands to, sorted
Tag Intl.getCanonicalLocales rejects LZ1002 locale-tag-invalid, build stops
Declared, no catalog LZ1005 catalog-missing, an error that still emits; the locale falls back to source text
Catalog, undeclared LZ1006 catalog-undeclared at warn, file ignored
sourceLocale unset, no en.json, not exactly one locale LZ1004 source-catalog-missing; the hint is a config file to paste, its locales one line per discovered locale and file
sourceLocale not in locales LZ1001
A region tag whose base is undeclared (de-AT without de) LZ1018 locale-base-missing at warn: runtime matching truncates subtags, so Accept-Language: de would silently get English

Exactly one {locale}, at most one {ns}, no {sourceLocale}, no glob characters. Anything else is LZ1001, the hint naming the character.

  • A token matches one path segment or basename stem, never crossing / or ., so en.meta.json and loclizr.context.json stay out of the locale set.
  • A resolved meta or record path the pattern matches is LZ1001: a file is a catalog or an artifact, never both. A record a build already left at such a path is skipped with LZ1006, never read as a locale; delete it.
  • 'public/locales/{locale}/{ns}.json' is the i18next-fs-backend, i18next-http-backend and next-i18next default. Keys gain the namespace: nav.home in common.json is common.nav.home.

Decided per file after flattening, never per value. 'icu' and 'i18next' force every file.

'auto' reads a file as When
i18next any value contains {{, or any key has a foldable CLDR plural suffix (X_one beside X_other)
ICU otherwise
  • No hybrid parse. ICU syntax in a file read as i18next stays literal text; {{name}} in a file read as ICU is a literal brace around an argument.
  • {count, plural, ...} in a file classed as i18next is the one mistake 'auto' could hide: LZ1020 icu-in-i18next-file, the hint naming the {{ or suffixed key that decided.

Importing i18next catalogs has examples.

Files read as i18next only.

Value Effect
'literal' Tags such as <b>here</b> are escaped to text; each such value raises LZ1016 i18next-markup-literal.
'tags' Catalog-wide, tags lower to markup arguments: the function returns an array of parts, each tag a required handler at the call site.

Flat-key JSON, per key a description and placeholders map. The path takes {sourceLocale}.

  • An entry for a key the source catalog lacks: LZ1015 meta-orphan.
  • A placeholders key that names no argument of its message: LZ1022 meta-placeholder-orphan.
  • false switches descriptions off; LZ3012 and LZ5006 then hint at setting meta.
  • The default stays under locales/ whatever catalogs says. loclizr init sets it beside catalogs that live elsewhere; a hand-written config moves it the same way: meta: 'lang/{sourceLocale}.meta.json'.
  • Inside the project root and not the root itself (LZ1007 outdir-unsafe), rechecked through symlinks before each generated file is read or written, in build and check (LZ5001 output-unwritable). Pruning deletes header-carrying files it did not produce, so give it its own directory.
  • May not be, or sit above, a directory that holds catalogs (locales under the default), nor hold the resolved meta or record path (LZ1001): the tree’s self-ignoring .gitignore would keep them out of commits. Paths compare without case, so outDir: 'Locales' is refused too.
  • Moving outDir leaves an earlier build’s .gitignore and generated files behind; delete them, or catalogs added later stay ignored.

The path takes {sourceLocale}. false writes no record and skips LZ5003 record-stale and LZ5007 record-rewritten.

  • The default stays under locales/ whatever catalogs says. loclizr init sets it beside catalogs that live elsewhere; a hand-written config moves it the same way: record: 'lang/loclizr.context.json'.

  • A path the catalogs pattern matches is LZ1001:

    record: 'locales/context.json'
    error LZ1001 config-invalid loclizr.config.ts
    `record` is `locales/context.json`, which the `catalogs` pattern `locales/{locale}.json` also matches.
    fix a file the pattern matches is either a catalog or the record file, never both. Name one it cannot match, such as 'locales/loclizr.context.json'. Then delete any record a build already wrote at 'locales/context.json'.
  • build writes over an existing file there only when it parses to an object with schema: 1, or carries merge conflict markers and still has a "schema": 1 line of its own. Anything else is LZ5001 output-unwritable, and the file is kept.

An RFC 6265 name: letters, digits and !#$%&'*+-.^_`|~; anything else is LZ1001, since document.cookie could never read it back. The client checks it first for the initial locale. Attributes are fixed: path=/, one year max-age, SameSite=Lax, no Domain, no Secure.

Controls one block in messages.d.ts:

declare module 'loclizr' {
interface LocaleRegistry {
locale: AppLocale
}
}
  • Types getLocale(), useLocale() and setLocale() from loclizr with your locale union.
  • Two trees declaring it in one TypeScript program is TS2717. Set false in the second; init does when it finds one. See Monorepo.
  • A tree shipped inside a published package sets false: the consumer’s own tree augments the same registry, which is TS2717 inside node_modules with skipLibCheck off.
  • The barrel’s own getLocale, setLocale and locales stay typed either way.

{ errors: 'errors' } exports every errors. key from groups.js as a frozen errors record, typed Readonly<{ [K in ErrorsKey]: ... }> so errors[code] checks each member’s arguments. Prefixes match on a dot boundary: err does not capture errors.forbidden.

Case Result
No key matches LZ4004 group-empty
Members with differing argument sets LZ4006 group-args-heterogeneous: a dynamic call must pass the union
Two groups sharing a name or its PascalCased types (ErrorsKey, ErrorsArgs) LZ4001 identifier-collision
A group id equal to a grouped message’s id LZ4001 identifier-collision
Object as a group id or grouped message id LZ4002 identifier-reserved
identifiers: { 'nav.home': 'navHome' }

Replaces the mangled identifier: fixes a collision without renaming a key i18next still reads.

  • Passes mangling steps 2 to 4: an illegal character becomes _, and a leading digit, reserved word, default or then gains a $ prefix.
  • LZ4002 refuses an override that starts with $ and a lowercase letter, names a barrel export (cookie, locales, sourceLocale, getLocale, setLocale, subscribe) or is __proto__, constructor or prototype.
  • Fixes a top-level segment that mangles to _locale, _formats or _root, or two that differ only by case (a case-insensitive filesystem merges them).
  • An entry whose key is no source key, top-level segment or group name is LZ4007 identifier-orphan, a warning naming the closest one, so a typo or a renamed key is not silently ignored.

Resolved at build time into each locale’s compiled arm; the runtime never walks a chain.

The default 'bcp47' gives [L, ...declared BCP 47 truncations of L, sourceLocale]: de-AT resolves through de, then en. Messages a sparse de-AT.json lacks are inherited from de; only reaching source counts as a missing translation.

fallback: { nb: ['no'] }

A map replaces the middle for the locales it names; the rest keep truncation. Source is always last.

formats: {
timeZone: 'UTC',
number: { compact: { notation: 'compact', maximumFractionDigits: 1 } },
dateTime: { weekday: { weekday: 'long', month: 'long', day: 'numeric' } },
}

Named styles beyond ICU’s own, used as {views, number, compact} or {when, date, weekday}. An undefined style is LZ2002 icu-style-unknown.

Option sets are checked at load time against Intl with a fixed probe locale, so every machine agrees. A rejected set is LZ1001, hint from Intl, not a throw in the browser. So is a non-finite number (NaN, Infinity) as an option value.

Merged into every date and time option set; ICU skeletons cannot carry an IANA zone. Leave it unset in a browser SPA. Set it when the server renders dates, so both sides of hydration agree. Turn on LZ3011 date-without-timezone (default off) to list the messages that format a date without one.

  • Reads include minus exclude, always minus outDir.
  • Follows symbolic links, including ones that point outside the project root. A path whose target is not a regular file, such as a FIFO or a device, is skipped.
  • Binds generated modules by specifier suffix, no resolver or alias map: ./loclizr/messages, @/loclizr/messages and #app/loclizr/groups all bind. Every m.<id> access and bound bare call lands in the record’s usage.
  • Fails nothing by default: LZ5004 scan-found-nothing warns only when files were read and none imported the tree, LZ5005 unused-message is off, the record gate ignores usage.
severity: {
'ambiguous-source': 'error',
'missing-translation': 'warn',
'unused-message': 'error',
}

Keys are the rule names printed beside each code. An unknown name is LZ1001, as is naming one of the three fixed rules, also the only ones that exit 2:

Rule Why it is fixed
LZ1001 config-invalid Self-referential: the field that turns it down is the one it validates.
LZ1007 outdir-unsafe A build could write outside the project root and exit 0.
LZ5001 output-unwritable Nothing downstream can act on a tree that is not there.
  • Applied once, at the end: it never changes what is emitted.
  • A fatal rule at warn or off still writes no tree and exits 1. It prints as a warning, except a rule raised before the config resolves (LZ1003 with no catalogs on disk), which prints as an error. The report names it in a nothing generated: 1 fatal (LZ1003) line and, outside --quiet, still closes on the counts line.
  • Only LZ3012 reads it, to name in its hint the severity it is not running at.
loclizr.config.ts
import { defineConfig } from 'loclizr'
import { base } from '@acme/loclizr-config'
export default defineConfig({
...base,
locales: ['en', 'fr'],
severity: { ...base.severity, 'missing-translation': 'warn' },
})

There is no extends: spread a plain object. Spread severity too, or the override drops every rule base set.

Only locales, sourceLocale and cookie, through $configure1 in the generated _locale.js. loclizr/server takes the same values in NegotiateOptions and never reads the generated tree.