Skip to content
loclizr

Every transcript on this page is pasted from a real run.

  1. Terminal window
    npm create vite@latest my-shop -- --template react-ts
    cd my-shop
    npm i

    Needs Node 20.19 or newer and nothing else: the CLI loads its .ts config itself. Only the template is Vite-specific; the generated messages are plain ESM for Node, a bundler or React Native. Under Jest, its default CommonJS transform needs a config change first.

  2. Terminal window
    npm i loclizr
    npx loclizr init
    npx loclizr init
    wrote loclizr.config.ts
    wrote locales/en.json
    Install loclizr, which loclizr.config.ts and the generated code import:
    npm i loclizr
    Add these scripts to package.json:
    "predev": "loclizr build --no-fail",
    "prebuild": "loclizr build",
    "pretypecheck": "loclizr build"
    Add this step to CI, before any step that runs loclizr build:
    # loclizr check compares the committed context record against this tree.
    # loclizr build would rewrite that record in the CI workspace and pass.
    - run: npx loclizr check
    loclizr.config.ts
    import { defineConfig } from 'loclizr'
    export default defineConfig({
    sourceLocale: 'en',
    catalogs: 'locales/{locale}.json',
    outDir: 'src/loclizr',
    severity: { 'ambiguous-source': 'error' },
    })
    • loclizr is a regular dependency: generated code imports its locale store at run time, so a Node server needs it installed (the note prints either way).
    • Install before building: the config imports loclizr, and without it build fails with LZ1001 config-invalid, exit 2 (all fifty-nine codes).
    • init never overwrites a file and never edits package.json; it prints the scripts.
    • ambiguous-source (two keys share a source text, one has no description) ships at warn; init raises it to error, since a hard gate is free before the first string exists.
  3. package.json
    "scripts": {
    "predev": "loclizr build --no-fail",
    "dev": "vite",
    "prebuild": "loclizr build",
    "build": "tsc -b && vite build",
    "lint": "oxlint",
    "preview": "vite preview",
    "pretypecheck": "loclizr build",
    "typecheck": "tsc -b"
    }
    Script Runs before Why
    predev npm run dev --no-fail: one untranslated key never stops the dev server
    prebuild npm run build takes the real exit code
    pretypecheck npm run typecheck typecheck is tsc -b: the template’s tsconfig.json only lists project references, so tsc -p checks nothing
    no prepare npm ci would rewrite the record before CI’s check compares it

    The template has no test script and loclizr needs none. The complete CI workflow runs npm test as the app’s own step; add one (Testing) or drop that step.

    The cost of no prepare: on a fresh clone ./loclizr/messages is red in the editor until npx loclizr build, npm run dev or npm run build runs.

  4. locales/en.json
    {
    "nav": {
    "home": "Home",
    "cart": "Cart"
    },
    "cart": {
    "greeting": "Hi {name}, your cart is ready",
    "items": "{count, plural, =0 {Your cart is empty} one {# item in your cart} other {# items in your cart}}"
    }
    }

    init seeds this file. Values are ICU MessageFormat (Catalogs): {name} is a placeholder, {count, plural, ...} picks a form by number, # is the number. Under the default catalogFormat: 'auto', an i18next file can sit beside it.

    Optional translator notes go in a sidecar you create; init does not write it. A TMS (the translation management system translators work in, such as Crowdin, Lokalise or Phrase) would ship a description written into en.json as copy.

    locales/en.meta.json
    {
    "cart.items": {
    "description": "Badge under the cart icon on every page.",
    "placeholders": {
    "count": "Number of line items, not total quantity."
    }
    }
    }
  5. Terminal window
    npx loclizr build
    npx loclizr build
    warn LZ5004 scan-found-nothing
    The scan read 2 files and found no import of the generated messages.
    fix check scan.include in loclizr.config.ts. A usage site is found through an import such as import * as m from './loclizr/messages'
    wrote src/loclizr (11 files) and locales/loclizr.context.json
    commit locales/loclizr.context.json; `loclizr check` compares it
    4 messages, 1 locale (source en), 0 errors, 1 warning

    One pass emits code, runs every rule and writes the context record. The warning is expected: nothing imports the messages yet.

    • Directorylocales
      • en.json
      • en.meta.json
      • loclizr.context.json committed
    • loclizr.config.ts
    • Directorysrc
      • Directoryloclizr
        • .gitignore self-ignoring
        • messages.js
        • messages.d.ts
        • Directorymessages
          • _formats.js
          • _formats.d.ts
          • _locale.js
          • _locale.d.ts
          • cart.js
          • cart.d.ts
          • nav.js
          • nav.d.ts

    Your root .gitignore stays as is. Context record has the schema of the committed file.

    src/loclizr/messages/cart.d.ts
    // @generated by loclizr abi=1. Do not edit; run `loclizr build`.
    import type { MessageOptions } from 'loclizr'
    /** en: "Hi {name}, your cart is ready" */
    export declare function cart_greeting(args: { name: string | number }, opts?: MessageOptions): string
    /** en: "{count, plural, =0 {Your cart is empty} one {# item in your cart} other {# items in your cart}}" */
    export declare function cart_items(args: { count: number }, opts?: MessageOptions): string

    One module per top-level key segment; abi=1 versions the four helpers it imports from loclizr.

  6. src/Cart.tsx
    import * as m from './loclizr/messages'
    export function Cart({ count }: { count: number }) {
    return <p>{m.cart_items({ count })}</p>
    }

    Keep it under src/: the usage scan, which notes where each message is called, reads src/**/*.{ts,tsx,js,jsx,mts,mjs,svelte,vue,astro} by default (scan.include). Vite takes the extensionless import; Node’s own resolver needs ./loclizr/messages.js and "type": "module" (the template has it).

    npx loclizr build
    wrote locales/loclizr.context.json
    commit locales/loclizr.context.json; `loclizr check` compares it
    4 messages, 1 locale (source en), 0 errors, 0 warnings
    locales/loclizr.context.json
    "usage": [
    {
    "file": "src/Cart.tsx",
    "scope": "Cart"
    }
    ]

    The warning is gone; cart.items gains this usage entry. Now pass { count: String(count) }:

    npm run typecheck
    > my-shop@0.0.0 pretypecheck
    > loclizr build
    4 messages, 1 locale (source en), 0 errors, 0 warnings
    > my-shop@0.0.0 typecheck
    > tsc -b
    src/Cart.tsx(4,29): error TS2322: Type 'string' is not assignable to type 'number'.

    A literal { count: '3' } fails too (plus noUnusedLocals for the unread prop). Put the call back.

  7. Add de.json without cart.items:

    locales/de.json
    {
    "nav": {
    "home": "Startseite",
    "cart": "Warenkorb"
    },
    "cart": {
    "greeting": "Hallo {name}, dein Warenkorb ist bereit"
    }
    }
    npx loclizr build
    error LZ3001 missing-translation locales/de.json de cart.items
    The de catalog has no value for this key, so this message renders en text.
    fix add "cart.items" to locales/de.json
    warn LZ5007 record-rewritten locales/loclizr.context.json
    `locales/loclizr.context.json` was rewritten: the committed record's contract differs from the one these catalogs produce.
    fix commit the rewritten record with the string change, so the context lands in the same pull request.
    wrote src/loclizr (5 files) and locales/loclizr.context.json
    4 messages, 2 locales (source en), 1 error, 1 warning
    fell back to source text: de 1

    The error exits 1 with output still written: cart_items renders English in de, the fallback the summary counts. LZ5007 fires because the locale list, part of the record’s contract, grew against the committed record (the last build’s; in a repo, the checked-in one).

    Add the key and both go quiet:

    locales/de.json
    "cart": {
    "greeting": "Hallo {name}, dein Warenkorb ist bereit",
    "items": "{count, plural, =0 {Dein Warenkorb ist leer} one {# Artikel in deinem Warenkorb} other {# Artikel in deinem Warenkorb}}"
    }
    npx loclizr build
    wrote src/loclizr (1 file) and locales/loclizr.context.json
    commit locales/loclizr.context.json; `loclizr check` compares it
    4 messages, 2 locales (source en), 0 errors, 0 warnings
  8. src/Switcher.tsx
    import { useLocale } from 'loclizr/react'
    import * as m from './loclizr/messages'
    export function Switcher() {
    const locale = useLocale()
    return (
    <div>
    {m.locales.map((tag) => (
    <button key={tag} type="button" aria-pressed={tag === locale} onClick={() => m.setLocale(tag)}>
    {tag}
    </button>
    ))}
    </div>
    )
    }
    src/App.tsx
    import * as m from './loclizr/messages'
    import { Cart } from './Cart'
    import { Switcher } from './Switcher'
    function App() {
    return (
    <>
    <Switcher />
    <h1>{m.cart_greeting({ name: 'Ada' })}</h1>
    <Cart count={3} />
    </>
    )
    }
    export default App
    src/main.tsx
    import { StrictMode } from 'react'
    import { createRoot } from 'react-dom/client'
    import { useLocale } from 'loclizr/react'
    import './index.css'
    import App from './App.tsx'
    function Root() {
    return <App key={useLocale()} />
    }
    createRoot(document.getElementById('root')!).render(
    <StrictMode>
    <Root />
    </StrictMode>,
    )

    Messages read the locale when called and subscribe to nothing, so the key re-renders App (Language switching keeps component state instead).

    npm run dev shows de and en buttons in m.locales order. Click de for Hallo Ada, dein Warenkorb ist bereit and 3 Artikel in deinem Warenkorb; a reload stays German because setLocale wrote a cookie.

  9. In CI run check, never build: it runs every rule build does (so LZ3001 still fails), writes nothing, and fails when the tree or record on disk is stale. Reword cart.greeting in en.json without rebuilding:

    npx loclizr check
    error LZ5002 output-stale src/loclizr/messages/cart.d.ts
    `src/loclizr/messages/cart.d.ts` differs from what this build would write.
    fix the generated tree on disk is stale; run `loclizr build`.
    error LZ5002 output-stale src/loclizr/messages/cart.js
    `src/loclizr/messages/cart.js` differs from what this build would write.
    fix the generated tree on disk is stale; run `loclizr build`.
    error LZ5003 record-stale locales/loclizr.context.json
    `locales/loclizr.context.json` no longer matches the catalogs: the contract this build derived differs from the committed record.
    fix run `loclizr build` and commit the record with the string change.
    4 messages, 2 locales (source en), 3 errors, 0 warnings

    build here would rewrite the record, warn LZ5007 and exit 0. So check goes before any step that runs build:

    .github/workflows/ci.yml
    - run: npm ci
    - run: npx loclizr check

    No loclizr script runs on install, so check sees the committed record. Continuous integration has the full workflow.

Next: Catalogs for dates, prices and plurals, React for a link inside a sentence, Continuous integration for the gate, or Importing i18next catalogs for existing translations.