{"id":50372403,"url":"https://github.com/jmwierzbicki/linguo","last_synced_at":"2026-06-01T10:00:40.888Z","repository":{"id":359674575,"uuid":"1247051579","full_name":"jmwierzbicki/linguo","owner":"jmwierzbicki","description":"A modern i18n toolkit for Angular 18+, built on SignalStore — reactive translations, ICU MessageFormat 2, HTML-free placeholders for translators, and a gettext .po extraction CLI.","archived":false,"fork":false,"pushed_at":"2026-05-22T23:16:18.000Z","size":475,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-22T23:36:56.997Z","etag":null,"topics":["angular","gettext","i18n","icu","internationalization","localization","messageformat","ngrx-signals","signals","translation","typescript"],"latest_commit_sha":null,"homepage":null,"language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/jmwierzbicki.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-22T21:08:40.000Z","updated_at":"2026-05-22T23:16:22.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/jmwierzbicki/linguo","commit_stats":null,"previous_names":["jmwierzbicki/linguo"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/jmwierzbicki/linguo","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jmwierzbicki%2Flinguo","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jmwierzbicki%2Flinguo/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jmwierzbicki%2Flinguo/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jmwierzbicki%2Flinguo/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/jmwierzbicki","download_url":"https://codeload.github.com/jmwierzbicki/linguo/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jmwierzbicki%2Flinguo/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33769492,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-01T02:00:06.963Z","response_time":115,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["angular","gettext","i18n","icu","internationalization","localization","messageformat","ngrx-signals","signals","translation","typescript"],"created_at":"2026-05-30T08:00:26.065Z","updated_at":"2026-06-01T10:00:40.882Z","avatar_url":"https://github.com/jmwierzbicki.png","language":"TypeScript","funding_links":[],"categories":["Development Utilities"],"sub_categories":["Internationalization"],"readme":"\u003cp align=\"center\"\u003e\n  \u003cimg\n    src=\"https://raw.githubusercontent.com/jmwierzbicki/linguo/main/apps/playground/public/linguo-logo.png\"\n    alt=\"ng-linguo\"\n    width=\"640\"\n  /\u003e\n\u003c/p\u003e\n\n# ng-linguo\n\n**Signal-native internationalization for Angular.** A modern, complete i18n\ntoolkit for Angular 18+, built on SignalStore — an independent, from-scratch\nalternative to `@ngx-translate/core` and Transloco, reactive by default with\nzero RxJS plumbing in your components.\n\n```html\n\u003c!-- translators edit plain text; this renders a real Angular link --\u003e\n\u003cp t=\"Read the [docs]documentation[/docs] to get started\"\u003e\n  \u003cng-template tFor=\"docs\" let-text\u003e\u003ca routerLink=\"/docs\"\u003e{{ text }}\u003c/a\u003e\u003c/ng-template\u003e\n\u003c/p\u003e\n```\n\n\u003cp align=\"center\"\u003e\n  \u003ca href=\"https://jmwierzbicki.github.io/linguo/\"\u003e\n    \u003cimg\n      src=\"https://raw.githubusercontent.com/jmwierzbicki/linguo/main/apps/playground/public/linguo-demo.gif\"\n      alt=\"Switching languages in the ng-linguo playground — every example re-renders reactively\"\n      width=\"800\"\n    /\u003e\n  \u003c/a\u003e\n  \u003cbr /\u003e\n  \u003cem\u003eSwitch the language and every binding re-renders — no reload, no subscriptions.\u003c/em\u003e\n  \u003cbr /\u003e\n  \u003cstrong\u003e\u003ca href=\"https://jmwierzbicki.github.io/linguo/\"\u003e▶ Try the live demo\u003c/a\u003e\u003c/strong\u003e\n\u003c/p\u003e\n\n\u003e **Status:** pre-release (`0.9.x`) — published to npm and usable today. The\n\u003e runtime, the extraction CLI, and the full test suite are in place and green.\n\u003e APIs may still shift before `1.0`.\n\n## Why ng-linguo\n\n**Writing code**\n\n- **Signals, not subscriptions.** Translations are reactive through\n  [`@ngrx/signals`](https://ngrx.io/guide/signals) — switch language and the UI\n  updates on its own, with no `async` pipe and no manual `subscribe`/`unsubscribe`.\n- **Three ways to translate, one for each job.** A `t` pipe for templates, a\n  `[t]` directive for elements and rich text, and `injectTranslate()` for\n  TypeScript — see [which to use](#which-api-should-i-use).\n- **Zoneless-ready, SSR-friendly, tree-shakeable.** No `zone.js` dependency,\n  safe to render on the server, and the optional ICU and HTTP pieces live in\n  separate entry points so you only ship what you import.\n\n**Writing translations**\n\n- **English _is_ the key — no key files to maintain.** You write real English\n  in your components, and that text _is_ the translation key. There are no\n  `home.header.title` paths to invent, keep unique, and keep in sync, and\n  nothing opaque for a translator (or an LLM) to guess at — they always see a\n  full, meaningful sentence. For the rare clash, a `context` disambiguates\n  (`Play` in a game vs. a music player).\n- **Translators never see HTML.** Named slots `[name]…[/name]` (a BBCode-like\n  syntax) bind to _your_ `\u003cng-template\u003e`, so links, buttons, and bindings render\n  as real Angular while the translation file stays plain text. Translated text\n  is never inserted as HTML, so cross-site scripting (XSS) is impossible by design.\n- **Correct grammar in every language (ICU MessageFormat 2, and MF1).** Real\n  plurals, `select`, and gendered text per locale — Polish gets four plural\n  forms, English gets two, all from one message.\n\n**Shipping translations**\n\n- **A real, additive extraction pipeline.** Extract your source strings to\n  standard gettext `.po` files (works with Crowdin / Lokalise / Phrase) and\n  compile them to runtime JSON. Re-running extraction is _additive_: new and\n  changed strings merge in while every existing translation is kept.\n- **Add a language in seconds.** Add its code to the config and extract — with\n  an AI translator wired up, filling it in is a single command (or a couple of\n  clicks in the interactive CLI).\n- **Translate with AI — your model, your key.** Copy a ready-made prompt into\n  any chat model, or point ng-linguo at a translator module that calls your own\n  provider. ng-linguo writes the prompt (it teaches the model your context,\n  slot tags, and plural rules) and merges the reply; your SDK and API key never\n  leave your machine.\n- **Automatable in CI.** Every command runs non-interactively and is\n  deterministic, so extraction and compilation drop straight into a pipeline.\n\n**Fast by default**\n\n- The `t` pipe memoizes its result, `injectTranslate()` + `computed()` does zero\n  work per change-detection pass, and ICU messages are compiled once and cached.\n  See [Performance](#performance).\n\n## Install\n\nng-linguo is two packages that do two different jobs at two different times — so\nyou install both, once.\n\n**The runtime** — what your app imports and ships to the browser:\n\n```bash\nnpm i @ng-linguo/linguo @ngrx/signals\n```\n\nRequires **Angular 18+**. `@ngrx/signals` is a peer dependency — bring your own.\n\n**The CLI** (`@ng-linguo/extract`) — the build-time tool that scans your source\nand produces the translation files the runtime loads. It's pure Node with zero\nAngular dependencies, so it installs as a dev dependency and never reaches the\nbrowser:\n\n```bash\nnpm i -D @ng-linguo/extract\n```\n\nIts bin is `linguo-extract`; you run it with `npx linguo-extract …` (or from an\nnpm script). Neither package depends on the other, so each stays minimal — the\nruntime renders translations in your app, the CLI generates them in your build.\n\n## Getting started\n\nThe fastest path from an empty Angular app to a translated one. Steps 1–3 get\nthe runtime working; step 4 uses the CLI to generate the real translation files.\n\n### 1. Configure the runtime\n\nAdd the providers to your `app.config.ts` (or `bootstrapApplication`). Pick a\nloader — loading is explicit, so nothing is fetched during DI setup.\n\nMost apps load their translation JSON over HTTP:\n\n```ts\nimport { provideTranslate } from '@ng-linguo/linguo';\nimport { createHttpLoader } from '@ng-linguo/linguo/http';\nimport { provideIcu } from '@ng-linguo/linguo/icu';\nimport { provideHttpClient } from '@angular/common/http';\n\nexport const appConfig = {\n  providers: [\n    provideHttpClient(),\n    provideTranslate({\n      defaultLang: 'en', // required: reported before load, and the fallback\n      // optional: only matches a saved/browser language to one you ship.\n      // This is a runtime concern — separate from the CLI's linguo.config.json.\n      supportedLangs: ['en', 'pl', 'de'],\n      // factory form: the loader is built in DI, so it can use HttpClient.\n      // GETs /assets/i18n/\u003clang\u003e.json by default.\n      loader: () =\u003e createHttpLoader(),\n    }),\n    provideIcu(), // optional — enables ICU MessageFormat (defaults to MF2)\n  ],\n};\n```\n\nPrefer to **bundle** translations (no network)? A loader is just an object with\na `load(lang)` method, so a static import works too:\n\n```ts\nimport en from './i18n/en.json';\nimport pl from './i18n/pl.json';\n\nconst dictionaries: Record\u003cstring, unknown\u003e = { en, pl };\n\nprovideTranslate({\n  defaultLang: 'en',\n  loader: { load: (lang) =\u003e Promise.resolve(dictionaries[lang] ?? {}) },\n});\n```\n\n### 2. Load a language at startup\n\nThe store never loads on its own. Call `restoreLang()` once, usually in your\nroot component. It picks the startup language for you —\n**persisted choice → browser preference → `defaultLang`** — and loads it. Gate\nyour UI on the `isReady` signal to avoid a flash of untranslated content:\n\n```ts\nimport { Component, inject } from '@angular/core';\nimport { TranslateStore } from '@ng-linguo/linguo';\n\n@Component({\n  selector: 'app-root',\n  template: `@if (store.isReady()) {\n      \u003crouter-outlet /\u003e\n    } @else {\n      \u003capp-splash /\u003e\n    }`,\n})\nexport class App {\n  protected readonly store = inject(TranslateStore);\n  constructor() {\n    void this.store.restoreLang(); // resolve + load the startup language\n  }\n}\n```\n\nThe active language is saved to `localStorage` (key `ng-linguo.lang`), and the\nbrowser's preferred language is used on the first visit — both **on by default**\nand **SSR-safe** (no-ops on the server). Set `supportedLangs` so a stored or\nbrowser value can be matched to a language you actually ship. To switch language\nlater, call `store.setLang('pl')` (which also saves the choice). The full set of\noptions — `persistSelectedLanguage`, `restoreSelectedLanguage`, `persistKey`,\n`detectBrowserLanguage` — is in [Configuration](#configuration).\n\n### 3. Translate\n\nIn templates use the `t` pipe or the `[t]` directive; in TypeScript use\n`injectTranslate()`.\n\n```html\n\u003c!-- plain text --\u003e\n{{ 'Save' | t }}\n\n\u003c!-- ICU placeholders \u0026 plurals --\u003e\n{{ 'Hello {$name}!' | t: { params: { name } } }}\n\n\u003c!-- context: same text, different translation --\u003e\n{{ 'Play' | t: { context: 'game' } }}\n\n\u003c!-- rich text: [tag] placeholders bound to your own templates (see the hero above) --\u003e\n\u003cp t=\"[b]Warning:[/b] this cannot be undone\"\u003e\n  \u003cng-template tFor=\"b\" let-text\u003e\u003cstrong\u003e{{ text }}\u003c/strong\u003e\u003c/ng-template\u003e\n\u003c/p\u003e\n```\n\n```ts\nimport { injectTranslate } from '@ng-linguo/linguo';\n\nconst t = injectTranslate();\n\n// Reactive and efficient: recomputes only when `name()` or the active language\n// changes. Prefer this for frequently-updated or looped bindings.\nreadonly greeting = computed(() =\u003e t('Hello {$name}!', { params: { name: this.name() } }));\n```\n\nUntil you generate translation files (step 4), every string falls through to the\nEnglish you wrote, so the app is fully usable from the first line of code.\n\n### 4. Generate the translation files\n\nThe strings above are also your source catalog. Use the\n[`@ng-linguo/extract`](#translation-workflow) CLI to collect them and produce the\nJSON your loader serves:\n\n```bash\nnpm i -D @ng-linguo/extract                   # one-time: install the CLI\nnpx linguo-extract init --locales en,pl,de    # create linguo.config.json\nnpx linguo-extract extract                    # scan source → en/pl/de .po catalogs\nnpx linguo-extract translate --all            # fill missing entries with AI (optional)\nnpx linguo-extract compile                    # .po → runtime JSON\n```\n\nThat's the whole loop. See [Translation workflow](#translation-workflow) for the\ninteractive menu, adding languages, and translating by hand.\n\n### Which API should I use?\n\nAll three resolve the same translations; they differ in where they run and what\nthey can render.\n\n| Use…                | When                                                           | Notes                                                                                              |\n| ------------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |\n| `[t]` **directive** | Translating an element, **rich text with slots**, or hot lists | The most efficient option for the DOM. Re-renders via an `effect()` only when something changes.   |\n| `injectTranslate()` | TypeScript, or a binding read inside a `computed()`            | Zero work per change-detection pass. Best for frequently-updated or looped bindings. Slots → text. |\n| `t` **pipe**        | Quick inline strings, attribute bindings                       | Convenient, but it's an **impure** pipe (see below). Slots degrade to plain text.                  |\n\n\u003e **A note on the `t` pipe.** Angular re-evaluates an impure pipe on _every_\n\u003e change-detection pass. The `t` pipe has to be impure to react to a language\n\u003e switch (a pure pipe only re-runs when its input reference changes), so it\n\u003e memoizes aggressively to stay cheap. That's perfectly fine for ordinary\n\u003e templates — but in a long `@for` list or a hot binding, prefer the `[t]`\n\u003e directive or `injectTranslate()` + `computed()`, which do no per-pass work.\n\u003e The directive is also the only option that renders slot tags as real DOM; the\n\u003e pipe and `injectTranslate()` return a string, so slots collapse to their text.\n\n### Performance\n\n- **The `t` pipe is memoized.** It only re-translates when the key, `params`,\n  `context`, or language actually change — so passing a fresh `{ params: … }`\n  object on every render is just a quick equality check, not a re-format.\n- **`injectTranslate()` + `computed()` does no per-pass work** — it recomputes\n  only when its signal inputs change. Reach for it on hot paths.\n- **ICU messages are compiled once and cached** per `(format, locale, message)`,\n  so repeated formatting of the same pattern is a map lookup.\n\n## Translation workflow\n\n`@ng-linguo/extract` is a pure-Node CLI (no Angular dependency, so it never\ndrags the framework into your tooling) that turns your source into translation\nfiles and back. It reads a `linguo.config.json` — auto-discovered — listing your\nlocales and paths. Install it once as a dev dependency; its bin is\n`linguo-extract`, so you invoke it with `npx` (or from an npm script):\n\n```bash\nnpm i -D @ng-linguo/extract   # install once; the bin is `linguo-extract`\nnpx linguo-extract init       # create/edit linguo.config.json\nnpx linguo-extract extract    # scan source → \u003clocale\u003e.po catalogs (additive)\nnpx linguo-extract translate  # fill missing entries with AI (needs a translator)\nnpx linguo-extract compile    # .po catalogs → runtime \u003clocale\u003e.json\n```\n\n### The interactive menu\n\nNew to the tool? Run it with **no command** to open a guided menu that walks\nthrough every step — extract, compile, translate, run the full pipeline — and\nincludes a BIOS-style settings editor where each config field carries an\ninline description:\n\n```bash\nnpx linguo-extract        # guided menu (also creates/edits the config)\n```\n\nEverything the menu does is also a flag-driven command, so you can graduate to\nscripts whenever you like.\n\n### Extraction is additive\n\n`extract` scans your `.ts` and `.html` for the `t` pipe, the `[t]` directive,\n`injectTranslate()` calls, and `mark()`, then **merges** the results into your\nexisting `.po` catalogs. New strings are added, removed ones are dropped, and\n**every translation you already have is preserved** — entries are matched by\ntheir source text plus `context`. Re-running it is safe and idempotent.\n\n(Need to keep a documentation sample or fixture out of the scan? Wrap it in\n`linguo-ignore-start` / `linguo-ignore-end` comments.)\n\n### Adding a language\n\nAdding a locale is a couple of steps — or a couple of clicks in the menu:\n\n```bash\nnpx linguo-extract init --locales en,pl,de,fr   # add `fr` to the config\nnpx linguo-extract extract                       # seeds fr.po with the source strings\nnpx linguo-extract translate --locale fr         # fill it in with AI…\n# …or: npx linguo-extract copyprompt fr          # …or copy a prompt for any chat model\nnpx linguo-extract compile                       # produce fr.json\n```\n\n### Translating with AI\n\nBecause the source strings are full English sentences (not opaque keys), an LLM\nhas all the context it needs. ng-linguo writes a self-contained prompt that\nteaches the model your `context` notes, slot tags, and plural rules, and only\never sends entries that are still missing. Two ways to run it:\n\n- **Clipboard (no key, no config):** `npx linguo-extract copyprompt pl` copies\n  the prompt; paste it into any chat model and save the reply over `pl.po`.\n- **Automatic:** point the `translator` config field at a small module that\n  calls your AI provider. ng-linguo builds the prompt and merges the reply; your\n  SDK and API key stay yours. See the\n  [`@ng-linguo/extract` README](https://github.com/jmwierzbicki/linguo/tree/main/packages/extract#readme)\n  for a copy-paste module (OpenAI, Anthropic, or any provider).\n\n### In CI\n\nEvery command runs non-interactively and deterministically, so the pipeline\ndrops into CI as-is:\n\n```bash\nnpx linguo-extract extract          # fails the build if it errors; idempotent otherwise\nnpx linguo-extract translate --all  # optional: fill any gaps (needs a translator)\nnpx linguo-extract compile\n```\n\n`init` is scriptable too: `npx linguo-extract init --locales en,pl,de --out public/i18n`.\n\n## Configuration\n\nng-linguo has two independent configs that don't overlap by accident: this\n**runtime** config (passed to `provideTranslate`, shipped in your browser\nbundle) and the **build-time** [`linguo.config.json`](#translation-workflow)\n(read only by the Node CLI). The runtime never reads the CLI's file. The one\nthing both name is the locale list — `supportedLangs` here vs. `locales` there —\nand `supportedLangs` is optional: it exists purely to match a saved or\nbrowser-preferred language to one you actually ship.\n\n### `provideTranslate(options)`\n\n| Option                    | Type                                           | Default                     | What it does                                                                                                      |\n| ------------------------- | ---------------------------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------- |\n| `defaultLang`             | `string`                                       | _(required)_                | The language reported before anything loads, and the guaranteed fallback.                                         |\n| `loader`                  | `TranslationLoader \\| () =\u003e TranslationLoader` | _(required)_                | How translations are fetched. The factory form runs in DI, so the loader can inject services (e.g. `HttpClient`). |\n| `supportedLangs`          | `string[]`                                     | _(none)_                    | Languages you ship. Used to match a persisted/browser value; browser detection is skipped when omitted.           |\n| `persistSelectedLanguage` | `boolean`                                      | `true`                      | Save the active language to `localStorage` when it changes. SSR-safe.                                             |\n| `restoreSelectedLanguage` | `boolean`                                      | = `persistSelectedLanguage` | Read the saved language back on startup (inside `restoreLang()`). SSR-safe.                                       |\n| `persistKey`              | `string`                                       | `'ng-linguo.lang'`          | The `localStorage` key used for the saved language.                                                               |\n| `detectBrowserLanguage`   | `boolean`                                      | `true`                      | On first run, match `navigator.languages` against `supportedLangs`. SSR-safe.                                     |\n\n`provideIcu({ defaultFormat })` accepts `'mf2'` (default) or `'mf1'`.\n`createHttpLoader({ prefix, suffix })` defaults to `/assets/i18n/` + `.json`,\nfetching `${prefix}${lang}${suffix}`. A loader is just an object with a\n`load(lang): Promise\u003cRecord\u003cstring, string\u003e\u003e` method, so any source works.\n\n## Packages \u0026 entry points\n\n| Import                     | What it gives you                                                                                  |\n| -------------------------- | -------------------------------------------------------------------------------------------------- |\n| `@ng-linguo/linguo`        | `TranslateStore`, `provideTranslate`, the `t` pipe, the `[t]` directive, `injectTranslate`, `mark` |\n| `@ng-linguo/linguo/icu`    | `provideIcu` — ICU MessageFormat 1 + 2                                                             |\n| `@ng-linguo/linguo/http`   | `createHttpLoader` — `HttpClient`-backed loader                                                    |\n| `@ng-linguo/extract`       | build-time extraction/translate/compile CLI (pure Node)                                            |\n| `@ng-linguo/eslint-plugin` | lint config so the a11y linter trusts empty `[t]` elements                                         |\n\n## Contributing\n\nThis is an Nx + pnpm monorepo.\n[CLAUDE.md](https://github.com/jmwierzbicki/linguo/blob/main/CLAUDE.md) is the\nsource of truth for architecture, code style, testing, and release conventions —\nread it first.\n\n```bash\npnpm install\npnpm nx run-many -t lint test build   # the full suite (what CI runs)\npnpm nx serve playground              # the demo app\n```\n\n## License\n\n[MIT](https://github.com/jmwierzbicki/linguo/blob/main/LICENSE)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjmwierzbicki%2Flinguo","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fjmwierzbicki%2Flinguo","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjmwierzbicki%2Flinguo/lists"}