# i18n (`@sdcorejs/angular/i18n`)

**Type**: Secondary entry point (service + pipe + types + catalogs + tooling)
**Import path**: `@sdcorejs/angular/i18n`
**Public to portals**: ❌ NOT re-exported from the main `@sdcorejs/angular` barrel — Core UI internal only. Portals that need the API import it explicitly from `@sdcorejs/angular/i18n`.

## One-line purpose
Bilingual+ runtime (default `vi/en/ja/ko/zh`) for every user-facing string inside `@sdcorejs/angular`, with a signal-based `I18nService`, a `pure: true` `| translate` pipe, type-safe key catalog derived from `EN_MESSAGES`, and an opt-in custom-language hook for portals.

## When to use
- Display any user-facing string from a Core UI component, service, directive, interceptor, or template — always go through `i18n.t('core.<scope>.<descriptor>')` or `| translate`.
- Surface a translated error message in `throw new Error(...)` so the global error handler can display it.
- Allow the portal to switch languages at runtime (page reloads to apply).
- Wire a portal-defined custom language (e.g. French) by passing a synchronous function in `ISdCoreConfiguration.language`.

## When NOT to use
- ❌ For dev logs (`console.log/warn/error`) — these are not UX. Leave VI / EN literals as-is and mark the line with `// @i18n-ignore` so the guard script ignores them.
- ❌ For code comments — they are not UX.
- ❌ For non-UI runtime data such as diacritic-stripping regex literals (`/[àáả...ư]/g`) — those are data. Whitelist the FILE in `scripts/check-i18n.mjs`.
- ❌ For dynamic database content (entity names, user-provided text) — translate those at the data source, not via i18n keys.
- ❌ For `export const` arrays evaluated at module-eval time (no DI available). Three options:
  1. Accept the limitation and whitelist the file (current state for `form-generic-component.model.ts` etc.).
  2. Expose a factory: `export const buildXxx = (i18n: I18nService) => [{ display: i18n.t(...) }];`.
  3. Use the [hardcoded localStorage pattern](#7-hardcoded-i18n-for-pure-utility-functions-no-di-no-injection-point) (Example #7) — appropriate for low-level utilities like file upload validators.

## Public API

### `I18nService`
`@Injectable({ providedIn: 'root' })`. Inject via `inject(I18nService)`.

```typescript
class I18nService {
  /** Current language as a readonly signal — re-renders signals that read it. */
  readonly language: Signal<Language>;

  /** Computed signal: current language's full message map (custom catalog if loaded, else built-in). */
  readonly messages: Signal<Readonly<Record<string, string>>>;

  /**
   * Translate a key with optional `{name}`-style parameter interpolation.
   * - Missing key in current lang → falls back to VI (warn once).
   * - Missing key in both → returns the key as-is (warn once).
   * - Missing param → keeps the `{name}` placeholder literal.
   */
  t(key: string, params?: I18nParams): string;

  /**
   * Switch language at runtime. Persists to localStorage and (by default) reloads the page
   * so the `pure: true` `translate` pipe rebuilds its cache against the new catalog.
   * Pass `{ reload: false }` in unit tests / SSR / programmatic flows where reload is undesired.
   *
   * Only `'vi' | 'en' | 'ja' | 'ko' | 'zh'` are accepted here — a custom-language function
   * cannot be switched to at runtime; it must come from `SD_CORE_CONFIGURATION` at startup.
   */
  setLanguage(lang: Language, opts?: { reload?: boolean }): void;
}
```

### `TranslatePipe` (selector `translate`)
Standalone, `pure: true`. Import via the `imports: [...]` array of any standalone component that uses it.

```html
<!-- Basic -->
{{ 'core.common.cancel' | translate }}

<!-- With params -->
{{ 'core.test.greet' | translate: { name: 'Ada' } }}

<!-- Attribute binding -->
<input [placeholder]="'core.common.search' | translate" />

<!-- Inline conditional with pipe sub-expression -->
<button [matTooltip]="cond ? ('core.keyA' | translate) : ('core.keyB' | translate)">…</button>
```

### Types

```typescript
/**
 * Built-in languages. Defined in `@sdcorejs/angular/models` (leaf entry point),
 * re-exported from `@sdcorejs/angular/i18n` for convenience.
 */
export type Language = 'vi' | 'en' | 'ja' | 'ko' | 'zh';

/** Literal-union of every i18n key in the canonical EN catalog. */
export type I18nKey = keyof typeof EN_MESSAGES;

/** Param map for `{name}` interpolation. */
export type I18nParams = Record<string, string | number>;

/** A complete catalog — every I18nKey mapped to a string. */
export type I18nCatalog = Record<I18nKey, string>;

/** Custom language provider (synchronous only). */
export type CustomLanguageProvider = () => I18nCatalog;
```

> **Why is `Language` in a separate entry point?** Both `@sdcorejs/angular/configurations` and `@sdcorejs/angular/i18n` need the type. Since `i18n` already imports `SD_CORE_CONFIGURATION` from `configurations`, having `configurations` import a type from `i18n` would create a cross-entry-point cycle (ng-packagr build failure). Moving `Language` to a leaf entry point (`@sdcorejs/angular/models`) lets both consume it without cycle.

### Constants
```typescript
/** Ordered list of built-in languages. */
export const SUPPORTED_LANGUAGES: readonly Language[];

/** Catalog table: lookup messages by built-in language code. */
export const I18N_MESSAGES: Record<Language, Readonly<Record<string, string>>>;

/** Direct catalog imports (rarely needed by consumers). */
export const EN_MESSAGES;  // `as const` — canonical key catalog
export const VI_MESSAGES;
export const JA_MESSAGES;
export const KO_MESSAGES;
export const ZH_MESSAGES;

/** localStorage key under which the user's language is persisted. */
export const I18N_STORAGE_KEY: string;  // value: 'sd-core.language'
```

## Configuration / DI tokens

### `ISdCoreConfiguration.language` (from `@sdcorejs/angular/configurations`)
Optional. Provide at portal bootstrap:

```typescript
// ISdCoreConfiguration field signature (verbatim):
import type { Language } from "@sdcorejs/angular/models";

language?: Language | (() => Record<string, string>);
```

- **Built-in `Language`** (`'vi' | 'en' | 'ja' | 'ko' | 'zh'`) — service uses `I18N_MESSAGES[lang]`.
- **Function** — sync `() => I18nCatalog` (the runtime type is loosened to `Record<string, string>` in the interface; cast at consumer side for strict typing). Service calls it once at construction and stores the result.
- **Omitted** — service falls back to `'vi'`.

### Resolution order on service construction
1. `localStorage[I18N_STORAGE_KEY]` — if present and one of `SUPPORTED_LANGUAGES`, use it. (localStorage only ever stores `'vi'|'en'|'ja'|'ko'|'zh'`; it cannot override a custom function except by picking a built-in instead.)
2. `SD_CORE_CONFIGURATION.language`
   - If `string` and built-in → use it.
   - If `function` → call (synchronously) and store as custom catalog; current `language` signal stays `'vi'` as a label.
3. Fallback `'vi'`.

### Storage key
`sd-core.language` — persisted by `setLanguage()`. Kept stable (not renamed when dropping the `Sd*` prefix from other identifiers) so existing user preferences survive the rename.

## Behavior notes

- **`pure: true` pipe** — re-evaluates only when the input `key` (or `params` reference) changes. Language switches do NOT auto-refresh existing bindings; instead `setLanguage()` reloads the page. This is the documented contract — it trades runtime reactivity for big perf wins on tables / lists with many `| translate` bindings.
- **Warn-once dedup** — `I18nService` keeps a `Set<string>` of already-warned messages so the same missing-key warning fires only once per session. Useful for tracking down typos without flooding the console.
- **Param interpolation** — regex `/\{(\w+)\}/g`. Value is `String(params[name])`. Missing key in `params` → original `{name}` placeholder is preserved (NOT replaced with `"undefined"`). Tip: when passing a possibly-undefined value, coalesce: `name: user?.fullName ?? ''`.
- **Custom language warm-up** — sync only. The catalog must be available the moment the service constructs. No `Promise`/`Observable` support — keep the function pure and instantaneous.
- **Type safety for custom catalogs** — cast at the consumer (configuration uses loose `Record<string, string>` to keep its entry point leaf):
  ```typescript
  import type { I18nCatalog } from '@sdcorejs/angular/i18n';
  // ... configuration:
  language: (): I18nCatalog => ({
    'core.common.cancel': 'Annuler',
    // ... must provide every I18nKey or TypeScript errors
  })
  ```
- **EN is canonical** — `EN_MESSAGES` is `as const`; every other built-in catalog is typed `Record<keyof typeof EN_MESSAGES, string>`. Adding a key to `EN_MESSAGES` immediately surfaces compile errors in `VI_MESSAGES` / `JA_MESSAGES` / `KO_MESSAGES` / `ZH_MESSAGES` until you fill them in. The parity script reinforces this at CI time.
- **VI is the runtime fallback** — for any missing key in the current language, the service tries VI before giving up. This matches the project's primary audience but is opinionated; revisit if you ever pivot.

## Key naming convention
Flat, dot-separated, lowercase + kebab inside segments. Always prefixed `core.` so portal consumer keys cannot collide.

```
core.<scope>.<descriptor>[.<sub>]

# Common reusable keys
core.common.cancel       core.common.close        core.common.search        core.common.reload

# Validators
core.validator.email.error            core.validator.phone-vn.error
core.validator.cccd.error             core.validator.time.error

# Interceptors
core.interceptor.no-internet.offline  core.interceptor.no-internet.maintenance

# Services
core.excel.cannot-read-file           core.docx.convert-error

# Components
core.component.table.paginator.first-page
core.component.editor.image.invalid-format
core.component.import-excel.row-limit

# Forms
core.form.input.invalid-pattern       core.form.datetime.cancel

# Modules
core.module.layout.forbidden.title    core.module.layout.home.feature.data

# Layout helpers
core.handler.global-error.update-title
```

## Tooling

### `npm run check:i18n-parity`
Verifies every built-in catalog (and the user's `vi.ts` / `ja.ts` / `ko.ts` / `zh.ts`) has the exact same key set as the canonical `EN_MESSAGES`. Fails CI on any mismatch. Implemented in `scripts/check-i18n-parity.mjs` and executed via `tsx`.

```
i18n parity OK (447 keys × 5 languages)
```

### `npm run check:i18n`
Scans `projects/sdcorejs-angular/**/*.{ts,html}` for raw Vietnamese diacritics (the regex covers all combinations of `À-ỹ` + `Đ`/`đ`). Fails CI on any hit outside the whitelist.

- **File-level whitelist** — `scripts/check-i18n.mjs` defines a `WHITELIST` regex array. Always whitelisted: `i18n/src/vi.ts`, `*.spec.ts`, the regex-data extension files, the form-generic / workflow model files (documented technical-debt — see [Anti-patterns](#anti-patterns)).
- **Per-line annotation** — add `// @i18n-ignore` on the offending line (or the line above) to skip just that line. Use for dev `console.*` arguments and other intentional VI literals.

```typescript
// @i18n-ignore — dev log, not UX
console.log('--- Bắt đầu chế độ theo dõi mạng ---');
```

### `// @i18n-ignore` rules
- Marker must appear on the same line as the offending VI, or on the immediately preceding line.
- The marker itself can include a justification comment after it (e.g., `// @i18n-ignore — dev log`).
- Use sparingly. If 3+ lines in a file need it, consider file-level whitelist instead.

## Examples

### 1. Translate inside a component
```typescript
import { Component, inject } from '@angular/core';
import { I18nService, TranslatePipe } from '@sdcorejs/angular/i18n';

@Component({
  standalone: true,
  imports: [TranslatePipe],
  template: `
    <h2>{{ 'core.component.import-excel.title' | translate }}</h2>
    <button (click)="onCancel()">{{ 'core.common.cancel' | translate }}</button>
    <p>{{ 'core.validator.min-length' | translate: { min: 5 } }}</p>
  `,
})
class MyComponent {
  readonly #i18n = inject(I18nService);
  onCancel() {
    throw new Error(this.#i18n.t('core.excel.cannot-read-file'));
  }
}
```

### 2. Switch language at runtime (default = reload)
```typescript
private i18n = inject(I18nService);
toggleLang() {
  this.i18n.setLanguage(this.i18n.language() === 'vi' ? 'en' : 'vi');
  // → persists to localStorage, then window.location.reload()
}
```

### 3. Configure default language at bootstrap
```typescript
// portal app.config.ts
import { ApplicationConfig } from '@angular/core';
import { SD_CORE_CONFIGURATION, ISdCoreConfiguration } from '@sdcorejs/angular/configurations';

export const appConfig: ApplicationConfig = {
  providers: [
    {
      provide: SD_CORE_CONFIGURATION,
      useValue: { language: 'en' } satisfies ISdCoreConfiguration,
    },
  ],
};
```

### 4. Provide a custom language (e.g. French)
```typescript
import type { I18nCatalog, ISdCoreConfiguration } from '@sdcorejs/angular/i18n';

const FR_MESSAGES: I18nCatalog = {
  'core.common.cancel': 'Annuler',
  'core.common.close': 'Fermer',
  // ... fill in every I18nKey (TypeScript will error on omissions)
};

export const appConfig: ApplicationConfig = {
  providers: [
    {
      provide: SD_CORE_CONFIGURATION,
      useValue: <ISdCoreConfiguration>{
        language: () => FR_MESSAGES,
      },
    },
  ],
};
```

### 5. Render a language switcher (with built-in list)
```typescript
@Component({
  imports: [MatButtonModule],
  template: `
    @for (lang of languages; track lang) {
      <button mat-stroked-button
              [color]="current() === lang ? 'primary' : ''"
              (click)="switch(lang)">
        {{ lang.toUpperCase() }}
      </button>
    }
  `,
})
class LangSwitcher {
  readonly #i18n = inject(I18nService);
  readonly languages = SUPPORTED_LANGUAGES;
  readonly current = this.#i18n.language;
  switch(lang: Language) { this.#i18n.setLanguage(lang); }
}
```

### 6. Translate inside a non-DI context (CKEditor plugin, etc.)
For code that runs outside Angular DI, accept `I18nService` via configuration and call it explicitly. The Core's editor / document-builder uses this `_i18n?: I18nService` field on its config object:

```typescript
// Plugin code
const i18n = editor.config.get('_i18n') as I18nService | undefined;
const label = i18n?.t('core.component.document-builder.ck-comment.label') ?? '';
```

The Angular wrapper component sets `config._i18n = inject(I18nService)` before instantiating the editor.

### 7. Hardcoded i18n for pure utility functions (no DI, no injection point)
When a function is too "low-level" to receive `I18nService` (pure utility, no Angular context, no config object to thread through), embed a small per-language message table inside the file and read the current language directly from localStorage. This is the pattern used by `BrowserUtilities.upload`:

```typescript
// utility.extension.ts — pure function, can't inject I18nService
const SD_UPLOAD_MESSAGES = {
  vi: { 'invalid-format': '[{name}] File tải lên không đúng định dạng. Vui lòng chọn lại', /* ... */ },
  en: { 'invalid-format': '[{name}] Invalid file format. Please select again', /* ... */ },
  ja: { 'invalid-format': '[{name}] ファイル形式が正しくありません。もう一度選択してください', /* ... */ },
  ko: { 'invalid-format': '[{name}] 파일 형식이 올바르지 않습니다. 다시 선택해 주세요', /* ... */ },
  zh: { 'invalid-format': '[{name}] 文件格式不正确，请重新选择', /* ... */ },
} as const;

const getSdUploadLang = (): keyof typeof SD_UPLOAD_MESSAGES => {
  try {
    const stored = localStorage.getItem('sd-core.language');
    if (stored && stored in SD_UPLOAD_MESSAGES) return stored as keyof typeof SD_UPLOAD_MESSAGES;
  } catch { /* ignore */ }
  return 'vi';
};

const throwUploadError = (msgKey: 'invalid-format' | 'invalid-size', name: string): never => {
  const lang = getSdUploadLang();
  const template = SD_UPLOAD_MESSAGES[lang][msgKey] ?? SD_UPLOAD_MESSAGES.vi[msgKey];
  throw new Error(template.replace('{name}', name));
};
```

Trade-offs:
- ✅ No DI dependency — works in any context (utility scripts, web workers, …).
- ✅ `Error.message` is already translated — consumer just calls `notify.error(err.message)`.
- ✅ Auto-sync with `I18nService` because both read the same `'sd-core.language'` localStorage key.
- ❌ Strings are duplicated outside the central catalog — `check:i18n-parity` does NOT validate them.
- ❌ File-level `WHITELIST` entry in `scripts/check-i18n.mjs` required (otherwise the hardcode guard flags the diacritics).
- Use only when DI is genuinely unavailable. Prefer the standard `I18nService.t()` path otherwise.

## Anti-patterns

- ❌ **Hardcoding VI/EN strings in components/services/templates** — always go through `i18n.t()` or `| translate`. The `check:i18n` guard catches it at CI.
- ❌ **Translating `console.log/warn/error` arguments** — they're dev logs, not UX. Mark with `// @i18n-ignore`.
- ❌ **Translating code comments / JSDoc** — comments are documentation, not user content.
- ❌ **Calling `setLanguage()` mid-render expecting the UI to update without reload** — pipe is `pure: true`. Use the default reload behavior, or accept that bindings already rendered won't refresh.
- ❌ **Adding a new key to only `EN_MESSAGES`** — TypeScript will error in `VI_MESSAGES`/`JA_MESSAGES`/`KO_MESSAGES`/`ZH_MESSAGES`; CI parity check will fail too. Add to ALL catalogs in the same commit.
- ❌ **Inventing new prefixes outside `core.*`** — portal-level keys shouldn't be added to Core's catalogs. Portals manage their own catalog. (Core's i18n is for Core's own strings; portals may use a separate translation system or wire portal strings via factory functions.)
- ❌ **Async custom language** — `CustomLanguageProvider` is sync. If you need to fetch a catalog, resolve it during `APP_INITIALIZER` before bootstrap and pass the resolved object via `useFactory`.
- ❌ **Using `String(params[x])` with possibly-undefined values directly in the template** — coalesce upstream: `{ name: user?.fullName ?? '' }`. Otherwise the placeholder renders as `"undefined"` at runtime.
- ❌ **Translating values in a top-level `export const` array** — runs at module-eval, no DI. Three options:
   1. Keep raw and live with the limitation (whitelist the file) — current state for `form-generic-component.model.ts`, `form-generic-expression.model.ts`, `form-generic-validation.model.ts`, plus their workflow duplicates.
   2. Convert to a factory: `export const buildXxx = (i18n: I18nService) => [...]` and let the consumer call it with DI.
   3. Hardcode a per-language table in the file and read `localStorage['sd-core.language']` — see Example #7 for the upload utility pattern.
- ❌ **Calling `i18n.t()` once and caching the result for the page lifetime** — that's fine on its own, but means a language toggle WON'T update that cached value (the page reload via `setLanguage()` should reset it anyway, but be aware).

## Known limitations / tech-debt

- **Static `export const` arrays with VI labels** — six model files under `components/form-generic/src/models/` and the workflow-duplicate folder are whitelisted in `scripts/check-i18n.mjs` because they're evaluated at module-eval time. Status: **by design** — kept as static literals to avoid forcing DI on every consumer of these model arrays. Portal authors who need localized dropdowns can wrap them with their own `i18n.t()` call at render time, or fork the factory pattern described in [Anti-patterns](#anti-patterns).
- **Async custom language is intentionally unsupported.** `CustomLanguageProvider` is sync only. To load a catalog from API, resolve it in `APP_INITIALIZER` before bootstrap and provide the resolved object via `useFactory`. Async support is not on the roadmap (would require `pure: false` pipe + service-level memoization, undermining current perf characteristics).

## Adding a new built-in language

To add a 6th built-in language (e.g. French):

1. Add to `Language` union in `projects/sdcorejs-angular/models/src/language.model.ts`:
   ```ts
   export type Language = 'vi' | 'en' | 'ja' | 'ko' | 'zh' | 'fr';
   export const SUPPORTED_LANGUAGES: readonly Language[] = ['vi', 'en', 'ja', 'ko', 'zh', 'fr'] as const;
   ```
2. Create `projects/sdcorejs-angular/i18n/src/fr.ts` with `FR_MESSAGES: Record<keyof typeof EN_MESSAGES, string>` — TypeScript enforces parity.
3. Register in `projects/sdcorejs-angular/i18n/src/i18n.messages.ts`:
   ```ts
   import { FR_MESSAGES } from './fr';
   export const I18N_MESSAGES: Record<Language, Readonly<Record<string, string>>> = {
     vi: VI_MESSAGES, en: EN_MESSAGES, ja: JA_MESSAGES, ko: KO_MESSAGES, zh: ZH_MESSAGES, fr: FR_MESSAGES,
   };
   ```
4. Update `scripts/check-i18n-parity.mjs` to include `fr` in the `others` map.
5. **Don't forget the hardcoded utility tables** — `BrowserUtilities.upload`'s `SD_UPLOAD_MESSAGES` in `utility.extension.ts` is a separate hardcoded 5-lang table. Add a `fr:` entry there too.
6. **Don't forget HomePage `#localeMap`** — `home-page.component.ts` maps `Language` → BCP 47 locale tag for date formatting. Add `fr: 'fr-FR'`.

The TypeScript compiler will surface any missed site immediately (each catalog must satisfy `Record<keyof typeof EN_MESSAGES, string>`).

## Related
- `@sdcorejs/angular/models` — leaf entry point owning the `Language` type + `SUPPORTED_LANGUAGES` constant. Imported by both `configurations` and `i18n`.
- `@sdcorejs/angular/configurations` (`SD_CORE_CONFIGURATION`) — DI token where `language` is set.
- `@sdcorejs/angular/utilities/models` (`MaybeAsync`) — used elsewhere but **not** by i18n (custom language is sync-only by design).
- `@sdcorejs/angular/utilities/extensions` (`BrowserUtilities.upload`) — example of the DI-less hardcode pattern (see Example #7).
- `scripts/check-i18n.mjs` + `scripts/check-i18n-parity.mjs` — CI guards.
- `projects/sdcorejs-angular/i18n/src/{vi,en,ja,ko,zh}.ts` — catalog source files.
