@orthacms/i18n-server 0.0.0-reserve.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.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -0
  3. package/dist/index.d.ts +13 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +15 -0
  6. package/dist/lib/content/controllers/get-entry-locales.controller.d.ts +15 -0
  7. package/dist/lib/content/controllers/get-entry-locales.controller.d.ts.map +1 -0
  8. package/dist/lib/content/controllers/get-entry-locales.controller.js +45 -0
  9. package/dist/lib/content/controllers/locale-summary.controller.d.ts +30 -0
  10. package/dist/lib/content/controllers/locale-summary.controller.d.ts.map +1 -0
  11. package/dist/lib/content/controllers/locale-summary.controller.js +61 -0
  12. package/dist/lib/content/controllers/resolve-type.d.ts +9 -0
  13. package/dist/lib/content/controllers/resolve-type.d.ts.map +1 -0
  14. package/dist/lib/content/controllers/resolve-type.js +20 -0
  15. package/dist/lib/content/dto/locale-summary.dto.d.ts +10 -0
  16. package/dist/lib/content/dto/locale-summary.dto.d.ts.map +1 -0
  17. package/dist/lib/content/dto/locale-summary.dto.js +30 -0
  18. package/dist/lib/content/locale-group-lock.d.ts +34 -0
  19. package/dist/lib/content/locale-group-lock.d.ts.map +1 -0
  20. package/dist/lib/content/locale-group-lock.js +38 -0
  21. package/dist/lib/content/services/entry-locale-extension.service.d.ts +188 -0
  22. package/dist/lib/content/services/entry-locale-extension.service.d.ts.map +1 -0
  23. package/dist/lib/content/services/entry-locale-extension.service.js +662 -0
  24. package/dist/lib/content/services/locale-group.service.d.ts +87 -0
  25. package/dist/lib/content/services/locale-group.service.d.ts.map +1 -0
  26. package/dist/lib/content/services/locale-group.service.js +135 -0
  27. package/dist/lib/copilot/i18n-tool.provider.d.ts +53 -0
  28. package/dist/lib/copilot/i18n-tool.provider.d.ts.map +1 -0
  29. package/dist/lib/copilot/i18n-tool.provider.js +157 -0
  30. package/dist/lib/copilot/translation-proposal.applier.d.ts +87 -0
  31. package/dist/lib/copilot/translation-proposal.applier.d.ts.map +1 -0
  32. package/dist/lib/copilot/translation-proposal.applier.js +242 -0
  33. package/dist/lib/copilot/translation-proposal.provider.d.ts +114 -0
  34. package/dist/lib/copilot/translation-proposal.provider.d.ts.map +1 -0
  35. package/dist/lib/copilot/translation-proposal.provider.js +419 -0
  36. package/dist/lib/domain/errors/index.d.ts +4 -0
  37. package/dist/lib/domain/errors/index.d.ts.map +1 -0
  38. package/dist/lib/domain/errors/index.js +9 -0
  39. package/dist/lib/domain/errors/invalid-locale-set.error.d.ts +10 -0
  40. package/dist/lib/domain/errors/invalid-locale-set.error.d.ts.map +1 -0
  41. package/dist/lib/domain/errors/invalid-locale-set.error.js +16 -0
  42. package/dist/lib/domain/errors/invalid-locale.error.d.ts +10 -0
  43. package/dist/lib/domain/errors/invalid-locale.error.d.ts.map +1 -0
  44. package/dist/lib/domain/errors/invalid-locale.error.js +16 -0
  45. package/dist/lib/domain/errors/unknown-locale.error.d.ts +11 -0
  46. package/dist/lib/domain/errors/unknown-locale.error.d.ts.map +1 -0
  47. package/dist/lib/domain/errors/unknown-locale.error.js +18 -0
  48. package/dist/lib/domain/locale-policy.d.ts +42 -0
  49. package/dist/lib/domain/locale-policy.d.ts.map +1 -0
  50. package/dist/lib/domain/locale-policy.js +61 -0
  51. package/dist/lib/domain/value-objects/locale-set.d.ts +41 -0
  52. package/dist/lib/domain/value-objects/locale-set.d.ts.map +1 -0
  53. package/dist/lib/domain/value-objects/locale-set.js +89 -0
  54. package/dist/lib/domain/value-objects/locale.d.ts +55 -0
  55. package/dist/lib/domain/value-objects/locale.d.ts.map +1 -0
  56. package/dist/lib/domain/value-objects/locale.js +73 -0
  57. package/dist/lib/i18n.constants.d.ts +33 -0
  58. package/dist/lib/i18n.constants.d.ts.map +1 -0
  59. package/dist/lib/i18n.constants.js +89 -0
  60. package/dist/lib/i18n.module.d.ts +14 -0
  61. package/dist/lib/i18n.module.d.ts.map +1 -0
  62. package/dist/lib/i18n.module.js +82 -0
  63. package/dist/lib/insights/http/controllers/localization-coverage.controller.d.ts +23 -0
  64. package/dist/lib/insights/http/controllers/localization-coverage.controller.d.ts.map +1 -0
  65. package/dist/lib/insights/http/controllers/localization-coverage.controller.js +46 -0
  66. package/dist/lib/insights/infrastructure/queries/localization-coverage.query.d.ts +59 -0
  67. package/dist/lib/insights/infrastructure/queries/localization-coverage.query.d.ts.map +1 -0
  68. package/dist/lib/insights/infrastructure/queries/localization-coverage.query.js +179 -0
  69. package/dist/lib/insights/types/i18n-insights-view.d.ts +83 -0
  70. package/dist/lib/insights/types/i18n-insights-view.d.ts.map +1 -0
  71. package/dist/lib/insights/types/i18n-insights-view.js +10 -0
  72. package/dist/lib/locales/controllers/list-locales.controller.d.ts +26 -0
  73. package/dist/lib/locales/controllers/list-locales.controller.d.ts.map +1 -0
  74. package/dist/lib/locales/controllers/list-locales.controller.js +48 -0
  75. package/dist/lib/locales/services/locale-registry.service.d.ts +26 -0
  76. package/dist/lib/locales/services/locale-registry.service.d.ts.map +1 -0
  77. package/dist/lib/locales/services/locale-registry.service.js +70 -0
  78. package/dist/lib/locales/services/orphaned-locale.checker.d.ts +60 -0
  79. package/dist/lib/locales/services/orphaned-locale.checker.d.ts.map +1 -0
  80. package/dist/lib/locales/services/orphaned-locale.checker.js +114 -0
  81. package/dist/lib/types/locale.d.ts +68 -0
  82. package/dist/lib/types/locale.d.ts.map +1 -0
  83. package/dist/lib/types/locale.js +8 -0
  84. package/dist/lib/utils/i18n-plugin.d.ts +41 -0
  85. package/dist/lib/utils/i18n-plugin.d.ts.map +1 -0
  86. package/dist/lib/utils/i18n-plugin.js +53 -0
  87. package/package.json +47 -0
@@ -0,0 +1,179 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LocalizationCoverageQuery = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const common_1 = require("@nestjs/common");
6
+ const drizzle_orm_1 = require("drizzle-orm");
7
+ const database_1 = require("@orthacms/database");
8
+ const content_server_1 = require("@orthacms/content-server");
9
+ const locale_registry_service_1 = require("../../../locales/services/locale-registry.service");
10
+ /** Reads a generated table's columns by property name. */
11
+ function columnsOf(type) {
12
+ return type.table;
13
+ }
14
+ /**
15
+ * Live read-model for the localization coverage widget.
16
+ *
17
+ * **This lives in i18n-server, not content-server, and that is not arbitrary.**
18
+ * Coverage is a question about a set the content plugin deliberately does not
19
+ * know: the *configured* locales. content-server owns the `locale` column's
20
+ * shape and nothing about what a locale means, so it can report which slugs
21
+ * happen to appear in the data but not which ones are missing — and "missing"
22
+ * is the entire widget. `LocaleRegistryService` is the source of that set, and
23
+ * it is here.
24
+ *
25
+ * Like content's own insights query this aggregates **on demand**, per type,
26
+ * with no projection: a localized collection is its own generated
27
+ * `content_<name>` table, so a workspace-wide answer is inherently a fan-out.
28
+ */
29
+ let LocalizationCoverageQuery = class LocalizationCoverageQuery {
30
+ db;
31
+ registry;
32
+ locales;
33
+ constructor(db, registry, locales) {
34
+ this.db = db;
35
+ this.registry = registry;
36
+ this.locales = locales;
37
+ }
38
+ /** Coverage across every localized content type in the workspace. */
39
+ async coverage(workspaceId) {
40
+ const configured = this.locales.all();
41
+ const slugs = configured.map((locale) => locale.slug);
42
+ const localizedTypes = this.registry.all().filter((type) => type.i18n);
43
+ /** Records holding a row in each locale, by slug. */
44
+ const translated = new Map(slugs.map((slug) => [slug, 0]));
45
+ const types = [];
46
+ let records = 0;
47
+ let localized = 0;
48
+ let single = 0;
49
+ for (const type of localizedTypes) {
50
+ for (const row of await this.perLocale(type, workspaceId, slugs)) {
51
+ translated.set(row.locale, (translated.get(row.locale) ?? 0) + row.translated);
52
+ }
53
+ const spread = await this.groupSpread(type, workspaceId, slugs, configured.length);
54
+ records += spread.records;
55
+ localized += spread.complete;
56
+ single += spread.single;
57
+ // A type the workspace has never used is noise on a chart about
58
+ // where the outstanding work sits — and listing it would push the
59
+ // types that do hold work further down.
60
+ if (spread.records === 0)
61
+ continue;
62
+ types.push({
63
+ name: type.name,
64
+ label: type.label,
65
+ records: spread.records,
66
+ localized: spread.complete,
67
+ notLocalized: configured.length > 1 ? spread.single : 0,
68
+ requiresLocalization: spread.records - spread.complete
69
+ });
70
+ }
71
+ types.sort((a, b) => b.records - a.records);
72
+ const locales = configured.map((locale) => {
73
+ const covered = translated.get(locale.slug) ?? 0;
74
+ return {
75
+ locale: locale.slug,
76
+ name: locale.name,
77
+ isDefault: locale.isDefault ?? false,
78
+ translated: covered,
79
+ missing: records - covered
80
+ };
81
+ });
82
+ return {
83
+ locales,
84
+ records,
85
+ localized,
86
+ // With one locale configured there is nowhere to translate to, so
87
+ // every record trivially covers "all" of them — reporting those
88
+ // same records as "not localized" would state both at once.
89
+ notLocalized: configured.length > 1 ? single : 0,
90
+ requiresLocalization: records - localized,
91
+ types
92
+ };
93
+ }
94
+ /**
95
+ * Records holding a row in each locale, for one type.
96
+ *
97
+ * `count(distinct locale_group_id)` rather than `count(*)`: the
98
+ * `(locale_group_id, locale)` unique index makes those equal in a healthy
99
+ * table, and the distinct keeps the figure honest against a paranoid type
100
+ * where the index is partial and a trashed row could otherwise be counted
101
+ * twice.
102
+ */
103
+ async perLocale(type, workspaceId, slugs) {
104
+ const columns = columnsOf(type);
105
+ // The generated tables are reached as an untyped column bag, so both
106
+ // the projection and the grouping go through `sql` fragments — a bare
107
+ // `AnyColumn` carries no dialect and doesn't satisfy the builder's
108
+ // overloads.
109
+ const locale = (0, drizzle_orm_1.sql) `${columns['locale']}`;
110
+ return this.db
111
+ .select({
112
+ locale,
113
+ translated: (0, drizzle_orm_1.sql) `cast(count(distinct ${columns['localeGroupId']}) as int)`
114
+ })
115
+ .from(type.table)
116
+ .where(this.configuredScope(type, workspaceId, slugs))
117
+ .groupBy(locale);
118
+ }
119
+ /**
120
+ * How many translation groups this type has, how many are complete, and how
121
+ * many sit in a single language.
122
+ *
123
+ * One grouped subquery folded by an outer aggregate — the alternative,
124
+ * reading one row per group and counting in JS, would pull the workspace's
125
+ * whole content set over the wire to produce three integers.
126
+ */
127
+ async groupSpread(type, workspaceId, slugs, configuredCount) {
128
+ const columns = columnsOf(type);
129
+ const groups = this.db
130
+ .select({
131
+ covered: (0, drizzle_orm_1.sql) `count(distinct ${columns['locale']})`.as('covered')
132
+ })
133
+ .from(type.table)
134
+ .where(this.configuredScope(type, workspaceId, slugs))
135
+ .groupBy((0, drizzle_orm_1.sql) `${columns['localeGroupId']}`)
136
+ .as('groups');
137
+ // The subquery alias carries no column type through, so both filters
138
+ // are raw fragments rather than `eq(...)`, whose overloads can't
139
+ // resolve one.
140
+ const [row] = await this.db
141
+ .select({
142
+ records: (0, drizzle_orm_1.sql) `cast(count(*) as int)`,
143
+ complete: (0, drizzle_orm_1.sql) `cast(count(*) filter (
144
+ where ${groups.covered} = ${configuredCount}
145
+ ) as int)`,
146
+ single: (0, drizzle_orm_1.sql) `cast(count(*) filter (
147
+ where ${groups.covered} = 1
148
+ ) as int)`
149
+ })
150
+ .from(groups);
151
+ return {
152
+ records: row?.records ?? 0,
153
+ complete: row?.complete ?? 0,
154
+ single: row?.single ?? 0
155
+ };
156
+ }
157
+ /**
158
+ * Workspace scope, the soft-delete guard, **and** the configured locale set.
159
+ *
160
+ * The locale filter is what makes "complete" mean what it says. A row in a
161
+ * slug the host has since removed from its config is not coverage of
162
+ * anything, and left in it would push a group's distinct-locale count past
163
+ * the configured total — so a record could be over-covered and still not
164
+ * counted as complete. Filtering here also drops a group made entirely of
165
+ * such rows, which is the right answer rather than a record with zero
166
+ * languages.
167
+ */
168
+ configuredScope(type, workspaceId, slugs) {
169
+ const columns = columnsOf(type);
170
+ return (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(columns['workspaceId'], workspaceId), (0, drizzle_orm_1.inArray)(columns['locale'], slugs), type.paranoid ? (0, drizzle_orm_1.isNull)(columns['deletedAt']) : undefined);
171
+ }
172
+ };
173
+ exports.LocalizationCoverageQuery = LocalizationCoverageQuery;
174
+ exports.LocalizationCoverageQuery = LocalizationCoverageQuery = tslib_1.__decorate([
175
+ (0, common_1.Injectable)(),
176
+ tslib_1.__param(0, (0, database_1.InjectDatabase)()),
177
+ tslib_1.__param(1, (0, content_server_1.InjectContentRegistry)()),
178
+ tslib_1.__metadata("design:paramtypes", [Object, Function, locale_registry_service_1.LocaleRegistryService])
179
+ ], LocalizationCoverageQuery);
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Read-side view shapes for the localization Insights widget.
3
+ *
4
+ * A **transport contract**, restated verbatim by `i18n-admin` (the admin can't
5
+ * import a server package across the module boundary). Derived live from the
6
+ * localized collection tables — this plugin owns no table and keeps no
7
+ * projection.
8
+ */
9
+ /** How much of the workspace's localized content exists in one locale. */
10
+ export interface LocaleCoverageView {
11
+ /** The configured locale slug. */
12
+ locale: string;
13
+ /** Its display name, from the host's config. */
14
+ name: string;
15
+ /** Whether it is the configured default locale. */
16
+ isDefault: boolean;
17
+ /** Records that have a row in this locale. */
18
+ translated: number;
19
+ /** Records that do not. */
20
+ missing: number;
21
+ }
22
+ /**
23
+ * One localized content type's own coverage.
24
+ *
25
+ * The same four figures as the workspace envelope, scoped to a type — which is
26
+ * what turns "122 records need translating" into "and 88 of them are articles".
27
+ * A workspace-wide total says work exists; only the per-type split says where.
28
+ */
29
+ export interface ContentTypeCoverageView {
30
+ /** The type's machine name. */
31
+ name: string;
32
+ /** Its human label. */
33
+ label: string;
34
+ /** Translation groups of this type. */
35
+ records: number;
36
+ /** Of those, the ones present in every configured locale. */
37
+ localized: number;
38
+ /** Of those, the ones present in exactly one locale. */
39
+ notLocalized: number;
40
+ /** Of those, the ones missing at least one locale. */
41
+ requiresLocalization: number;
42
+ }
43
+ /**
44
+ * Localization coverage across every `i18n: true` content type.
45
+ *
46
+ * The unit is a **record**, not a row: a localized entry is one row per
47
+ * language sharing a `locale_group_id`, so counting rows would report a
48
+ * workspace of 40 stories in 3 languages as 120 things and make every
49
+ * percentage meaningless. Every figure below counts translation groups.
50
+ *
51
+ * `notLocalized` is a **subset** of `requiresLocalization`, not a second slice
52
+ * of it — a record in one language of four both "has no translations" and
53
+ * "needs some". They are separate because they are separate jobs: one is
54
+ * starting a translation, the other is finishing it.
55
+ */
56
+ export interface I18nCoverageView {
57
+ /** One entry per configured locale, in config order. */
58
+ locales: LocaleCoverageView[];
59
+ /** Translation groups across every localized type. */
60
+ records: number;
61
+ /** Records that exist in **every** configured locale. */
62
+ localized: number;
63
+ /**
64
+ * Records that exist in exactly one locale — nothing translated yet.
65
+ *
66
+ * Always `0` when the deployment configures a single locale: there is
67
+ * nowhere to translate to, so every record would otherwise be reported as
68
+ * both fully localized and not localized at all.
69
+ */
70
+ notLocalized: number;
71
+ /** Records missing at least one configured locale (`records - localized`). */
72
+ requiresLocalization: number;
73
+ /**
74
+ * Per-type coverage, most records first.
75
+ *
76
+ * A type the workspace has never used is **omitted** rather than listed at
77
+ * zero — the same rule the content pipeline applies. A permanently empty row
78
+ * is noise on a chart about where the outstanding work sits, and it would
79
+ * push the types that do hold work further down the list.
80
+ */
81
+ types: ContentTypeCoverageView[];
82
+ }
83
+ //# sourceMappingURL=i18n-insights-view.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"i18n-insights-view.d.ts","sourceRoot":"","sources":["../../../../src/lib/insights/types/i18n-insights-view.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,0EAA0E;AAC1E,MAAM,WAAW,kBAAkB;IAC/B,kCAAkC;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,gDAAgD;IAChD,IAAI,EAAE,MAAM,CAAC;IACb,mDAAmD;IACnD,SAAS,EAAE,OAAO,CAAC;IACnB,8CAA8C;IAC9C,UAAU,EAAE,MAAM,CAAC;IACnB,2BAA2B;IAC3B,OAAO,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,uBAAuB;IACpC,+BAA+B;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,uBAAuB;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,uCAAuC;IACvC,OAAO,EAAE,MAAM,CAAC;IAChB,6DAA6D;IAC7D,SAAS,EAAE,MAAM,CAAC;IAClB,wDAAwD;IACxD,YAAY,EAAE,MAAM,CAAC;IACrB,sDAAsD;IACtD,oBAAoB,EAAE,MAAM,CAAC;CAChC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,gBAAgB;IAC7B,wDAAwD;IACxD,OAAO,EAAE,kBAAkB,EAAE,CAAC;IAC9B,sDAAsD;IACtD,OAAO,EAAE,MAAM,CAAC;IAChB,yDAAyD;IACzD,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;OAMG;IACH,YAAY,EAAE,MAAM,CAAC;IACrB,8EAA8E;IAC9E,oBAAoB,EAAE,MAAM,CAAC;IAC7B;;;;;;;OAOG;IACH,KAAK,EAAE,uBAAuB,EAAE,CAAC;CACpC"}
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ /**
3
+ * Read-side view shapes for the localization Insights widget.
4
+ *
5
+ * A **transport contract**, restated verbatim by `i18n-admin` (the admin can't
6
+ * import a server package across the module boundary). Derived live from the
7
+ * localized collection tables — this plugin owns no table and keeps no
8
+ * projection.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,26 @@
1
+ import type { LocaleDef } from '../../types/locale';
2
+ import { LocaleRegistryService } from '../services/locale-registry.service';
3
+ /** The `GET /api/i18n/locales` response envelope. */
4
+ export interface LocalesView {
5
+ /** Configured locales in display order; exactly one `isDefault`. */
6
+ items: LocaleDef[];
7
+ }
8
+ /**
9
+ * `GET /api/i18n/locales` — the configured locales, in display order. The
10
+ * admin's single source for the locale switcher / widget; any authenticated
11
+ * session may read it (the global `AuthGuard` applies; locales carry no
12
+ * per-workspace data).
13
+ *
14
+ * Each item carries everything a consumer needs to render the language
15
+ * *correctly*, not just to name it: `slug` doubles as the HTML `lang` value
16
+ * (it is a BCP-47 tag by contract) and `dir` is the resolved text direction.
17
+ * Together they are what lets the entry editor, the preview, and a published
18
+ * page mark up a translation as the language it is — which is the whole of
19
+ * WCAG 3.1.2 and, for an RTL locale, of 1.3.2.
20
+ */
21
+ export declare class ListLocalesController {
22
+ private readonly locales;
23
+ constructor(locales: LocaleRegistryService);
24
+ list(): LocalesView;
25
+ }
26
+ //# sourceMappingURL=list-locales.controller.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"list-locales.controller.d.ts","sourceRoot":"","sources":["../../../../src/lib/locales/controllers/list-locales.controller.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AACpD,OAAO,EAAE,qBAAqB,EAAE,MAAM,qCAAqC,CAAC;AAE5E,qDAAqD;AACrD,MAAM,WAAW,WAAW;IACxB,oEAAoE;IACpE,KAAK,EAAE,SAAS,EAAE,CAAC;CACtB;AAED;;;;;;;;;;;;GAYG;AACH,qBACa,qBAAqB;IAClB,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,qBAAqB;IAG3D,IAAI,IAAI,WAAW;CAYtB"}
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ListLocalesController = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const common_1 = require("@nestjs/common");
6
+ const locale_registry_service_1 = require("../services/locale-registry.service");
7
+ /**
8
+ * `GET /api/i18n/locales` — the configured locales, in display order. The
9
+ * admin's single source for the locale switcher / widget; any authenticated
10
+ * session may read it (the global `AuthGuard` applies; locales carry no
11
+ * per-workspace data).
12
+ *
13
+ * Each item carries everything a consumer needs to render the language
14
+ * *correctly*, not just to name it: `slug` doubles as the HTML `lang` value
15
+ * (it is a BCP-47 tag by contract) and `dir` is the resolved text direction.
16
+ * Together they are what lets the entry editor, the preview, and a published
17
+ * page mark up a translation as the language it is — which is the whole of
18
+ * WCAG 3.1.2 and, for an RTL locale, of 1.3.2.
19
+ */
20
+ let ListLocalesController = class ListLocalesController {
21
+ locales;
22
+ constructor(locales) {
23
+ this.locales = locales;
24
+ }
25
+ list() {
26
+ return {
27
+ items: this.locales.all().map(({ slug, name, isDefault, dir }) => ({
28
+ slug,
29
+ name,
30
+ isDefault: isDefault ?? false,
31
+ // The registry always resolves a direction; the fallback is
32
+ // for type flow only.
33
+ dir: dir ?? 'ltr'
34
+ }))
35
+ };
36
+ }
37
+ };
38
+ exports.ListLocalesController = ListLocalesController;
39
+ tslib_1.__decorate([
40
+ (0, common_1.Get)('locales'),
41
+ tslib_1.__metadata("design:type", Function),
42
+ tslib_1.__metadata("design:paramtypes", []),
43
+ tslib_1.__metadata("design:returntype", Object)
44
+ ], ListLocalesController.prototype, "list", null);
45
+ exports.ListLocalesController = ListLocalesController = tslib_1.__decorate([
46
+ (0, common_1.Controller)('i18n'),
47
+ tslib_1.__metadata("design:paramtypes", [locale_registry_service_1.LocaleRegistryService])
48
+ ], ListLocalesController);
@@ -0,0 +1,26 @@
1
+ import type { I18nPluginConfig, LocaleDef } from '../../types/locale';
2
+ /**
3
+ * Read access to the validated locale configuration — the runtime authority
4
+ * every other i18n service consults. It wraps the domain {@link LocaleSet} /
5
+ * {@link LocalePolicy} (rebuilt from the already-validated config), so every
6
+ * service resolves locales through the same domain rule; it exposes the plain
7
+ * {@link LocaleDef} shape its callers expect and maps the domain's
8
+ * {@link UnknownLocaleError} to HTTP 400.
9
+ */
10
+ export declare class LocaleRegistryService {
11
+ private readonly locales;
12
+ private readonly policy;
13
+ constructor(config: I18nPluginConfig);
14
+ /** All configured locales, in config (display) order. */
15
+ all(): LocaleDef[];
16
+ /** One locale by slug, or undefined. */
17
+ get(slug: string): LocaleDef | undefined;
18
+ /** The default locale (exactly one exists). */
19
+ default(): LocaleDef;
20
+ /**
21
+ * Resolve a request's locale slug: absent → the default; unknown → 400.
22
+ * The uniform gate every request-facing path funnels through.
23
+ */
24
+ resolve(slug: string | undefined): LocaleDef;
25
+ }
26
+ //# sourceMappingURL=locale-registry.service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"locale-registry.service.d.ts","sourceRoot":"","sources":["../../../../src/lib/locales/services/locale-registry.service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAkBtE;;;;;;;GAOG;AACH,qBACa,qBAAqB;IAC9B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAY;IACpC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAe;gBAEL,MAAM,EAAE,gBAAgB;IAKzD,yDAAyD;IACzD,GAAG,IAAI,SAAS,EAAE;IAIlB,wCAAwC;IACxC,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS;IAKxC,+CAA+C;IAC/C,OAAO,IAAI,SAAS;IAIpB;;;OAGG;IACH,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS;CAU/C"}
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LocaleRegistryService = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const common_1 = require("@nestjs/common");
6
+ const i18n_constants_1 = require("../../i18n.constants");
7
+ const locale_policy_1 = require("../../domain/locale-policy");
8
+ const errors_1 = require("../../domain/errors");
9
+ const locale_set_1 = require("../../domain/value-objects/locale-set");
10
+ /** Maps a domain {@link Locale} back to the plain wire/config shape. */
11
+ function toDef(locale) {
12
+ return {
13
+ slug: locale.slug,
14
+ name: locale.name,
15
+ isDefault: locale.isDefault,
16
+ // Always resolved by the value object (declared or inferred), so every
17
+ // caller gets a direction rather than having to decide for itself.
18
+ dir: locale.dir
19
+ };
20
+ }
21
+ /**
22
+ * Read access to the validated locale configuration — the runtime authority
23
+ * every other i18n service consults. It wraps the domain {@link LocaleSet} /
24
+ * {@link LocalePolicy} (rebuilt from the already-validated config), so every
25
+ * service resolves locales through the same domain rule; it exposes the plain
26
+ * {@link LocaleDef} shape its callers expect and maps the domain's
27
+ * {@link UnknownLocaleError} to HTTP 400.
28
+ */
29
+ let LocaleRegistryService = class LocaleRegistryService {
30
+ locales;
31
+ policy;
32
+ constructor(config) {
33
+ this.locales = locale_set_1.LocaleSet.fromDefs(config.locales);
34
+ this.policy = new locale_policy_1.LocalePolicy(this.locales);
35
+ }
36
+ /** All configured locales, in config (display) order. */
37
+ all() {
38
+ return this.locales.all().map(toDef);
39
+ }
40
+ /** One locale by slug, or undefined. */
41
+ get(slug) {
42
+ const locale = this.locales.get(slug);
43
+ return locale ? toDef(locale) : undefined;
44
+ }
45
+ /** The default locale (exactly one exists). */
46
+ default() {
47
+ return toDef(this.locales.default());
48
+ }
49
+ /**
50
+ * Resolve a request's locale slug: absent → the default; unknown → 400.
51
+ * The uniform gate every request-facing path funnels through.
52
+ */
53
+ resolve(slug) {
54
+ try {
55
+ return toDef(this.policy.resolve(slug));
56
+ }
57
+ catch (error) {
58
+ if (error instanceof errors_1.UnknownLocaleError) {
59
+ throw new common_1.BadRequestException(error.message);
60
+ }
61
+ throw error;
62
+ }
63
+ }
64
+ };
65
+ exports.LocaleRegistryService = LocaleRegistryService;
66
+ exports.LocaleRegistryService = LocaleRegistryService = tslib_1.__decorate([
67
+ (0, common_1.Injectable)(),
68
+ tslib_1.__param(0, (0, common_1.Inject)(i18n_constants_1.I18N_CONFIG)),
69
+ tslib_1.__metadata("design:paramtypes", [Object])
70
+ ], LocaleRegistryService);
@@ -0,0 +1,60 @@
1
+ import { type OnApplicationBootstrap } from '@nestjs/common';
2
+ import { type Database } from '@orthacms/database';
3
+ import { type ContentTypeRegistry } from '@orthacms/content-server';
4
+ import type { I18nPluginConfig } from '../../types/locale';
5
+ import { LocaleRegistryService } from './locale-registry.service';
6
+ /** One localized content type's rows in a slug the config no longer declares. */
7
+ export interface OrphanedLocaleReport {
8
+ /** The content type holding the rows. */
9
+ typeName: string;
10
+ /** The unconfigured locale slug. */
11
+ locale: string;
12
+ /** How many rows sit in it. */
13
+ rows: number;
14
+ }
15
+ /**
16
+ * Boot-time guard against the one locale change that fails silently: **removing
17
+ * a locale from the config while its rows still exist**.
18
+ *
19
+ * Adding a locale is safe and self-announcing — existing groups gain a missing
20
+ * slot, coverage reports `translated: 0`, the panel shows `entry: null`.
21
+ * Removing one is neither. Nothing deletes the rows and nothing migrates them,
22
+ * and from that moment every read path hides them: `?locale=de` becomes a 400
23
+ * (`resolve` no longer knows the slug), the locale panel iterates the
24
+ * configured set, the coverage query and the virtual filters restrict to it,
25
+ * and the batched summary drops them. The German content is intact, reachable
26
+ * only by `psql`, and the product never mentions it again — including to the
27
+ * person who made the change, who sees a smaller locale switcher and nothing
28
+ * else. `LocaleSet.remove` guards the *default* against exactly this class of
29
+ * mistake, but the plugin never calls it: the config is a literal array, so
30
+ * there is no removal operation to guard, only a diff nobody computes.
31
+ *
32
+ * This computes it, once, at boot: for every localized content type, the
33
+ * distinct locale slugs present that the config does not declare, with a row
34
+ * count. Under the default `'fail'` policy the boot aborts, which puts the
35
+ * decision (migrate the rows, or restore the locale) in front of whoever
36
+ * changed the config while it is still their change; `'warn'` logs the same
37
+ * report and continues, for a deployment knowingly mid-migration.
38
+ *
39
+ * The scan is one `GROUP BY locale` per localized type, on a column the
40
+ * `(locale_group_id, locale)` index already covers.
41
+ */
42
+ export declare class OrphanedLocaleChecker implements OnApplicationBootstrap {
43
+ private readonly db;
44
+ private readonly registry;
45
+ private readonly config;
46
+ private readonly locales;
47
+ private readonly logger;
48
+ constructor(db: Database, registry: ContentTypeRegistry, config: I18nPluginConfig, locales: LocaleRegistryService);
49
+ /** @inheritdoc */
50
+ onApplicationBootstrap(): Promise<void>;
51
+ /**
52
+ * Rows in an unconfigured locale, per localized content type. Public so a
53
+ * migration script or an operator endpoint can ask the same question
54
+ * without re-deriving the rule.
55
+ */
56
+ findOrphans(): Promise<OrphanedLocaleReport[]>;
57
+ /** The unconfigured locales present on one type, with row counts. */
58
+ private orphansOf;
59
+ }
60
+ //# sourceMappingURL=orphaned-locale.checker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"orphaned-locale.checker.d.ts","sourceRoot":"","sources":["../../../../src/lib/locales/services/orphaned-locale.checker.ts"],"names":[],"mappings":"AAAA,OAAO,EAIH,KAAK,sBAAsB,EAC9B,MAAM,gBAAgB,CAAC;AAExB,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AACnE,OAAO,EAGH,KAAK,mBAAmB,EAC3B,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAC3D,OAAO,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAC;AAKlE,iFAAiF;AACjF,MAAM,WAAW,oBAAoB;IACjC,yCAAyC;IACzC,QAAQ,EAAE,MAAM,CAAC;IACjB,oCAAoC;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,+BAA+B;IAC/B,IAAI,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,qBACa,qBAAsB,YAAW,sBAAsB;IAI1C,OAAO,CAAC,QAAQ,CAAC,EAAE;IAErC,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACJ,OAAO,CAAC,QAAQ,CAAC,MAAM;IAC5C,OAAO,CAAC,QAAQ,CAAC,OAAO;IAP5B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA0C;gBAG1B,EAAE,EAAE,QAAQ,EAE9B,QAAQ,EAAE,mBAAmB,EACR,MAAM,EAAE,gBAAgB,EAC7C,OAAO,EAAE,qBAAqB;IAGnD,kBAAkB;IACZ,sBAAsB,IAAI,OAAO,CAAC,IAAI,CAAC;IA4B7C;;;;OAIG;IACG,WAAW,IAAI,OAAO,CAAC,oBAAoB,EAAE,CAAC;IAUpD,qEAAqE;YACvD,SAAS;CAwB1B"}
@@ -0,0 +1,114 @@
1
+ "use strict";
2
+ var OrphanedLocaleChecker_1;
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.OrphanedLocaleChecker = void 0;
5
+ const tslib_1 = require("tslib");
6
+ const common_1 = require("@nestjs/common");
7
+ const drizzle_orm_1 = require("drizzle-orm");
8
+ const database_1 = require("@orthacms/database");
9
+ const content_server_1 = require("@orthacms/content-server");
10
+ const i18n_constants_1 = require("../../i18n.constants");
11
+ const locale_registry_service_1 = require("./locale-registry.service");
12
+ /**
13
+ * Boot-time guard against the one locale change that fails silently: **removing
14
+ * a locale from the config while its rows still exist**.
15
+ *
16
+ * Adding a locale is safe and self-announcing — existing groups gain a missing
17
+ * slot, coverage reports `translated: 0`, the panel shows `entry: null`.
18
+ * Removing one is neither. Nothing deletes the rows and nothing migrates them,
19
+ * and from that moment every read path hides them: `?locale=de` becomes a 400
20
+ * (`resolve` no longer knows the slug), the locale panel iterates the
21
+ * configured set, the coverage query and the virtual filters restrict to it,
22
+ * and the batched summary drops them. The German content is intact, reachable
23
+ * only by `psql`, and the product never mentions it again — including to the
24
+ * person who made the change, who sees a smaller locale switcher and nothing
25
+ * else. `LocaleSet.remove` guards the *default* against exactly this class of
26
+ * mistake, but the plugin never calls it: the config is a literal array, so
27
+ * there is no removal operation to guard, only a diff nobody computes.
28
+ *
29
+ * This computes it, once, at boot: for every localized content type, the
30
+ * distinct locale slugs present that the config does not declare, with a row
31
+ * count. Under the default `'fail'` policy the boot aborts, which puts the
32
+ * decision (migrate the rows, or restore the locale) in front of whoever
33
+ * changed the config while it is still their change; `'warn'` logs the same
34
+ * report and continues, for a deployment knowingly mid-migration.
35
+ *
36
+ * The scan is one `GROUP BY locale` per localized type, on a column the
37
+ * `(locale_group_id, locale)` index already covers.
38
+ */
39
+ let OrphanedLocaleChecker = OrphanedLocaleChecker_1 = class OrphanedLocaleChecker {
40
+ db;
41
+ registry;
42
+ config;
43
+ locales;
44
+ logger = new common_1.Logger(OrphanedLocaleChecker_1.name);
45
+ constructor(db, registry, config, locales) {
46
+ this.db = db;
47
+ this.registry = registry;
48
+ this.config = config;
49
+ this.locales = locales;
50
+ }
51
+ /** @inheritdoc */
52
+ async onApplicationBootstrap() {
53
+ const orphans = await this.findOrphans();
54
+ if (!orphans.length)
55
+ return;
56
+ const detail = orphans
57
+ .map((orphan) => `${orphan.typeName}: ${orphan.rows} row(s) in "${orphan.locale}"`)
58
+ .join('; ');
59
+ const configured = this.locales
60
+ .all()
61
+ .map((locale) => locale.slug)
62
+ .join(', ');
63
+ const message = `Entry rows exist in locales this deployment no longer configures ` +
64
+ `(configured: ${configured}). They are invisible to every read ` +
65
+ `path — migrate or delete them, or restore the locale. ${detail}`;
66
+ if ((this.config.orphanedLocales ?? 'fail') === 'warn') {
67
+ this.logger.warn(message);
68
+ return;
69
+ }
70
+ throw new Error(`${message} Set plugins.i18n.orphanedLocales to 'warn' to boot anyway.`);
71
+ }
72
+ /**
73
+ * Rows in an unconfigured locale, per localized content type. Public so a
74
+ * migration script or an operator endpoint can ask the same question
75
+ * without re-deriving the rule.
76
+ */
77
+ async findOrphans() {
78
+ const configured = this.locales.all().map((locale) => locale.slug);
79
+ const reports = [];
80
+ for (const type of this.registry.all()) {
81
+ if (!type.i18n)
82
+ continue;
83
+ reports.push(...(await this.orphansOf(type, configured)));
84
+ }
85
+ return reports;
86
+ }
87
+ /** The unconfigured locales present on one type, with row counts. */
88
+ async orphansOf(type, configured) {
89
+ const table = type.table;
90
+ // Soft-deleted rows are counted too: a restore would bring back a row
91
+ // in a locale nothing can show, which is the same problem deferred.
92
+ const rows = (await this.db
93
+ .select({
94
+ locale: (0, drizzle_orm_1.sql) `${table['locale']}`,
95
+ rows: (0, drizzle_orm_1.sql) `count(*)::int`
96
+ })
97
+ .from(type.table)
98
+ .where((0, drizzle_orm_1.notInArray)(table['locale'], configured))
99
+ .groupBy((0, drizzle_orm_1.sql) `${table['locale']}`));
100
+ return rows.map((row) => ({
101
+ typeName: type.name,
102
+ locale: row.locale,
103
+ rows: row.rows
104
+ }));
105
+ }
106
+ };
107
+ exports.OrphanedLocaleChecker = OrphanedLocaleChecker;
108
+ exports.OrphanedLocaleChecker = OrphanedLocaleChecker = OrphanedLocaleChecker_1 = tslib_1.__decorate([
109
+ (0, common_1.Injectable)(),
110
+ tslib_1.__param(0, (0, database_1.InjectDatabase)()),
111
+ tslib_1.__param(1, (0, content_server_1.InjectContentRegistry)()),
112
+ tslib_1.__param(2, (0, common_1.Inject)(i18n_constants_1.I18N_CONFIG)),
113
+ tslib_1.__metadata("design:paramtypes", [Object, Function, Object, locale_registry_service_1.LocaleRegistryService])
114
+ ], OrphanedLocaleChecker);