Skip to content

Commit a9d1aa0

Browse files
committed
wip(service-settings): Setup's default language serves the deployment's locale list
Claude-Session: https://claude.ai/code/session_01Utp6CZEhtHfQH5TLEVe3TF Co-authored-by: Claude <noreply@anthropic.com>
1 parent fe835b4 commit a9d1aa0

8 files changed

Lines changed: 346 additions & 14 deletions

File tree

‎packages/services/service-settings/src/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ export {
5454
type SettingsActionHandler,
5555
type SettingsLiveStatus,
5656
type SettingsLiveStatusProvider,
57+
// The options a select key serves from the running system, not its manifest.
58+
type SettingsOptionSource,
59+
type SettingsOptionSourceAnswer,
60+
type SettingsServedOption,
5761
// ADR-0144 D4 — the write-side rule a package registers on one key.
5862
type SettingsWritePredicate,
5963
type SettingsWritePredicateInput,
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The options of `localization.locale` (Setup's 默认语言): the languages the
5+
* deployment supports, not a list written into the manifest.
6+
*
7+
* The manifest used to declare four (`en-US`, `zh-CN`, `ja-JP`, `es-ES`), and
8+
* that table was the enforcement boundary. The user's language menu offers
9+
* something else: what `GET /api/v1/i18n/locales` reports, which is the i18n
10+
* service's `getLocales()` (the app's declared `i18n.supportedLocales`, else
11+
* every locale a bundle or an authored translation item has loaded). So a
12+
* deployment with a French pack, or a Vietnamese translation item, offered
13+
* French or Vietnamese to each user and refused it as the organization's
14+
* default. This source makes both answers one: the options and the accepted
15+
* values are what `getLocales()` reports at the moment they are asked, so a
16+
* translation item added at runtime is offered here too.
17+
*
18+
* What it answers, for the i18n service's codes:
19+
*
20+
* - **Options.** One per code, in the service's order, each named in its own
21+
* language: the platform locale table's name for the language the code
22+
* means (`PLATFORM_LOCALES`, through its read aliases, so `zh-CN` is
23+
* 简体中文), else `Intl.DisplayNames` in the code itself (`vi-VN` is
24+
* "Tiếng Việt (Việt Nam)"), else the code. The language menu names them
25+
* the same way.
26+
* - **Accepted values.** Each code, and a second spelling of one: a stored
27+
* code that means the same table language (`en-US` where `en` is offered,
28+
* `zh-CN` where `zh-Hans` is). Those are the spellings this setting has
29+
* stored all along, and its manifest default is `en-US`; the read aliases
30+
* exist so that such values stay valid. A code the table does not name is
31+
* accepted only as spelled (`fr-FR` is not `fr`).
32+
* - **A stored value in effect.** Offered under its own spelling when it is a
33+
* second spelling of an offered code, so the page shows it selected; and,
34+
* when the deployment no longer offers it, offered as itself, named as no
35+
* longer offered, so the page shows what is in effect rather than a blank.
36+
* It is not accepted again: only a write that changes the key is judged, so
37+
* the stored value keeps reading and keeps working until someone picks
38+
* another language.
39+
*
40+
* No i18n service in the composition, or one that reports no locale: the
41+
* source answers nothing and the manifest's declared four stand.
42+
*/
43+
44+
import { resolveLocale } from '@objectstack/core';
45+
import { PLATFORM_LOCALES, platformLocaleTagOf } from '@objectstack/spec/system';
46+
import type { SettingsOptionSource, SettingsServedOption } from './settings-service.types.js';
47+
48+
/** The i18n service surface this source reads: the locale list the language menu reads. */
49+
export interface LocaleListService {
50+
getLocales(): readonly string[];
51+
}
52+
53+
const TABLE_LABEL = new Map<string, string>(PLATFORM_LOCALES.map((row) => [row.tag, row.label]));
54+
55+
/**
56+
* A locale's name in its own language: the platform locale table's name for
57+
* the language the code means, else `Intl.DisplayNames` in the code itself,
58+
* capitalized as the table's names are, else the code. Every failure answers
59+
* the code, which is still an honest, selectable name: `Intl.DisplayNames`
60+
* may be absent, throws `RangeError` on a malformed tag, and answers the code
61+
* back for a language its ICU data does not name.
62+
*/
63+
export function localeSelfName(code: string): string {
64+
const tag = platformLocaleTagOf(code);
65+
const fromTable = tag ? TABLE_LABEL.get(tag) : undefined;
66+
if (fromTable) return fromTable;
67+
try {
68+
const name = new Intl.DisplayNames([code], { type: 'language' }).of(code);
69+
if (!name || name === code) return code;
70+
return name.charAt(0).toLocaleUpperCase(code) + name.slice(1);
71+
} catch {
72+
return code;
73+
}
74+
}
75+
76+
/** "no longer offered", in the reader's language. */
77+
export const NOT_OFFERED_COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', string> = {
78+
en: 'no longer offered',
79+
'zh-CN': '已不再提供',
80+
'ja-JP': '現在は提供されていません',
81+
'es-ES': 'ya no disponible',
82+
};
83+
84+
const COPY_LOCALES = Object.keys(NOT_OFFERED_COPY);
85+
86+
/** The "no longer offered" words for a reader's locale: exact, then by language, else English. */
87+
export function notOfferedCopy(locale: string | undefined): string {
88+
const picked = locale ? resolveLocale(locale, COPY_LOCALES) : undefined;
89+
return NOT_OFFERED_COPY[(picked ?? 'en') as keyof typeof NOT_OFFERED_COPY];
90+
}
91+
92+
/** The locale codes the i18n service reports, de-duplicated, in its order. */
93+
function offeredCodes(i18n: LocaleListService | undefined): string[] {
94+
const reported = i18n && typeof i18n.getLocales === 'function' ? i18n.getLocales() : undefined;
95+
if (!Array.isArray(reported)) return [];
96+
const seen = new Set<string>();
97+
for (const code of reported) {
98+
if (typeof code === 'string' && code.trim()) seen.add(code.trim());
99+
}
100+
return [...seen];
101+
}
102+
103+
/**
104+
* The option source of `localization.locale`. `i18n` is asked at each use
105+
* (the i18n service registers after this plugin's `init`); it answers
106+
* `undefined` when the composition carries none.
107+
*/
108+
export function localizationLocaleOptionSource(
109+
i18n: () => LocaleListService | undefined,
110+
): SettingsOptionSource {
111+
return ({ current, locale } = {}) => {
112+
const codes = offeredCodes(i18n());
113+
if (codes.length === 0) return undefined;
114+
115+
// The table language each offered code means, where the table names one.
116+
const offeredTags = new Set(codes.map((code) => platformLocaleTagOf(code)).filter(Boolean));
117+
const admits = (value: string): boolean => {
118+
if (codes.includes(value)) return true;
119+
const tag = platformLocaleTagOf(value);
120+
return tag !== undefined && offeredTags.has(tag);
121+
};
122+
123+
const options: SettingsServedOption[] = codes.map((code) => ({ value: code, label: localeSelfName(code) }));
124+
125+
if (typeof current === 'string' && current.trim() && !codes.includes(current)) {
126+
const tag = platformLocaleTagOf(current);
127+
const sameLanguage = tag === undefined ? -1 : codes.findIndex((code) => platformLocaleTagOf(code) === tag);
128+
if (sameLanguage !== -1) {
129+
// Stored as a second spelling (`en-US` where `en` is offered): offer
130+
// that language under the stored spelling, so the page shows it chosen.
131+
options[sameLanguage] = { value: current, label: options[sameLanguage].label };
132+
} else {
133+
options.push({ value: current, label: `${localeSelfName(current)} (${notOfferedCopy(locale)})` });
134+
}
135+
}
136+
137+
return { options, admits };
138+
};
139+
}
140+
141+
/** The settings service surface {@link registerLocalizationLocaleOptions} needs. */
142+
export interface LocaleOptionsHost {
143+
registerOptionSource(namespace: string, key: string, source: SettingsOptionSource): void;
144+
}
145+
146+
/**
147+
* Serve `localization.locale`'s options from the i18n service. Called before
148+
* the manifest registers, so the manifest's registration-time audit of
149+
* `OS_LOCALIZATION_LOCALE` leaves the key to be judged when it is read.
150+
*/
151+
export function registerLocalizationLocaleOptions(
152+
host: LocaleOptionsHost,
153+
i18n: () => LocaleListService | undefined,
154+
): void {
155+
host.registerOptionSource('localization', 'locale', localizationLocaleOptionSource(i18n));
156+
}

‎packages/services/service-settings/src/manifests/localization.manifest.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,13 @@ export const localizationSettingsManifest: SettingsManifest = {
8383
{
8484
type: 'select', key: 'locale', label: 'Default language', required: false, default: 'en-US',
8585
description: 'BCP-47 locale for message catalogs and number/date formatting.',
86+
// [#30286] What a running deployment offers and accepts here is NOT this
87+
// table: `SettingsServicePlugin` registers an option source for this key
88+
// (`localization-locale-options.ts`) that serves the i18n service's
89+
// locale list, the one the user's language menu reads, each language
90+
// named in its own language. These four are what a composition without
91+
// an i18n service gets; the translation bundles name none of them, for
92+
// the same reason.
8693
options: [
8794
{ value: 'en-US', label: 'English (US)' },
8895
{ value: 'zh-CN', label: '简体中文' },

‎packages/services/service-settings/src/settings-routes.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,8 @@ export function registerSettingsRoutes(
289289
mountFamilyRoute('get', `${base}/:namespace`, {
290290
async serve(req, res, ctx) {
291291
const ns = req.params.namespace;
292-
const payload = await service.getNamespace(ns, ctx);
292+
// The request's language names a served option no longer offered.
293+
const payload = await service.getNamespace(ns, ctx, { locale: requestLocaleOf(req) });
293294
// The page states what is LIVE, not only what is stored: the owning
294295
// package's statement heads the specifiers as an `info_banner`, asked on
295296
// this read (see `SettingsService.liveStatusOf`). Added here and not in

‎packages/services/service-settings/src/settings-service-plugin.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
} from './local-crypto-provider.js';
4040
import { buildConfigChangeAuditSink } from './config-change-audit.js';
4141
import { registerLocalizationCurrencyLock, type CurrencyUsageEngine } from './localization-currency-lock.js';
42+
import { registerLocalizationLocaleOptions, type LocaleListService } from './localization-locale-options.js';
4243
import { USER_OBJECT, assertUserReferenceResolves, registeredLabel } from './actor-reference.js';
4344
import { registerSettingsRoutes } from './settings-routes.js';
4445
import { registerSettingsReadDoor } from './settings-read-door.js';
@@ -231,6 +232,18 @@ export class SettingsServicePlugin implements Plugin {
231232
// `console.error` fallback instead.
232233
logger: ctx.logger,
233234
});
235+
// Setup's default language offers the languages the deployment supports:
236+
// the i18n service's locale list, the one the language menu reads, asked
237+
// at each use (the i18n service registers after this `init`). Registered
238+
// before the manifests, so `localization`'s registration-time audit of
239+
// `OS_LOCALIZATION_LOCALE` leaves that key to be judged when it is read.
240+
registerLocalizationLocaleOptions(this.service, () => {
241+
try {
242+
return ctx.getService<LocaleListService>('i18n');
243+
} catch {
244+
return undefined;
245+
}
246+
});
234247
for (const m of this.opts.manifests ?? []) {
235248
if (UNWALLED_ONLY_NAMESPACES.has(m.namespace)) {
236249
this.heldUntilPostureKnown.push(m);

0 commit comments

Comments
 (0)