@reforgium/presentia
v2.0.0
Published
Angular infrastructure library for i18n, route-aware namespace preload, theming, adaptive breakpoints, route state, and SEO
Maintainers
Readme
@reforgium/presentia
Infrastructure package for Angular applications:
- localization (
LangService,langpipe,reLangdirective), - theming (
ThemeService), - adaptive behavior (
AdaptiveService,*reIfDevice), - route state helper (
RouteWatcher), - SEO automation (
SeoService,SeoRouteListener).
Release Highlights (2.0.0)
- Grouped
v2provider viaprovidePresentia(...). - Route-aware namespace preload with generated manifests, diagnostics, and strict failure handling.
- Extended
ThemeServicewith theme registry, configurable DOM strategy, and persistence adapter. - Breakpoint-first adaptive helpers with
reIfDeviceAtLeastandreIfDeviceBetween. - Richer
RouteWatcherstate with merged/deepest selectors and route matching helpers.
Install
npm i @reforgium/presentiaRecommended Setup
For new integrations prefer:
providePresentia(...)- route namespace preload in
blockingmode theme.registryeven if you currently use onlylight/dark- explicit
adaptive.breakpointswhen app breakpoints differ from defaults
Legacy integrations can keep provideReInit(...) temporarily, but it is now the compatibility path, not the recommended one.
Quick Start
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:
// 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.
Localization exports:
LangServiceLangPipeLangDirectiveLANG_CONFIGLANG_PIPE_CONFIGLANG_MISSING_KEY_HANDLERPRESENTIA_ROUTE_NAMESPACES_DATA_KEY- Types:
LocaleConfig,LangModel,LangDto,LangParams,LangKey,LangKeyRegistry, etc.
Theme exports:
ThemeServiceThemeTHEME_CONFIGthemes
Adaptive exports:
AdaptiveServiceIfDeviceDirectiveDEVICE_BREAKPOINTS,defaultBreakpoints
Routes and SEO exports:
RouteWatcherSeoServiceSeoRouteListener
Legacy compatibility surface:
provideReInit(config: AppConfig)darkThemePrefixdefaultThemeConfigdefaultThemePersistenceAdapterRouteNamespaceDiagnosticsService
Provider Configuration
providePresentia
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?: ThemeConfigbreakpoints?: DeviceBreakpointslangPipe?: LangPipeConfiglangMissingKeyHandler?: LangMissingKeyHandler
It also wires integration tokens from @reforgium/internal:
TRANSLATIONSELECTED_LANGCHANGE_LANGSELECTED_THEMECHANGE_THEMECURRENT_DEVICE
Localization
LocaleConfig
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
kyis accepted and normalized to internalkg. supportedLangslets you add custom language codes.- If key is missing,
defaultValueis used (or missing key handler result). defaultValueis a missing-key fallback, not a loading placeholder.LangPipe.placeholderis used while a namespace is still loading.- Stale HTTP responses are ignored when language changes mid-flight.
Basic Usage
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
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
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:
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:
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:
npx presentia-gen-namespacesDefault paths:
--routes src/app/app.routes.ts--project src--tsconfig tsconfig.json--out src/app/presentia-route-namespaces.ts
Explicit paths:
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.tsThe 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
datalang keys such astitle: 'profile.title' - route
data.presentiaNamespaces {{ 'namespace.key' | lang }}reLang/reLangKeyreLangAttrslang.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:
npx presentia-gen-namespaces \
--locales src/assets/locales \
--report src/app/presentia-route-namespaces.report.json \
--print-reportThis 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 defaultpresentiaNamespaces.manifest- generated or handwrittenroute -> 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
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)
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[])
provideReInit({
locale: {
url: '/api/i18n',
isFromAssets: false,
requestBuilder: ({ ns, lang, baseUrl }) => `${baseUrl}/${ns}?language=${lang}`,
// default parser already supports LangDto[]
},
});Expected payload:
[
{ "namespace": "layout", "code": "layout.title", "localization": "Dashboard" }
]3. Envelope API DTO ({ data: [...] })
provideReInit({
locale: {
url: '/api/i18n',
isFromAssets: false,
responseAdapter: (response) => (response as { data: LangDto[] }).data,
},
});Expected payload:
{
"data": [{ "namespace": "layout", "code": "layout.title", "localization": "Dashboard" }]
}4. Multi-tenant API with headers and params
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
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
<h1>{{ 'layout.title' | lang }}</h1>
<p>{{ 'users.welcome' | lang: { name: userName, count: 12 } }}</p>LangPipeConfig (via LANG_PIPE_CONFIG or provideReInit.langPipe):
ttlMs?: numbermaxCacheSize?: numberplaceholder?: string | ((query: string) => string)
reLang Directive
Auto-localizes text and selected attributes.
Supported modes:
'all''only-content''only-placeholder''only-label''only-title'
Examples:
<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:
npx presentia-gen-lang-keys --locales src/assets/locales --out src/types/presentia-lang-keys.d.tsThis 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
const theme = inject(ThemeService);
theme.switch('dark');ThemeConfig:
registry?: readonly Theme[]defaultTheme?: ThemedarkThemePrefix?: stringdom?: { strategy?: 'root-class' | 'data-attribute'; rootSelector?: string; darkThemePrefix?: string; attributeName?: string; themeClassPrefix?: string; classNameBuilder?: (theme) => string }
providePresentia(...).theme additionally supports:
persistence?: 'localStorage' | 'none'persistenceAdapter?: PersistenceAdapterdom.darkClassNameas a consumer-friendly alias fordarkThemePrefixdom.classPrefixas a consumer-friendly alias forthemeClassPrefix
Example:
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:
darkstill 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 bucketwidth()height()isMobile()isTablet()isDesktopSmall()isDesktop()isPortrait()is(device | device[])isAtLeast(device)isBetween(min, max)
*reIfDevice structural directive:
<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:
setTitlesetDescriptionsetKeywordssetRobotssetCanonicalsetOgsetTwittersetJsonLd
SeoRouteListener
Auto-applies SEO from route data on navigation:
const seoRoute = inject(SeoRouteListener);
seoRoute.init('https://example.com');Expected route data keys:
titledescriptionrobotscanonicalogtwitterjsonld
Performance Notes
- Use
preloadNamespacesfor first-screen keys. - Use
loadNamespaces+ batch hooks for chatty APIs. - Use
namespaceCachelimits to control memory in long-lived sessions. - Keep
LangPipe.maxCacheSizerealistic for your screen complexity.
Troubleshooting
If some texts appear untranslated until reload:
- Ensure keys are valid (
namespace.keyformat). - 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()/langpipe 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:
kyinput is normalized to internalkg.currentLang()returnskgValuewhen language iskg.
License
MIT
