npm package: @reforgium/presentia
Description: Angular infrastructure library for i18n, route-aware namespace preload, theming, adaptive breakpoints, route state, and SEO
Author: rtommievich
Latest version: 2.1.1

# @reforgium/presentia

[![npm version](https://badge.fury.io/js/%40reforgium%2Fpresentia.svg)](https://www.npmjs.com/package/@reforgium/presentia)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

Infrastructure package for Angular applications:
- localization (`LangService`, `lang` pipe, `reLang` directive),
- theming (`ThemeService`),
- adaptive behavior (`AdaptiveService`, `*reIfDevice`),
- route state helper (`RouteWatcher`),
- SEO automation (`SeoService`, `SeoRouteListener`).

## Release Highlights (2.0.0)

- Grouped `v2` provider via `providePresentia(...)`.
- Route-aware namespace preload with generated manifests, diagnostics, and strict failure handling.
- Extended `ThemeService` with theme registry, configurable DOM strategy, and persistence adapter.
- Breakpoint-first adaptive helpers with `reIfDeviceAtLeast` and `reIfDeviceBetween`.
- Richer `RouteWatcher` state with merged/deepest selectors and route matching helpers.

## Install

```bash
npm i @reforgium/presentia
```

## Recommended Setup

For new integrations prefer:
- `providePresentia(...)`
- route namespace preload in `blocking` mode
- `theme.registry` even if you currently use only `light` / `dark`
- explicit `adaptive.breakpoints` when app breakpoints differ from defaults

Legacy integrations can keep `provideReInit(...)` temporarily, but it is now the compatibility path, not the recommended one.

## Quick Start

```ts
import { bootstrapApplication } from '@angular/platform-browser';
import { providePresentia } from '@reforgium/presentia';

bootstrapApplication(AppComponent, {
  providers: [
    providePresentia({
      lang: {
        source: {
          url: '/assets/i18n',
          fromAssets: true,
          defaultLang: 'ru',
          fallbackLang: 'en',
          supportedLangs: ['de'],
        },
        preload: {
          global: ['layout', 'common'],
        },
        rendering: {
          placeholder: '...',
        },
        missingKeyHandler: (key, ctx) => `[${ctx.lang}] ${key}`,
      },
      theme: {
        defaultTheme: 'light',
        dom: { darkClassName: 'dark' },
      },
    }),
  ],
});
```

## What You Get

Main provider:
- `providePresentia(config: PresentiaConfig)`

`providePresentia(...)` is the new grouped config entry point.
`provideReInit(...)` remains available as the legacy `1.x` style provider.

## Migration Snapshot

Typical `1.x -> v2` provider migration:

```ts
// 1.x
provideReInit({
  locale: {
    url: '/assets/i18n',
    isFromAssets: true,
    preloadNamespaces: ['layout', 'common'],
  },
  theme: {
    defaultTheme: 'light',
    darkThemePrefix: 're-dark',
  },
  breakpoints: {
    mobile: '(max-width: 719px)',
  },
});

// v2
providePresentia({
  lang: {
    source: {
      url: '/assets/i18n',
      fromAssets: true,
    },
    preload: {
      global: ['layout', 'common'],
    },
  },
  theme: {
    defaultTheme: 'light',
    dom: {
      darkClassName: 're-dark',
    },
  },
  adaptive: {
    breakpoints: {
      mobile: '(max-width: 719px)',
    },
  },
});
```

For a fuller mapping see [`V2-MIGRATION.md`](./V2-MIGRATION.md).

Localization exports:
- `LangService`
- `LangPipe`
- `LangDirective`
- `LANG_CONFIG`
- `LANG_PIPE_CONFIG`
- `LANG_MISSING_KEY_HANDLER`
- `PRESENTIA_ROUTE_NAMESPACES_DATA_KEY`
- Types: `LocaleConfig`, `LangModel`, `LangDto`, `LangParams`, `LangKey`, `LangKeyRegistry`, etc.

Theme exports:
- `ThemeService`
- `Theme`
- `THEME_CONFIG`
- `themes`

Adaptive exports:
- `AdaptiveService`
- `IfDeviceDirective`
- `DEVICE_BREAKPOINTS`, `defaultBreakpoints`

Routes and SEO exports:
- `RouteWatcher`
- `SeoService`
- `SeoRouteListener`

Legacy compatibility surface:
- `provideReInit(config: AppConfig)`
- `darkThemePrefix`
- `defaultThemeConfig`
- `defaultThemePersistenceAdapter`
- `RouteNamespaceDiagnosticsService`

## Provider Configuration

### `providePresentia`

```ts
providePresentia({
  lang: {
    source: {
      url: '/assets/i18n',
      fromAssets: true,
      defaultLang: 'ru',
      fallbackLang: 'en',
    },
    rendering: {
      placeholder: '...',
      missingValue: '--',
    },
    preload: {
      global: ['layout', 'common'],
      routes: {
        mode: 'blocking',
      },
    },
  },
  theme: {
    registry: ['light', 'dark'],
    defaultTheme: 'dark',
    persistence: 'localStorage',
    dom: {
      strategy: 'root-class',
      darkClassName: 're-dark',
    },
  },
  adaptive: {
    breakpoints: {
      mobile: '(max-width: 719px)',
      tablet: '(min-width: 720px) and (max-width: 1399px)',
      'desktop-s': '(min-width: 1400px) and (max-width: 1919px)',
      desktop: '(min-width: 1920px)',
    },
  },
});
```

Use `providePresentia(...)` for new integrations.
Use `provideReInit(...)` only when you need the legacy flat config shape.

### `provideReInit`

Legacy compatibility provider. Prefer `providePresentia(...)` for new integrations.

`AppConfig` includes:
- `locale: LocaleConfig` (required)
- `theme?: ThemeConfig`
- `breakpoints?: DeviceBreakpoints`
- `langPipe?: LangPipeConfig`
- `langMissingKeyHandler?: LangMissingKeyHandler`

It also wires integration tokens from `@reforgium/internal`:
- `TRANSLATION`
- `SELECTED_LANG`
- `CHANGE_LANG`
- `SELECTED_THEME`
- `CHANGE_THEME`
- `CURRENT_DEVICE`

## Localization

### `LocaleConfig`

```ts
type LocaleConfig = {
  url: string;
  isFromAssets: boolean;
  defaultLang?: Langs;
  fallbackLang?: Langs;
  defaultValue?: string;
  kgValue?: 'kg' | 'ky';
  supportedLangs?: readonly string[];
  preloadNamespaces?: readonly string[];
  requestBuilder?: (ctx: {
    ns: string;
    lang: Langs;
    isFromAssets: boolean;
    baseUrl: string;
  }) => string;
  requestOptionsFactory?: (ctx: {
    ns: string;
    lang: Langs;
    isFromAssets: boolean;
    baseUrl: string;
  }) => {
    headers?: HttpHeaders | Record<string, string | string[]>;
    params?: HttpParams | Record<string, string | number | boolean | readonly (string | number | boolean)[]>;
    withCredentials?: boolean;
    responseType?: 'json' | 'text' | 'blob' | 'arraybuffer';
    context?: HttpContext;
    transferCache?: boolean | { includeHeaders?: string[] };
  };
  responseAdapter?: (response: unknown, ctx: {
    ns: string;
    lang: Langs;
    isFromAssets: boolean;
  }) => LangModel | LangDto[];
  batchRequestBuilder?: (ctx: {
    namespaces: readonly string[];
    lang: Langs;
    isFromAssets: boolean;
    baseUrl: string;
  }) => string;
  batchResponseAdapter?: (response: unknown, ctx: {
    namespaces: readonly string[];
    lang: Langs;
    isFromAssets: boolean;
  }) => Record<string, LangModel | LangDto[]>;
  namespaceCache?: {
    maxNamespaces?: number;
    ttlMs?: number;
  };
  routeNamespacePreload?: {
    mode?: 'blocking' | 'lazy';
    dataKey?: string;
    manifest?: Record<string, readonly string[]>;
    mergeStrategy?: 'append' | 'replace';
    onError?: 'continue' | 'throw';
    diagnostics?: boolean;
  };
};
```

### Behavior Notes

- Built-in languages: `ru`, `kg`, `en`.
- Alias `ky` is accepted and normalized to internal `kg`.
- `supportedLangs` lets you add custom language codes.
- If key is missing, `defaultValue` is used (or missing key handler result).
- `defaultValue` is a missing-key fallback, not a loading placeholder.
- `LangPipe.placeholder` is used while a namespace is still loading.
- Stale HTTP responses are ignored when language changes mid-flight.

### Basic Usage

```ts
const lang = inject(LangService);

lang.setLang('en');
await lang.loadNamespace('layout');

const title = lang.get('layout.title');
const welcome = lang.get('layout.welcome', { name: 'John' });
```

### API Mode With Custom Payload

```ts
provideReInit({
  locale: {
    url: '/api/i18n',
    isFromAssets: false,
    requestBuilder: ({ ns, lang, baseUrl }) => `${baseUrl}/${ns}?language=${lang}`,
    requestOptionsFactory: () => ({
      headers: { 'x-tenant-id': 'tenant-1' },
      withCredentials: true,
      responseType: 'json',
    }),
    responseAdapter: (response) => (response as { data: LangDto[] }).data,
  },
});
```

### Batch Namespace Loading

```ts
provideReInit({
  locale: {
    url: '/api/i18n',
    isFromAssets: false,
    batchRequestBuilder: ({ lang, namespaces }) =>
      `/api/i18n/batch?language=${lang}&ns=${namespaces.join(',')}`,
    batchResponseAdapter: (response) => response as Record<string, LangDto[]>,
  },
});

const lang = inject(LangService);
await lang.loadNamespaces(['layout', 'common', 'breadcrumbs']);
```

### Route Namespace Preload

Use this when first-paint localization must be ready before page activation.
`presentia` patches router config automatically via `providePresentia(...)` or `provideReInit(...)`,
so no extra resolver wiring is required in the consumer app.

Behavior summary:
- `mode: 'blocking'` waits for route namespaces before route activation.
- `mode: 'lazy'` starts namespace loading without delaying navigation.
- `onError: 'continue'` keeps navigation alive if preload fails.
- `onError: 'throw'` cancels navigation by rethrowing the preload error.

Route data mode:

```ts
providePresentia({
  lang: {
    source: {
      url: '/assets/i18n',
      fromAssets: true,
    },
    preload: {
      routes: {},
    },
  },
});

export const routes: Routes = [
  {
    path: 'profile',
    component: ProfilePage,
    data: {
      presentiaNamespaces: ['profile', 'layout', 'common'],
    },
  },
];
```

Manifest mode:

```ts
const PRESENTIA_ROUTE_NAMESPACES = {
  '/': ['home', 'layout', 'common'],
  '/profile': ['profile', 'layout', 'common'],
  '/users/:id': ['users', 'layout', 'common'],
} as const;

providePresentia({
  lang: {
    source: {
      url: '/assets/i18n',
      fromAssets: true,
    },
    preload: {
      routes: {
        manifest: PRESENTIA_ROUTE_NAMESPACES,
        mode: 'blocking',
      },
    },
    diagnostics: {
      lateNamespaceLoads: true,
    },
  },
});
```

Legacy `1.x` projects can keep using `provideReInit(...)`,
but new integrations should prefer `providePresentia(...)`.

Generate the manifest in a consumer app:

```bash
npx presentia-gen-namespaces
```

Default paths:
- `--routes src/app/app.routes.ts`
- `--project src`
- `--tsconfig tsconfig.json`
- `--out src/app/presentia-route-namespaces.ts`

Explicit paths:

```bash
npx presentia-gen-namespaces \
  --routes src/app/app.routes.ts \
  --project src \
  --tsconfig tsconfig.json \
  --locales src/assets/locales \
  --out src/app/presentia-route-namespaces.ts
```

The public CLI is `presentia-gen-namespaces`.
Repository scripts such as `libs/presentia/scripts/generate-sandbox-namespaces.mjs`
are internal helpers for local demo verification only.

Best-effort extraction sources:
- route `data` lang keys such as `title: 'profile.title'`
- route `data.presentiaNamespaces`
- `{{ 'namespace.key' | lang }}`
- `reLang` / `reLangKey`
- `reLangAttrs`
- `lang.get('namespace.key')`
- `lang.observe('namespace.key')`
- imported local standalone components from component `imports: [...]`

When `--locales` is provided, generator validates discovered keys and namespaces
against real locale JSON files to reduce false positives.

Optional report mode:

```bash
npx presentia-gen-namespaces \
  --locales src/assets/locales \
  --report src/app/presentia-route-namespaces.report.json \
  --print-report
```

This emits a JSON report with static-analysis gaps such as unresolved lazy imports
or unsupported `presentiaNamespaces` expressions.

Options:
- `mode: 'blocking' | 'lazy'` - waits for namespaces before route activation, or starts loading without blocking.
- `dataKey` - custom route-data field name instead of default `presentiaNamespaces`.
- `manifest` - generated or handwritten `route -> namespaces[]` map.
- `mergeStrategy: 'append' | 'replace'` - merge manifest and route-data namespaces, or let route data override manifest entries.
- `onError: 'continue' | 'throw'` - in blocking mode either keep navigation alive on preload failure, or cancel it by rethrowing the load error.
- `diagnostics` - in dev mode warns when a namespace starts loading only after route activation.

### Namespace Cache Policy

```ts
provideReInit({
  locale: {
    url: '/assets/i18n',
    isFromAssets: true,
    namespaceCache: {
      maxNamespaces: 20,
      ttlMs: 10 * 60 * 1000,
    },
  },
});
```

Cache control methods:
- `lang.evictNamespace('layout')`
- `lang.clearNamespaceCache()`

### Copy-Paste Recipes

#### 1. Assets JSON (simple frontend-only i18n)

```ts
provideReInit({
  locale: {
    url: '/assets/locales',
    isFromAssets: true,
    defaultLang: 'en',
    fallbackLang: 'en',
    preloadNamespaces: ['layout', 'common'],
  },
});
```

Expected files:
- `/assets/locales/layout.en.json`
- `/assets/locales/common.en.json`

#### 2. Flat API DTO (`LangDto[]`)

```ts
provideReInit({
  locale: {
    url: '/api/i18n',
    isFromAssets: false,
    requestBuilder: ({ ns, lang, baseUrl }) => `${baseUrl}/${ns}?language=${lang}`,
    // default parser already supports LangDto[]
  },
});
```

Expected payload:

```json
[
  { "namespace": "layout", "code": "layout.title", "localization": "Dashboard" }
]
```

#### 3. Envelope API DTO (`{ data: [...] }`)

```ts
provideReInit({
  locale: {
    url: '/api/i18n',
    isFromAssets: false,
    responseAdapter: (response) => (response as { data: LangDto[] }).data,
  },
});
```

Expected payload:

```json
{
  "data": [{ "namespace": "layout", "code": "layout.title", "localization": "Dashboard" }]
}
```

#### 4. Multi-tenant API with headers and params

```ts
provideReInit({
  locale: {
    url: '/api/i18n',
    isFromAssets: false,
    requestOptionsFactory: ({ lang }) => ({
      headers: { 'x-tenant-id': 'tenant-1' },
      params: { region: 'eu', language: lang },
      withCredentials: true,
    }),
    requestBuilder: ({ ns, baseUrl }) => `${baseUrl}/${ns}`,
  },
});
```

#### 5. Batch endpoint (`/batch`) for many namespaces

```ts
provideReInit({
  locale: {
    url: '/api/i18n',
    isFromAssets: false,
    batchRequestBuilder: ({ namespaces, lang, baseUrl }) =>
      `${baseUrl}/batch?language=${lang}&ns=${namespaces.join(',')}`,
    batchResponseAdapter: (response) =>
      response as Record<string, LangDto[]>,
  },
});

const lang = inject(LangService);
await lang.loadNamespaces(['layout', 'common', 'breadcrumbs']);
```

## `LangPipe`

- Name: `lang`
- Standalone, impure pipe (`pure: false`)
- Internal cache with TTL and max size
- Configurable placeholder while namespace is loading

```html
<h1>{{ 'layout.title' | lang }}</h1>
<p>{{ 'users.welcome' | lang: { name: userName, count: 12 } }}</p>
```

`LangPipeConfig` (via `LANG_PIPE_CONFIG` or `provideReInit.langPipe`):
- `ttlMs?: number`
- `maxCacheSize?: number`
- `placeholder?: string | ((query: string) => string)`

## `reLang` Directive

Auto-localizes text and selected attributes.

Supported modes:
- `'all'`
- `'only-content'`
- `'only-placeholder'`
- `'only-label'`
- `'only-title'`

Examples:

```html
<button reLang title="common.save">common.save</button>
<button [reLang]="'only-title'" title="common.cancel">Cancel</button>
<input reLang [langForAttr]="'aria-label'" aria-label="forms.username" />
<div [reLang]="{ mode: 'only-content', textKey: 'layout.title' }"></div>
```

## Typed Lang Keys (Opt-in)

`LangService.get()` and `LangService.observe()` support typed keys via module augmentation.

Generate keys from locale JSON in consumer app:

```bash
npx presentia-gen-lang-keys --locales src/assets/locales --out src/types/presentia-lang-keys.d.ts
```

This generates:
- `declare module '@reforgium/presentia'`
- `interface LangKeyRegistry { keys: 'layout.title' | 'common.save' | ... }`

After generation, invalid keys in `get()`/`observe()` are compile-time errors.

## Theme

`ThemeService`:
- `theme()` current theme (`Theme = BaseTheme | string`)
- `isLight()`
- `isDark()`
- `is(theme | theme[])`
- `switch(theme?)` explicit switch or toggle

```ts
const theme = inject(ThemeService);
theme.switch('dark');
```

`ThemeConfig`:
- `registry?: readonly Theme[]`
- `defaultTheme?: Theme`
- `darkThemePrefix?: string`
- `dom?: { strategy?: 'root-class' | 'data-attribute'; rootSelector?: string; darkThemePrefix?: string; attributeName?: string; themeClassPrefix?: string; classNameBuilder?: (theme) => string }`

`providePresentia(...).theme` additionally supports:
- `persistence?: 'localStorage' | 'none'`
- `persistenceAdapter?: PersistenceAdapter`
- `dom.darkClassName` as a consumer-friendly alias for `darkThemePrefix`
- `dom.classPrefix` as a consumer-friendly alias for `themeClassPrefix`

Example:

```ts
providePresentia({
  lang: {
    source: {
      url: '/assets/i18n',
      fromAssets: true,
    },
  },
  theme: {
    registry: ['light', 'dark', 'sepia'],
    defaultTheme: 'light',
    persistence: 'localStorage',
    dom: {
      strategy: 'root-class',
      darkClassName: 're-dark',
      classPrefix: 're-theme-',
    },
  },
});
```

With that config:
- `dark` still applies the legacy dark class (`re-dark`)
- every registered theme also gets a prefixed class such as `re-theme-light`, `re-theme-dark`, `re-theme-sepia`
- for fully custom naming, use `dom.classNameBuilder`

## Adaptive

`AdaptiveService` provides reactive signals:
- `device()` -> `'desktop' | 'desktop-s' | 'tablet' | 'mobile'`
- `breakpoint()` alias for the current device bucket
- `width()`
- `height()`
- `isMobile()`
- `isTablet()`
- `isDesktopSmall()`
- `isDesktop()`
- `isPortrait()`
- `is(device | device[])`
- `isAtLeast(device)`
- `isBetween(min, max)`

`*reIfDevice` structural directive:

```html
<div *reIfDevice="'desktop'">Desktop only</div>
<div *reIfDevice="['mobile', 'tablet']">Mobile and tablet</div>
<div *reIfDevice="'mobile'; inverse: true">Hidden on mobile</div>
<div *reIfDeviceAtLeast="'tablet'">Tablet and larger</div>
<div *reIfDeviceBetween="['tablet', 'desktop']">Tablet to desktop</div>
```

Breakpoints are configurable via `DeviceBreakpoints`.

## Route State Helper

`RouteWatcher` gives reactive route state:
- `params()`
- `deepestParams()`
- `query()`
- `data()`
- `mergedData()`
- `url()`
- `routePattern()`
- `fragment()`
- `state()`
- `selectData<T>(key)`
- `selectData<T>(key, 'merged')`
- `selectParam(key)`
- `selectParam(key, 'deepest')`
- `matchesPath(path | regexp)`

Use when you want route-derived UI state without manual router subscriptions.

## SEO

### `SeoService`

Methods:
- `setTitle`
- `setDescription`
- `setKeywords`
- `setRobots`
- `setCanonical`
- `setOg`
- `setTwitter`
- `setJsonLd`

### `SeoRouteListener`

Auto-applies SEO from route `data` on navigation:

```ts
const seoRoute = inject(SeoRouteListener);
seoRoute.init('https://example.com');
```

Expected route `data` keys:
- `title`
- `description`
- `robots`
- `canonical`
- `og`
- `twitter`
- `jsonld`

## Performance Notes

- Use `preloadNamespaces` for first-screen keys.
- Use `loadNamespaces` + batch hooks for chatty APIs.
- Use `namespaceCache` limits to control memory in long-lived sessions.
- Keep `LangPipe.maxCacheSize` realistic for your screen complexity.

## Troubleshooting

If some texts appear untranslated until reload:
- Ensure keys are valid (`namespace.key` format).
- Confirm namespace is loaded for current language.
- Check API adapter (`responseAdapter`) returns correct shape.
- Verify route/lazy components do not call `get()` before namespace load if you require strict first render.
- Prefer `observe()` / `lang` pipe for reactive updates.

If custom language is ignored:
- Add it to `supportedLangs`.
- Pass lowercase code or rely on normalization.

If `ky`/`kg` behavior is unexpected:
- `ky` input is normalized to internal `kg`.
- `currentLang()` returns `kgValue` when language is `kg`.

## License

MIT