@endora-commerce/mod-i18n 0.100.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/dist/backend/cli/coverage.d.ts +29 -0
- package/dist/backend/cli/coverage.d.ts.map +1 -0
- package/dist/backend/cli/coverage.js +84 -0
- package/dist/backend/cli/coverage.js.map +1 -0
- package/dist/backend/cli/reload.d.ts +35 -0
- package/dist/backend/cli/reload.d.ts.map +1 -0
- package/dist/backend/cli/reload.js +18 -0
- package/dist/backend/cli/reload.js.map +1 -0
- package/dist/backend/entities/translation-bundle.entity.d.ts +23 -0
- package/dist/backend/entities/translation-bundle.entity.d.ts.map +1 -0
- package/dist/backend/entities/translation-bundle.entity.js +69 -0
- package/dist/backend/entities/translation-bundle.entity.js.map +1 -0
- package/dist/backend/index.d.ts +164 -0
- package/dist/backend/index.d.ts.map +1 -0
- package/dist/backend/index.js +134 -0
- package/dist/backend/index.js.map +1 -0
- package/dist/backend/routes.admin.d.ts +41 -0
- package/dist/backend/routes.admin.d.ts.map +1 -0
- package/dist/backend/routes.admin.js +74 -0
- package/dist/backend/routes.admin.js.map +1 -0
- package/dist/backend/services/bundle-loader.d.ts +49 -0
- package/dist/backend/services/bundle-loader.d.ts.map +1 -0
- package/dist/backend/services/bundle-loader.js +84 -0
- package/dist/backend/services/bundle-loader.js.map +1 -0
- package/dist/backend/services/bundle-reconciler.d.ts +82 -0
- package/dist/backend/services/bundle-reconciler.d.ts.map +1 -0
- package/dist/backend/services/bundle-reconciler.js +38 -0
- package/dist/backend/services/bundle-reconciler.js.map +1 -0
- package/dist/backend/services/i18n-service.d.ts +67 -0
- package/dist/backend/services/i18n-service.d.ts.map +1 -0
- package/dist/backend/services/i18n-service.js +323 -0
- package/dist/backend/services/i18n-service.js.map +1 -0
- package/dist/backend/services/missing-key-logger.d.ts +52 -0
- package/dist/backend/services/missing-key-logger.d.ts.map +1 -0
- package/dist/backend/services/missing-key-logger.js +37 -0
- package/dist/backend/services/missing-key-logger.js.map +1 -0
- package/dist/manifest.d.ts +214 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +245 -0
- package/dist/manifest.js.map +1 -0
- package/dist/migrations/20260507T091405_i18n_admin_i18n_init.d.ts +30 -0
- package/dist/migrations/20260507T091405_i18n_admin_i18n_init.d.ts.map +1 -0
- package/dist/migrations/20260507T091405_i18n_admin_i18n_init.js +50 -0
- package/dist/migrations/20260507T091405_i18n_admin_i18n_init.js.map +1 -0
- package/dist/migrations/index.d.ts +23 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/migrations/index.js +23 -0
- package/dist/migrations/index.js.map +1 -0
- package/docs/i18n.md +166 -0
- package/i18n/en.json +1957 -0
- package/i18n/pl.json +1957 -0
- package/package.json +68 -0
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { defineModuleManifest, } from '@endora-commerce/contracts';
|
|
2
|
+
/**
|
|
3
|
+
* Admin UI i18n subsystem — feature 019.
|
|
4
|
+
*
|
|
5
|
+
* Platform-internal module (underscore-prefixed exemption per Constitution
|
|
6
|
+
* Principle VI, alongside `auth` / `example` / `_lifecycle`). Owns the
|
|
7
|
+
* `translation_bundles` table, the per-language merged-bundle resolver,
|
|
8
|
+
* the read API consumed by the admin SPA at boot, and the `core`
|
|
9
|
+
* namespace that holds admin-chrome strings (AppShell, navigation,
|
|
10
|
+
* login, profile).
|
|
11
|
+
*
|
|
12
|
+
* Depends on `_lifecycle` because every module's bundle install / hard-
|
|
13
|
+
* uninstall is driven by the lifecycle orchestrator's hook surface.
|
|
14
|
+
*/
|
|
15
|
+
export const manifest = defineModuleManifest({
|
|
16
|
+
id: '_i18n',
|
|
17
|
+
name: 'Admin UI i18n',
|
|
18
|
+
// One string literal, not a concatenation: `manifests:generate` reads this
|
|
19
|
+
// field to render the package's `description`, and it reads a literal — a
|
|
20
|
+
// package whose description is computed is one the generator refuses rather
|
|
21
|
+
// than invents a sentence for (feature 080, T041).
|
|
22
|
+
description: 'Per-user Admin UI language preference and module-scoped translation bundles. English is the platform-wide fallback (FR-013 / FR-016).',
|
|
23
|
+
version: '1.0.0',
|
|
24
|
+
// `auth` owns the `requireAdmin` port every route here is gated by;
|
|
25
|
+
// `admin_users` owns the service the preferred-language setter writes through.
|
|
26
|
+
// Neither depends back on this module, so the graph stays acyclic.
|
|
27
|
+
dependencies: ['_lifecycle', 'auth', 'admin_users'],
|
|
28
|
+
i18n: { bundlesDir: 'i18n' },
|
|
29
|
+
docs: { dir: 'docs' },
|
|
30
|
+
// Feature 074 (Constitution XVII), test C1 — reachability. Two grounds hold
|
|
31
|
+
// and they are stated in that order because only the first is about this
|
|
32
|
+
// module's merits: every user-facing string on every surface resolves here,
|
|
33
|
+
// including the labels on `/platform/modules`, so switching it off would
|
|
34
|
+
// leave an operator unable to read the screen that switches it back on. The
|
|
35
|
+
// `_`-prefix rule is the structural second: an `_`-prefixed id is
|
|
36
|
+
// platform-internal and `assertActivationRules`
|
|
37
|
+
// (`packages/contracts/src/modules.ts`) refuses any other activation form
|
|
38
|
+
// for one.
|
|
39
|
+
activation: {
|
|
40
|
+
nonDeactivatable: true,
|
|
41
|
+
reason: 'Every user-facing string on every surface is served from here, including the labels ' +
|
|
42
|
+
'on the platform screen that holds the module switches.',
|
|
43
|
+
},
|
|
44
|
+
/**
|
|
45
|
+
* **The platform's error-code block — 21 codes, and this is their home** rather
|
|
46
|
+
* than the residue of a migration (feature 090,
|
|
47
|
+
* `specs/090-module-owned-error-codes/core-block-home.md`; D-186 §4).
|
|
48
|
+
*
|
|
49
|
+
* A code is here because **no module owns its noun** — D-121 T3, and nothing
|
|
50
|
+
* weaker. Not because the platform throws it: D-121 rejected the thrower rule
|
|
51
|
+
* by measurement, and four of the entries below name a code exactly one module
|
|
52
|
+
* raises. Each carries its tier and the sentence that argues it in
|
|
53
|
+
* `PLATFORM_OWNED_ERROR_CODES`
|
|
54
|
+
* (`backend/test/fixtures/error-code-routing/reference-ledgers.ts`), which is
|
|
55
|
+
* where a reason can live in a form a test reads — a comment does not survive
|
|
56
|
+
* `tsc`, and the gate reads this list out of the built package.
|
|
57
|
+
*
|
|
58
|
+
* **The gate, and what it is derived from.**
|
|
59
|
+
* `backend/test/unit/_i18n/platform-error-code-block.test.ts` holds this list
|
|
60
|
+
* and those annotations equal in **both** directions: a code added here with no
|
|
61
|
+
* reason of its own is `unannotated` and names itself, and an annotation this
|
|
62
|
+
* list no longer backs is `undeclared`. Neither side is a written-down list of
|
|
63
|
+
* twenty-one (D-100) — the annotations' membership is itself held against
|
|
64
|
+
* `intendedRouting(capture, ledgers)`, the frozen chain capture with the
|
|
65
|
+
* re-homing and minting ledgers laid over it, so what belongs here is the
|
|
66
|
+
* hundred the deleted chain answered `core` for minus the seventy-nine D-129's
|
|
67
|
+
* sweep moved. D-186 §4 chose that over a scanner because both scanners were
|
|
68
|
+
* measured and both are ledgers of exceptions (`d129-sweep.md` §6.1–§6.2),
|
|
69
|
+
* and because a block of twenty-one lines is one a reviewer reads in full.
|
|
70
|
+
*
|
|
71
|
+
* **How it came to be twenty-one.** The list opened as the frozen chain
|
|
72
|
+
* capture and not as a judgement: the verbatim output of the runbook's step-1
|
|
73
|
+
* derivation over `test/fixtures/error-code-routing/chain-answers.ts`, every
|
|
74
|
+
* code `moduleIdForErrorCode` answered `core` for, because
|
|
75
|
+
* `contracts/error-code-declaration.md` §6.2 makes the migration
|
|
76
|
+
* answer-preserving over all 289 codes with no exception list and §6.5 puts
|
|
77
|
+
* re-routing out of scope. Most of that hundred was never the platform's: they
|
|
78
|
+
* fell off the end of a prefix chain that ended in `return 'core'`, which is
|
|
79
|
+
* the defect `specs/082-error-code-ownership/rulings.md` §9 opened the sweep
|
|
80
|
+
* for. D-129's sweep then moved **79 codes into 20 modules** over six batches —
|
|
81
|
+
* Tier A's `KSEF_*` and `PIM_ERGONODE_*` (MR 2) and its six remaining modules
|
|
82
|
+
* (MR 3), Tier B's six that already shipped a bundle (MR 4) and its four that
|
|
83
|
+
* had to create one (MR 5), then Tier C's `admin_roles` (MR 6) and
|
|
84
|
+
* `organizations` (MR 7) — and every one of the 79 carries a
|
|
85
|
+
* `REHOMED_ERROR_CODES` entry naming where it went, which tier decided it and
|
|
86
|
+
* why. The capture itself is untouched, because a reference a migration may
|
|
87
|
+
* rewrite is one that agrees with whatever the migration did.
|
|
88
|
+
*
|
|
89
|
+
* **Do not move one in passing.** A code that leaves this list without arriving
|
|
90
|
+
* in its owner's manifest routes nowhere; one that arrives in both routes to
|
|
91
|
+
* neither, and the collision rule makes that visible at composition rather than
|
|
92
|
+
* at an operator. The three ledgers and the two harnesses all read the same
|
|
93
|
+
* reference, so a half-done move is red in the merge request that made it.
|
|
94
|
+
*
|
|
95
|
+
* **Why `_i18n` and not a module called `core`.** `core` is not a module id. It
|
|
96
|
+
* is the synthetic namespace this module's bundle is exposed under at the
|
|
97
|
+
* resolver boundary (`I18N_CHROME_MODULE_ID` / `CORE_NAMESPACE` in
|
|
98
|
+
* `backend/services/i18n-service.ts`), so the module that owns the platform
|
|
99
|
+
* bundle — the phrase §6.5 uses — is this one. Every sentence for these codes
|
|
100
|
+
* already lives in this package's `i18n/{en,pl}.json`. The alternatives, and
|
|
101
|
+
* why each was refused, are §4 of the design note: a residual set in
|
|
102
|
+
* `packages/contracts` (a second input to a derivation §4 says has one),
|
|
103
|
+
* `_lifecycle` (82 sentence keys moved for no operator-visible gain), and a new
|
|
104
|
+
* registered `_platform` module (a registry row, an install-order position and
|
|
105
|
+
* a `/platform/modules` row, invented to hold a list).
|
|
106
|
+
*
|
|
107
|
+
* **`tokens` is derived from the raise sites, not from the bundle** (runbook
|
|
108
|
+
* §5), and the arithmetic below is **re-derived on this tree rather than
|
|
109
|
+
* decremented** — which is the instruction each batch of the sweep followed and
|
|
110
|
+
* the reason the numbers survived it. Measured over the 21 by balanced-paren
|
|
111
|
+
* extraction of every `new HttpError(...)` call's own arguments: **794 raise
|
|
112
|
+
* sites, four of the codes carrying a `details.code`**. Three of the four
|
|
113
|
+
* declare their tokens here — five tokens in total, of which **two have a
|
|
114
|
+
* sentence in both languages** (`FORBIDDEN.organization_cannot_transact`,
|
|
115
|
+
* `.customer_outside_assignment_scope`) and three do not, each of those three
|
|
116
|
+
* being a Phase 4 finding rather than a silent absence. The fourth is
|
|
117
|
+
* `VALIDATION_FAILED`, which declares **none** although 19 distinct tokens are
|
|
118
|
+
* passed at its raise sites: `localizeErrorEnvelope` returns before translating
|
|
119
|
+
* that code (`packages/platform/src/http/error-envelope.ts`), so its
|
|
120
|
+
* `details.code` values are machine-readable discriminators on the wire and can
|
|
121
|
+
* never key an `errors.VALIDATION_FAILED.<token>` sentence. Declaring them
|
|
122
|
+
* would declare sentences nothing can render. If a code arrives or leaves,
|
|
123
|
+
* re-run the scan; do not adjust the number.
|
|
124
|
+
*
|
|
125
|
+
* **What the bundle holds for these 21**: 16 `errors.*` keys in each language —
|
|
126
|
+
* 14 base sentences and the two token keys above. The seven codes with no
|
|
127
|
+
* sentence are the six `MODULE_*` refusals an operator meets through the
|
|
128
|
+
* lifecycle CLI rather than through the envelope, plus `PRICE_UNAVAILABLE`,
|
|
129
|
+
* which nothing raises. All seven are on `UNTRANSLATED_ERROR_CODES` under this
|
|
130
|
+
* module's group and are drained by writing a sentence, never by moving a code.
|
|
131
|
+
*/
|
|
132
|
+
errorCodes: [
|
|
133
|
+
// T3 — the platform's own `MODULE_*` vocabulary (7). The noun is a module,
|
|
134
|
+
// and what installs, composes, gates and withdraws one is the platform.
|
|
135
|
+
// `settings`, `audit_logs`, `product_feeds` and `carts` raise four of these
|
|
136
|
+
// seven and own none of them; the deleted chain reached them through a rule
|
|
137
|
+
// that **named** them (`startsWith('MODULE_')`), which is a decision rather
|
|
138
|
+
// than the fall-through the rest of the block arrived by (`d129-sweep.md`
|
|
139
|
+
// §2.2).
|
|
140
|
+
{ code: 'MODULE_ACTIVATION_PROTECTED' },
|
|
141
|
+
{ code: 'MODULE_DEPENDENCIES_ABSENT' },
|
|
142
|
+
{ code: 'MODULE_DEPENDENTS_PRESENT' },
|
|
143
|
+
{ code: 'MODULE_DISABLED' },
|
|
144
|
+
{ code: 'MODULE_NOT_DEACTIVATABLE' },
|
|
145
|
+
{ code: 'MODULE_NOT_FOUND' },
|
|
146
|
+
{ code: 'MODULE_SETTING_READ_ONLY' },
|
|
147
|
+
// T3 — the envelope's generic vocabulary (6). Each of these is what dozens
|
|
148
|
+
// of modules answer with for a condition that is about the request rather
|
|
149
|
+
// than about a noun, so its sentence has to be generic and the specific case
|
|
150
|
+
// is a token on it (issue #65) or an interpolated value (issue #161).
|
|
151
|
+
{ code: 'FORBIDDEN', tokens: [
|
|
152
|
+
'organization_cannot_transact',
|
|
153
|
+
'customer_outside_assignment_scope',
|
|
154
|
+
'reorder_disabled',
|
|
155
|
+
] },
|
|
156
|
+
{ code: 'INTERNAL', tokens: ['customer_account_organization_missing'] },
|
|
157
|
+
{ code: 'NOT_FOUND' },
|
|
158
|
+
{ code: 'UNAUTHORIZED' },
|
|
159
|
+
{ code: 'VALIDATION_FAILED' },
|
|
160
|
+
{ code: 'VERSION_CONFLICT', tokens: ['organization_version_mismatch'] },
|
|
161
|
+
// T3 by D-122 — a noun with two claimants or none (7). Identical claimants
|
|
162
|
+
// are the proof: `admin_users` and `customer_accounts` each own an account
|
|
163
|
+
// with a password, `orders` and `returns` each own a state machine, and
|
|
164
|
+
// `catalog` and `price_lists` both claim the noun "price".
|
|
165
|
+
// `INVALID_TRANSITION` is the code D-122 was written about.
|
|
166
|
+
{ code: 'CURRENT_PASSWORD_INVALID' },
|
|
167
|
+
{ code: 'EMAIL_ALREADY_REGISTERED' },
|
|
168
|
+
{ code: 'INVALID_CREDENTIALS' },
|
|
169
|
+
{ code: 'INVALID_TRANSITION' },
|
|
170
|
+
{ code: 'PRICE_UNAVAILABLE' },
|
|
171
|
+
{ code: 'TERMS_VERSION_STALE' },
|
|
172
|
+
{ code: 'TOKEN_INVALID_OR_EXPIRED' },
|
|
173
|
+
// T3 by declaration (1). The deleted chain named `RATE_LIMITED` in an
|
|
174
|
+
// explicit generic list rather than reaching it by fall-through, which makes
|
|
175
|
+
// it `core-block-home.md` §1.3's counter-example: an explicit T3 with one
|
|
176
|
+
// raiser today. The noun is a request budget, which the platform imposes.
|
|
177
|
+
{ code: 'RATE_LIMITED' },
|
|
178
|
+
],
|
|
179
|
+
});
|
|
180
|
+
/**
|
|
181
|
+
* `translation_bundles` follows the manifest set — feature 080, T036a / D-159.
|
|
182
|
+
*
|
|
183
|
+
* Not an install hook: an install hook fires for *its own* module, and this has
|
|
184
|
+
* to run whenever **any** module is installed, because this module's table is a
|
|
185
|
+
* projection of every other module's `manifest.i18n` declaration. Not a port
|
|
186
|
+
* either: the lifecycle orchestrator also serves the five `module:*` commands,
|
|
187
|
+
* and a platform command composes no container to resolve one from (D-157.2 /
|
|
188
|
+
* D-157.4) — which is exactly how a terminal install came to write no bundle at
|
|
189
|
+
* all while the same install from `/platform/modules` wrote them.
|
|
190
|
+
*
|
|
191
|
+
* The service is imported at call time, not at module load. This file is
|
|
192
|
+
* imported by the generated manifest index, which is in turn imported by every
|
|
193
|
+
* static check script and by `src/db/configured-migrations.ts`; a static import
|
|
194
|
+
* of `I18nService` would pull an ORM-dependent graph into all of them.
|
|
195
|
+
*
|
|
196
|
+
* A fresh `I18nService` per call is correct and not a lost cache:
|
|
197
|
+
* `getMergedBundleForLanguage` revalidates against `MAX(version)` in the table
|
|
198
|
+
* on every read, so the running server picks the new rows up on its next
|
|
199
|
+
* request without having been the instance that wrote them.
|
|
200
|
+
*/
|
|
201
|
+
export const lifecycleParticipant = {
|
|
202
|
+
async onModuleInstalled({ moduleId, manifest: installed, modulePath, em }) {
|
|
203
|
+
// The declaration belongs to the module being installed, so the decision
|
|
204
|
+
// is this one's to make: a manifest with no `i18n` block ships no bundle.
|
|
205
|
+
if (!installed.i18n)
|
|
206
|
+
return;
|
|
207
|
+
const { I18nService } = await import('./backend/services/i18n-service.js');
|
|
208
|
+
await new I18nService({ em: () => em }).installBundlesForModule(moduleId, modulePath, installed.i18n.bundlesDir, em);
|
|
209
|
+
},
|
|
210
|
+
async onModuleHardUninstalled({ moduleId, em }) {
|
|
211
|
+
// Unconditional, and deliberately not gated on the manifest's `i18n`
|
|
212
|
+
// block: the manifest is `null` for an orphan row whose module this
|
|
213
|
+
// instance no longer has, and that is the one case whose rows nothing
|
|
214
|
+
// else will ever remove.
|
|
215
|
+
const { I18nService } = await import('./backend/services/i18n-service.js');
|
|
216
|
+
await new I18nService({ em: () => em }).removeBundlesForModule(moduleId, em);
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
/**
|
|
220
|
+
* The two operator commands this module declares — feature 080, T042b /
|
|
221
|
+
* D-160.9.
|
|
222
|
+
*
|
|
223
|
+
* They were `scripts/reload.ts` and `scripts/coverage.ts`, each opening its own
|
|
224
|
+
* ORM and building its own `I18nService`. The host composes now and hands the
|
|
225
|
+
* body this module's `ModuleContext`, which is what retired this module's one
|
|
226
|
+
* `check:module-boundary` key: `reload` needed the deployment's module registry,
|
|
227
|
+
* and the registry is a root-supplied name any module may read off its cradle.
|
|
228
|
+
*
|
|
229
|
+
* The bodies are `await import()`ed for the reason the participant above gives:
|
|
230
|
+
* this file is imported by the generated manifest index, and through it by every
|
|
231
|
+
* static check script and by `src/db/configured-migrations.ts`.
|
|
232
|
+
*/
|
|
233
|
+
export const cliCommands = [
|
|
234
|
+
{
|
|
235
|
+
name: 'reload',
|
|
236
|
+
summary: "Re-read every module's on-disk i18n bundles into translation_bundles.",
|
|
237
|
+
run: async (context) => (await import('./backend/cli/reload.js')).reload(context),
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
name: 'coverage',
|
|
241
|
+
summary: 'Print the per-module, per-language translation coverage snapshot.',
|
|
242
|
+
run: async (context) => (await import('./backend/cli/coverage.js')).coverage(context),
|
|
243
|
+
},
|
|
244
|
+
];
|
|
245
|
+
//# sourceMappingURL=manifest.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"manifest.js","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations';
|
|
2
|
+
/**
|
|
3
|
+
* Admin UI language versions — feature 019 / data-model.md §1.
|
|
4
|
+
*
|
|
5
|
+
* Schema changes (one atomic migration):
|
|
6
|
+
* - new sequence `translation_bundles_version_seq` — monotonically-
|
|
7
|
+
* increasing version vector consumed by the resolver's cache
|
|
8
|
+
* invalidation path (research §R9).
|
|
9
|
+
* - new table `translation_bundles` — one row per
|
|
10
|
+
* `(module_id, language_code)`. JSONB `entries` carries the flat
|
|
11
|
+
* translation key → string map; `version` defaults to
|
|
12
|
+
* `nextval(translation_bundles_version_seq)` so every UPSERT bumps
|
|
13
|
+
* the vector without explicit application logic.
|
|
14
|
+
* - btree index `idx_translation_bundles_language_code` to support
|
|
15
|
+
* `GET /api/v1/admin/i18n/bundles?language=…` which scans by language.
|
|
16
|
+
* - column `admin_users.preferred_language` (nullable) — NULL means
|
|
17
|
+
* "no preference saved", which the resolver treats as `'en'`
|
|
18
|
+
* (feature 019 spec FR-003 / FR-016).
|
|
19
|
+
*
|
|
20
|
+
* No FK on `translation_bundles.module_id`: modules are filesystem-driven
|
|
21
|
+
* (feature 018) and `module_registrations` is the registry of record;
|
|
22
|
+
* adding a FK here would couple lifecycle ordering in ways the existing
|
|
23
|
+
* modules do not have. Cleanup is enforced by the lifecycle hard-uninstall
|
|
24
|
+
* hook, not by ON DELETE CASCADE.
|
|
25
|
+
*/
|
|
26
|
+
export declare class Migration20260507T091405I18nAdminI18nInit extends Migration {
|
|
27
|
+
up(): Promise<void>;
|
|
28
|
+
down(): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=20260507T091405_i18n_admin_i18n_init.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"20260507T091405_i18n_admin_i18n_init.d.ts","sourceRoot":"","sources":["../../src/migrations/20260507T091405_i18n_admin_i18n_init.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAElD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,qBAAa,yCAA0C,SAAQ,SAAS;IACvD,EAAE,IAAI,OAAO,CAAC,IAAI,CAAC;IA4BnB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAKrC"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations';
|
|
2
|
+
/**
|
|
3
|
+
* Admin UI language versions — feature 019 / data-model.md §1.
|
|
4
|
+
*
|
|
5
|
+
* Schema changes (one atomic migration):
|
|
6
|
+
* - new sequence `translation_bundles_version_seq` — monotonically-
|
|
7
|
+
* increasing version vector consumed by the resolver's cache
|
|
8
|
+
* invalidation path (research §R9).
|
|
9
|
+
* - new table `translation_bundles` — one row per
|
|
10
|
+
* `(module_id, language_code)`. JSONB `entries` carries the flat
|
|
11
|
+
* translation key → string map; `version` defaults to
|
|
12
|
+
* `nextval(translation_bundles_version_seq)` so every UPSERT bumps
|
|
13
|
+
* the vector without explicit application logic.
|
|
14
|
+
* - btree index `idx_translation_bundles_language_code` to support
|
|
15
|
+
* `GET /api/v1/admin/i18n/bundles?language=…` which scans by language.
|
|
16
|
+
* - column `admin_users.preferred_language` (nullable) — NULL means
|
|
17
|
+
* "no preference saved", which the resolver treats as `'en'`
|
|
18
|
+
* (feature 019 spec FR-003 / FR-016).
|
|
19
|
+
*
|
|
20
|
+
* No FK on `translation_bundles.module_id`: modules are filesystem-driven
|
|
21
|
+
* (feature 018) and `module_registrations` is the registry of record;
|
|
22
|
+
* adding a FK here would couple lifecycle ordering in ways the existing
|
|
23
|
+
* modules do not have. Cleanup is enforced by the lifecycle hard-uninstall
|
|
24
|
+
* hook, not by ON DELETE CASCADE.
|
|
25
|
+
*/
|
|
26
|
+
export class Migration20260507T091405I18nAdminI18nInit extends Migration {
|
|
27
|
+
async up() {
|
|
28
|
+
this.addSql(`create sequence "translation_bundles_version_seq" as bigint;`);
|
|
29
|
+
this.addSql(`
|
|
30
|
+
create table "translation_bundles" (
|
|
31
|
+
"module_id" varchar(64) not null,
|
|
32
|
+
"language_code" varchar(12) not null,
|
|
33
|
+
"entries" jsonb not null default '{}'::jsonb,
|
|
34
|
+
"version" bigint not null default nextval('translation_bundles_version_seq'),
|
|
35
|
+
"installed_at" timestamptz not null default now(),
|
|
36
|
+
"updated_at" timestamptz not null default now(),
|
|
37
|
+
constraint "translation_bundles_pkey" primary key ("module_id", "language_code")
|
|
38
|
+
);
|
|
39
|
+
`);
|
|
40
|
+
this.addSql(`alter sequence "translation_bundles_version_seq" owned by "translation_bundles"."version";`);
|
|
41
|
+
this.addSql(`create index "idx_translation_bundles_language_code" on "translation_bundles" ("language_code");`);
|
|
42
|
+
this.addSql(`alter table "admin_users" add column "preferred_language" varchar(12) null;`);
|
|
43
|
+
}
|
|
44
|
+
async down() {
|
|
45
|
+
this.addSql(`alter table "admin_users" drop column if exists "preferred_language";`);
|
|
46
|
+
this.addSql(`drop table if exists "translation_bundles";`);
|
|
47
|
+
this.addSql(`drop sequence if exists "translation_bundles_version_seq";`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=20260507T091405_i18n_admin_i18n_init.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"20260507T091405_i18n_admin_i18n_init.js","sourceRoot":"","sources":["../../src/migrations/20260507T091405_i18n_admin_i18n_init.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAElD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,OAAO,yCAA0C,SAAQ,SAAS;IAC7D,KAAK,CAAC,EAAE;QACf,IAAI,CAAC,MAAM,CAAC,8DAA8D,CAAC,CAAC;QAE5E,IAAI,CAAC,MAAM,CAAC;;;;;;;;;;KAUX,CAAC,CAAC;QAEH,IAAI,CAAC,MAAM,CACT,4FAA4F,CAC7F,CAAC;QAEF,IAAI,CAAC,MAAM,CACT,kGAAkG,CACnG,CAAC;QAEF,IAAI,CAAC,MAAM,CACT,6EAA6E,CAC9E,CAAC;IACJ,CAAC;IAEQ,KAAK,CAAC,IAAI;QACjB,IAAI,CAAC,MAAM,CAAC,uEAAuE,CAAC,CAAC;QACrF,IAAI,CAAC,MAAM,CAAC,6CAA6C,CAAC,CAAC;QAC3D,IAAI,CAAC,MAAM,CAAC,4DAA4D,CAAC,CAAC;IAC5E,CAAC;CACF"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `./migrations` subpath — every migration class this module owns, as one
|
|
3
|
+
* ordered `migrations` array.
|
|
4
|
+
*
|
|
5
|
+
* The array is what the platform reads when this module is **installed**:
|
|
6
|
+
* `src/packages/package-runtime.ts` takes `exported['migrations']` and refuses
|
|
7
|
+
* the package outright when it is absent (D-168).
|
|
8
|
+
*
|
|
9
|
+
* **One class**, and it creates the `translation_bundles` table together with
|
|
10
|
+
* the `translation_bundles_version_seq` sequence the resolver's cache
|
|
11
|
+
* invalidation reads. Its position relative to every other module's is decided
|
|
12
|
+
* by the manifest `dependencies` graph (feature 081); the timestamp orders this
|
|
13
|
+
* module's own migrations and nothing else.
|
|
14
|
+
*
|
|
15
|
+
* The **named** export stays beside the array. `db/migrations-registry.generated.ts`
|
|
16
|
+
* imports the class by name from this specifier, and a migration class name is
|
|
17
|
+
* contract in a way an entity class name is not: `mikro_orm_migrations` persists
|
|
18
|
+
* it, so it is a string every already-migrated database holds.
|
|
19
|
+
*/
|
|
20
|
+
import { Migration20260507T091405I18nAdminI18nInit } from './20260507T091405_i18n_admin_i18n_init.js';
|
|
21
|
+
export declare const migrations: (typeof Migration20260507T091405I18nAdminI18nInit)[];
|
|
22
|
+
export { Migration20260507T091405I18nAdminI18nInit };
|
|
23
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,yCAAyC,EAAE,MAAM,2CAA2C,CAAC;AAEtG,eAAO,MAAM,UAAU,sDAA8C,CAAC;AAEtE,OAAO,EAAE,yCAAyC,EAAE,CAAC"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `./migrations` subpath — every migration class this module owns, as one
|
|
3
|
+
* ordered `migrations` array.
|
|
4
|
+
*
|
|
5
|
+
* The array is what the platform reads when this module is **installed**:
|
|
6
|
+
* `src/packages/package-runtime.ts` takes `exported['migrations']` and refuses
|
|
7
|
+
* the package outright when it is absent (D-168).
|
|
8
|
+
*
|
|
9
|
+
* **One class**, and it creates the `translation_bundles` table together with
|
|
10
|
+
* the `translation_bundles_version_seq` sequence the resolver's cache
|
|
11
|
+
* invalidation reads. Its position relative to every other module's is decided
|
|
12
|
+
* by the manifest `dependencies` graph (feature 081); the timestamp orders this
|
|
13
|
+
* module's own migrations and nothing else.
|
|
14
|
+
*
|
|
15
|
+
* The **named** export stays beside the array. `db/migrations-registry.generated.ts`
|
|
16
|
+
* imports the class by name from this specifier, and a migration class name is
|
|
17
|
+
* contract in a way an entity class name is not: `mikro_orm_migrations` persists
|
|
18
|
+
* it, so it is a string every already-migrated database holds.
|
|
19
|
+
*/
|
|
20
|
+
import { Migration20260507T091405I18nAdminI18nInit } from './20260507T091405_i18n_admin_i18n_init.js';
|
|
21
|
+
export const migrations = [Migration20260507T091405I18nAdminI18nInit];
|
|
22
|
+
export { Migration20260507T091405I18nAdminI18nInit };
|
|
23
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,yCAAyC,EAAE,MAAM,2CAA2C,CAAC;AAEtG,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,yCAAyC,CAAC,CAAC;AAEtE,OAAO,EAAE,yCAAyC,EAAE,CAAC"}
|
package/docs/i18n.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Admin UI Languages
|
|
3
|
+
description: Admin UI per-user language preference + module-scoped translation bundles
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Admin UI Languages
|
|
7
|
+
|
|
8
|
+
Per-user language preference for the Admin UI plus a module-scoped translation pipeline that
|
|
9
|
+
lets every backend module ship its own bundle of translated strings. Polish and English are
|
|
10
|
+
shipped at launch; English is the platform-wide fallback.
|
|
11
|
+
|
|
12
|
+
The subsystem itself is the workspace package `@endora-commerce/mod-i18n`, at
|
|
13
|
+
`packages/modules/_i18n/` (platform-internal — leading underscore, which the npm name drops).
|
|
14
|
+
Its translation bundles sit at the package root, `packages/modules/_i18n/i18n/`, because the
|
|
15
|
+
platform anchors a module's `bundlesDir` to the module's own directory and a package's own
|
|
16
|
+
directory is where its `package.json` is. The admin SPA's runtime lives at `admin/src/i18n/`.
|
|
17
|
+
|
|
18
|
+
## How a user changes their language
|
|
19
|
+
|
|
20
|
+
1. Sign in to the Admin UI.
|
|
21
|
+
2. Open the **Profile** page (top-right avatar, or the `EN` / `PL` badge in the topbar).
|
|
22
|
+
3. Pick **English** or **Polski** in the **Language** section, then click **Save language**.
|
|
23
|
+
4. The whole Admin UI re-renders in the chosen language. No sign-out is required.
|
|
24
|
+
|
|
25
|
+
The choice is persisted on the user's record and follows the user across devices: signing in
|
|
26
|
+
from a different browser or machine yields the same Admin UI language as the most recent
|
|
27
|
+
saved choice.
|
|
28
|
+
|
|
29
|
+
New users (and users who have never made a choice) see English by default.
|
|
30
|
+
|
|
31
|
+
## Public surface
|
|
32
|
+
|
|
33
|
+
| Verb + Path | Purpose |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| `GET /api/v1/admin/i18n/bundles?language=<en\|pl>` | Returns the merged `core` + per-module bundle for the requested language plus a monotonically-non-decreasing `version` vector the SPA uses to detect refreshes. Permission: any authenticated admin. |
|
|
36
|
+
| `PATCH /api/v1/admin/me/preferred-language` | Sets the calling user's preferred language. Body: `{ "preferredLanguage": "en" \| "pl" \| null }`. Idempotent; `null` reverts to "no preference saved" → English fallback. Permission: any authenticated admin. |
|
|
37
|
+
|
|
38
|
+
The session-bootstrap response (`GET /api/v1/admin/me`) carries `preferredLanguage` in the
|
|
39
|
+
`adminUser` object so the SPA can seed its `<TranslationProvider>` without an extra round-trip.
|
|
40
|
+
|
|
41
|
+
## How a module ships translations
|
|
42
|
+
|
|
43
|
+
1. Declare the bundle directory in your manifest:
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
// packages/modules/<my_module>/src/manifest.ts
|
|
47
|
+
import { defineModuleManifest } from '@endora-commerce/contracts';
|
|
48
|
+
|
|
49
|
+
export const manifest = defineModuleManifest({
|
|
50
|
+
id: 'my_module',
|
|
51
|
+
name: 'My module',
|
|
52
|
+
version: '1.0.0',
|
|
53
|
+
dependencies: [],
|
|
54
|
+
i18n: { bundlesDir: 'i18n' }, // ← add this
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
2. Author per-language JSON files at `packages/modules/<my_module>/i18n/<lang>.json` —
|
|
59
|
+
the package root, not under `src/`, because `bundlesDir` is resolved against the
|
|
60
|
+
directory holding the module's `package.json`.
|
|
61
|
+
The shape is a flat `Record<string, string>` — keys are dotted (`"actions.save"`),
|
|
62
|
+
values are strings. `{name}`-style placeholders are interpolated at runtime.
|
|
63
|
+
|
|
64
|
+
```jsonc
|
|
65
|
+
// packages/modules/my_module/i18n/en.json
|
|
66
|
+
{
|
|
67
|
+
"actions.save": "Save",
|
|
68
|
+
"validation.required": "This field is required.",
|
|
69
|
+
"audit.userCreated": "Created user \"{email}\"."
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
3. Replace inline strings in the admin code with `useTranslation(scope)` calls:
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
import { useTranslation } from '@/i18n/useTranslation';
|
|
77
|
+
|
|
78
|
+
export function MyForm() {
|
|
79
|
+
const t = useTranslation('my_module');
|
|
80
|
+
return <button>{t('actions.save')}</button>;
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
4. Restart (or re-install) the backend. The boot-time bundle reconciler walks every module
|
|
85
|
+
declaring `manifest.i18n` and refreshes its `translation_bundles` rows from the JSON files
|
|
86
|
+
on disk. Failures are logged but do not abort boot.
|
|
87
|
+
|
|
88
|
+
### Rules and constraints
|
|
89
|
+
|
|
90
|
+
- The supported set is currently `['en', 'pl']` (closed enum in `@endora-commerce/contracts/src/admin-i18n.ts`).
|
|
91
|
+
- Files for unsupported languages are rejected at install time.
|
|
92
|
+
- A module that ships any bundle MUST ship `en.json` (English is the platform-wide fallback).
|
|
93
|
+
A Polish bundle is encouraged but optional — ship it in the same PR as the English file.
|
|
94
|
+
- Two modules MAY use the same key string (for example `actions.save`); each bundle is scoped
|
|
95
|
+
to its owning module so the strings do not collide.
|
|
96
|
+
- User-authored content (CMS pages, product names, blog posts, setting *values*) is **out of
|
|
97
|
+
scope** for this feature — it is rendered as authored, not re-translated.
|
|
98
|
+
|
|
99
|
+
## How the resolver picks a string
|
|
100
|
+
|
|
101
|
+
Every lookup goes through a three-step fallback chain:
|
|
102
|
+
|
|
103
|
+
1. The user's preferred-language entry under the module's scope.
|
|
104
|
+
2. The English entry under the module's scope.
|
|
105
|
+
3. A literal placeholder `${scope}.${key}` (always non-empty so the UI never goes blank).
|
|
106
|
+
|
|
107
|
+
Both the backend resolver (`I18nService.translate(...)`) and the admin SPA resolver
|
|
108
|
+
(`admin/src/i18n/resolver.ts`) share the same chain.
|
|
109
|
+
|
|
110
|
+
## Diagnosing missing translations
|
|
111
|
+
|
|
112
|
+
When the resolver falls back, the backend writes a structured log line through the platform's
|
|
113
|
+
existing logger:
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{ "event": "i18n.fallback", "moduleId": "settings", "languageCode": "pl", "key": "actions.save", "fellBackTo": "en" }
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Operators can answer "what's missing in our Polish bundle?" with a single grep:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
grep '"event":"i18n.fallback"' /var/log/b2b-backend.log | jq -r '"\(.languageCode) \(.moduleId).\(.key) → \(.fellBackTo)"' | sort -u
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The admin SPA emits the same gap as a `console.warn` in development (`import.meta.env.DEV`)
|
|
126
|
+
and is silent in production.
|
|
127
|
+
|
|
128
|
+
## Database
|
|
129
|
+
|
|
130
|
+
One MikroORM migration (`040_admin_i18n_init.ts`) introduces:
|
|
131
|
+
|
|
132
|
+
- Sequence `translation_bundles_version_seq` (the cache-invalidation vector).
|
|
133
|
+
- Table `translation_bundles` — one row per `(module_id, language_code)`, JSONB `entries`,
|
|
134
|
+
`version` defaults to `nextval(translation_bundles_version_seq)` so every UPSERT advances
|
|
135
|
+
the sequence.
|
|
136
|
+
- Column `admin_users.preferred_language` (varchar(12), nullable). NULL means "no preference
|
|
137
|
+
saved" → resolver treats as English.
|
|
138
|
+
|
|
139
|
+
No FK on `translation_bundles.module_id` — modules are filesystem-driven and the
|
|
140
|
+
hard-uninstall hook is the cleanup mechanism, not `ON DELETE CASCADE`.
|
|
141
|
+
|
|
142
|
+
## Adding a third language
|
|
143
|
+
|
|
144
|
+
Adding `de` (or any other BCP-47 code) is treated as a separate feature because the cost is
|
|
145
|
+
mostly translation work, not engineering. The schema change is one line in
|
|
146
|
+
`@endora-commerce/contracts/src/admin-i18n.ts`:
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
export const SupportedAdminLanguageSchema = z.enum(['en', 'pl', 'de']);
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
After that, each module that emits admin-visible strings must ship `de.json` (or accept the
|
|
153
|
+
EN fallback). The Profile page's selector picks up the new code automatically.
|
|
154
|
+
|
|
155
|
+
## Testing
|
|
156
|
+
|
|
157
|
+
Unit tests live next to the implementation:
|
|
158
|
+
|
|
159
|
+
- `backend/test/unit/_i18n/bundle-loader.unit.test.ts` — every `BundleLoadError` reason path
|
|
160
|
+
(parse-failed, invalid-shape, unsupported-language-file, missing-fallback-bundle) plus the
|
|
161
|
+
happy path and EN-only acceptance.
|
|
162
|
+
- `backend/test/unit/_i18n/missing-key-logger.unit.test.ts` — structured log line shape.
|
|
163
|
+
- `backend/test/unit/_i18n/i18n-service.unit.test.ts` — the three-step fallback chain plus
|
|
164
|
+
interpolation.
|
|
165
|
+
- `admin/test/i18n/resolver.test.ts` — the same chain on the SPA side.
|
|
166
|
+
- `admin/test/i18n/interpolate.test.ts` — placeholder regex.
|