Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/31074-cli-i18n-coverage-empty-entry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/cli': patch
---

fix(cli): i18n coverage reports a blank inline-label entry the renderer shows as a gap

Clause-②: no
Metadata-protocol: none

An inline locale map with an empty entry that the renderer reaches for a locale no longer counts that locale as translated. `{ 'zh-CN': '成员', 'zh-TW': '' }` was reported covered for `zh-Hant` by the Simplified text, while the console shows the blank Traditional entry; it is now a `missing translation` finding, as an empty bundle value already is. Readers that never reach the blank entry are covered as before.
10 changes: 10 additions & 0 deletions .changeset/31074-i18n-cross-script-edit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@object-ui/i18n': patch
---

fix(i18n): an edit in a Chinese script the label lacks adds its own entry instead of overwriting the other script

Clause-②: no
Metadata-protocol: none

`setLocalized` and `clearLocalized` no longer treat the other Chinese script's entry as the editor's own. A `zh-Hant` (or `zh-TW`, `zh-HK`) edit of a label written as `{ en: 'Customer', zh: '客户' }` now adds a `zh-Hant` entry and keeps the Simplified text; before, it overwrote `zh` with Traditional text. A clear in that case removes nothing, because the box was showing the other script's text as a fallback. A Simplified editor over a Traditional-only map is handled the same way. Reading is unchanged: `pickLocalized` still shows the other script's entry until one of the reader's own script exists. Every other language, and every map that has an entry of the editor's script, writes exactly as before.
19 changes: 10 additions & 9 deletions packages/cli/src/utils/i18n-coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -545,27 +545,28 @@ function collectExpectedKeys(config: any): ExpectedKey[] {
*
* Empty values do not count, matching {@link lookupKey}'s rule on the bundle
* side — an empty translation is not a translation, whichever side it is
* written on.
* written on. They are judged AFTER the resolver has picked, not before: the
* renderer shows an empty entry when it is the one its limbs reach, so a blank
* `zh-TW` beside `zh-CN` leaves a `zh-Hant` reader looking at nothing, and a
* blank exact `zh-CN` beside `zh` leaves a `zh-CN` reader the same. Dropping
* empties first would let the other entry stand in and report that reader
* covered (#31074).
*/
export function inlineLocaleText(
map: Readonly<Record<string, string>> | undefined,
locale: string,
): string | undefined {
if (!map) return undefined;
const read = (tag: string): string | undefined => {
if (!Object.prototype.hasOwnProperty.call(map, tag)) return undefined;
const value = map[tag];
return typeof value === 'string' && value.length > 0 ? value : undefined;
};
const tag = (locale || 'en').trim();
const base = tag.split('-')[0];
const sameLanguage: Record<string, string> = {};
for (const key of Object.keys(map)) {
const value = read(key);
if (value !== undefined && key.split('-')[0] === base) sameLanguage[key] = value;
const value = map[key];
if (typeof value === 'string' && key.split('-')[0] === base) sameLanguage[key] = value;
}
if (Object.keys(sameLanguage).length === 0) return undefined;
return resolveI18nLabel(sameLanguage, tag);
const shown = resolveI18nLabel(sameLanguage, tag);
return shown !== undefined && shown.length > 0 ? shown : undefined;
}

/**
Expand Down
21 changes: 19 additions & 2 deletions packages/cli/test/i18n-inline-locale-map-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,9 @@ describe('coverage reads the map the way the renderer does', () => {
{ en: 'Members', 'zh-CN': '成员', 'zh-TW': '成員' },
{ en: 'Members', 'zh-TW': '成員', 'zh-CN': '成员' },
{ en: 'Members', zh: '成员', 'zh-Hant': '成員' },
// An empty entry the renderer reaches (#31074): that reader sees nothing.
{ en: 'Members', 'zh-CN': '成员', 'zh-TW': '' },
{ en: 'Members', zh: '成员', 'zh-CN': '' },
];
const locales = ['en', 'zh', 'zh-CN', 'zh-TW', 'zh-Hans', 'zh-Hant', 'zh-HK', 'ja-JP', 'es-ES'];

Expand Down Expand Up @@ -309,7 +312,21 @@ describe('coverage reads the map the way the renderer does', () => {
it("a map with both Chinese scripts covers each reader with its own script's entry (#30910)", () => {
expect(inlineLocaleText({ 'zh-CN': '成员', 'zh-TW': '成員' }, 'zh-Hant')).toBe('成員');
expect(inlineLocaleText({ 'zh-TW': '成員', 'zh-CN': '成员' }, 'zh')).toBe('成员');
// An empty entry of the reader's script is no translation: the other script still covers.
expect(inlineLocaleText({ 'zh-CN': '成员', 'zh-TW': '' }, 'zh-Hant')).toBe('成员');
// (The row that pinned an empty `zh-TW` as covered by `zh-CN` at `zh-Hant`
// moved to the #31074 case below: the renderer shows the empty entry.)
});

it('an empty entry the renderer shows is a gap, not covered by another entry (#31074)', () => {
// Own script: the reader of the Traditional script sees the blank `zh-TW`.
expect(resolveI18nLabel({ 'zh-CN': '成员', 'zh-TW': '' }, 'zh-Hant')).toBe('');
expect(inlineLocaleText({ 'zh-CN': '成员', 'zh-TW': '' }, 'zh-Hant')).toBeUndefined();
expect(inlineLocaleText({ 'zh-CN': '成员', 'zh-TW': '' }, 'zh-HK')).toBeUndefined();
// Own tag: an exact blank `zh-CN` shadows the bare `zh`.
expect(resolveI18nLabel({ zh: '成员', 'zh-CN': '' }, 'zh-CN')).toBe('');
expect(inlineLocaleText({ zh: '成员', 'zh-CN': '' }, 'zh-CN')).toBeUndefined();
// CONTROL: the readers that never reach the blank entry stay covered.
expect(inlineLocaleText({ 'zh-CN': '成员', 'zh-TW': '' }, 'zh-Hans')).toBe('成员');
expect(inlineLocaleText({ zh: '成员', 'zh-CN': '' }, 'zh')).toBe('成员');
expect(inlineLocaleText({ 'zh-CN': '成员' }, 'zh-TW')).toBe('成员');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import { usePermissions } from '@object-ui/permissions';
import { extractWriteErrorMessage, useAdapter, useMetadata } from '@object-ui/react';
import { Plus, X, Users, Loader2, AlertCircle } from 'lucide-react';
import { useMetadataLocale } from './i18n.js';
import { isZhLocale } from './schema-field-label.js';
import { membershipOrganization, readOrganizationMemberIds, type MemberFilter } from './organization-members.js';

/** The object a permission set's direct grants are rows of: ADD creates one, REMOVE deletes one. */
Expand Down Expand Up @@ -129,7 +130,7 @@ interface AssignedRow {
* them: a space in English, none after a CJK full stop.
*/
function useCopy() {
const zh = useMetadataLocale() === 'zh-CN';
const zh = isZhLocale(useMetadataLocale());
return React.useMemo(
() =>
zh
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import { usePermissions } from '@object-ui/permissions';
import { extractWriteErrorMessage, useAdapter, useMetadata } from '@object-ui/react';
import { AlertCircle, Loader2, Plus, UserCheck, X } from 'lucide-react';
import { useMetadataLocale } from './i18n.js';
import { isZhLocale } from './schema-field-label.js';
import { membershipOrganization, readOrganizationMemberIds, type MemberFilter } from './organization-members.js';

export interface PositionHoldersSectionProps {
Expand Down Expand Up @@ -107,7 +108,7 @@ const PICKER_EMAIL_FIELD = 'email';
const refusalSentence = (err: unknown): string => extractWriteErrorMessage(err) ?? '';

function useCopy() {
const zh = useMetadataLocale() === 'zh-CN';
const zh = isZhLocale(useMetadataLocale());
return React.useMemo(
() =>
zh
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ import { useRegisterAssistantEditor, type AssistantEditorContext } from '../../a
import { useRegisteredMetadataInspector } from './inspector-registry.js';
import { useRegisteredMetadataDefaultInspector } from './default-inspector-registry.js';
import { InspectorReadOnlyReason } from './inspectors/_shared.js';
import { useMetadataLocale, t, tFormat, translateMetadataType, translateSchemaIssue, translateValidationMessage } from './i18n.js';
import { isZhLocale, useMetadataLocale, t, tFormat, translateMetadataType, translateSchemaIssue, translateValidationMessage } from './i18n.js';
import { JsonSourceEditor } from '../item-tools/JsonSourceEditor.js';
import { validateMetadataDraft, hasClientValidator, type DraftMode } from './clientValidation.js';
import {
Expand Down Expand Up @@ -3056,7 +3056,7 @@ function MetadataResourceEditPageImpl({
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-amber-800 underline hover:text-amber-900 dark:text-amber-200 dark:hover:text-amber-100"
>
{locale === 'zh-CN' ? '查看文档' : 'View docs'} →
{isZhLocale(locale) ? '查看文档' : 'View docs'} →
</a>
)}
{layered?.packageId && (
Expand Down
32 changes: 27 additions & 5 deletions ui/packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
* `ui/scripts/__tests__/check-i18n-dead-keys.test.ts`.
*/

import { useObjectTranslation } from '@object-ui/i18n';
import { resolveBuiltInLanguage, useObjectTranslation } from '@object-ui/i18n';
// The designer strings' new home. Its `en` catalogue is resident; its other
// catalogues are fetched on demand, and `useMetadataLocale()` re-renders its
// caller when one lands. ⚠️ `ui/scripts/check-i18n-dead-keys.mjs` reads the
Expand All @@ -74,7 +74,15 @@ import type {
MetadataOverlayScope,
} from '@object-ui/data-objectstack';

export type SupportedLocale = 'en-US' | 'zh-CN';
/**
* The designer locale. `'zh-Hant'` is a Traditional Chinese session: Studio
* reads and writes the Traditional entry of an inline label in it, and its
* chrome strings fall back to the namespace's `zh` (Simplified) catalogue,
* which is the only Chinese one it ships (`resolveStudioLanguage`). Compare a
* value with `isZhLocale`, never `=== 'zh-CN'`, when the question is "Chinese
* chrome?".
*/
export type SupportedLocale = 'en-US' | 'zh-CN' | 'zh-Hant';

const TYPE_LABELS_EN: Record<string, string> = {
// Data
Expand Down Expand Up @@ -931,9 +939,23 @@ export function detectLocale(): SupportedLocale {
return 'en-US';
}

/**
* The designer locale for an i18n `language` — {@link useMetadataLocale}'s
* answer, as a pure function.
*/
export function designerLocaleOf(language: string | undefined): SupportedLocale {
if (!/^zh/i.test(language ?? '')) return 'en-US';
return resolveBuiltInLanguage(language ?? '') === 'zh-Hant' ? 'zh-Hant' : 'zh-CN';
}

/**
* React hook — the Studio/metadata-admin locale derived from the app's ACTIVE
* i18n language, collapsed to one of the two locales this catalog ships.
* i18n language: `'zh-Hant'` for a session the console serves in Traditional
* Chinese (`zh-Hant`, `zh-TW`, `zh-HK`, … through `resolveBuiltInLanguage`,
* the console's own resolution), `'zh-CN'` for every other Chinese session,
* `'en-US'` otherwise. The Traditional value exists for the label entries an
* author reads and edits (the nav designer shows and renames the entry the
* sidebar shows); its chrome strings are the Simplified ones.
*
* Prefer this over {@link detectLocale} in components: `detectLocale()` reads
* `navigator.language`, which never changes when the user switches languages
Expand All @@ -944,15 +966,15 @@ export function detectLocale(): SupportedLocale {
*
* ⛔ Never hand this value to `Intl` (`Intl.*Format`, `toLocale*String`) as the
* locale a date, time or number is formatted in. It picks one of this file's
* TWO string tables, so it is `'en-US'` for every language that is not zh — a
* string tables, so it is `'en-US'` for every language that is not zh — a
* de-DE session would read its faces in US English (objectui#10232). Faces
* format with `useDisplayLocale()` from `@object-ui/i18n`, the channel every
* date and number renderer resolves through; keep this value for `t()` /
* `tFormat()` and the other lookups into the tables here.
*/
export function useMetadataLocale(): SupportedLocale {
const { language } = useObjectTranslation();
const locale: SupportedLocale = /^zh/i.test(language) ? 'zh-CN' : 'en-US';
const locale = designerLocaleOf(language);
// Starts the fetch of the `studio` namespace catalogue this locale reads and
// re-renders the caller when it lands, so a moved key does not stay on the
// `en` answer the first render gave.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,8 @@ describe('#30910 — renamedLabel and clearedLabel change the entry the resolver
// Neither the exact tag nor the bare language: the exact tag is added, a sibling region is not owned.
[{ en: 'Customer', 'zh-CN': '客户', 'zh-TW': '客戶' }, 'zh-Hant', { en: 'Customer', 'zh-CN': '客户', 'zh-TW': '客戶', 'zh-Hant': 'RENAMED' }],
[{ en: 'Customer', 'zh-TW': '客戶' }, 'zh-Hant', { en: 'Customer', 'zh-TW': '客戶', 'zh-Hant': 'RENAMED' }],
// The bare language with no sibling of the locale's script: the bare language, as before.
[{ en: 'Customer', zh: '客户' }, 'zh-Hant', { en: 'Customer', zh: 'RENAMED' }],
// The bare language of the locale's own script: the bare language, as before.
// (`{ en, zh }` renamed in `zh-Hant`, which wrote `zh` here, moved to the #31074 block.)
[{ en: 'Customer', zh: '客户', 'zh-TW': '客戶' }, 'zh-CN', { en: 'Customer', zh: 'RENAMED', 'zh-TW': '客戶' }],
// Languages the table names once.
[{ en: 'Accounts', 'en-GB': 'Accounts (GB)' }, 'en-US', { en: 'RENAMED', 'en-GB': 'Accounts (GB)' }],
Expand All @@ -207,3 +207,29 @@ describe('#30910 — renamedLabel and clearedLabel change the entry the resolver
expect(renamedLabel(map as I18nLabel, 'RENAMED', locale)).toEqual(expected);
});
});

/**
* #31074 — a rename in a script the map lacks adds the locale's own entry
* (the shared write limb, `setLocalized`), and a clear there removes nothing:
* a Traditional designer over `{ en, zh }` used to overwrite the Simplified
* text. The entry is shown in the other script until then, as the sidebar
* shows it.
*/
describe('#31074 — a rename in a script the map lacks adds its own entry', () => {
const CROSS: ReadonlyArray<readonly [Record<string, string>, string]> = [
[{ en: 'Customer', zh: '客户' }, 'zh-Hant'],
[{ en: 'Customer', 'zh-CN': '客户' }, 'zh-Hant'],
[{ en: 'Customer', zh: '客户' }, 'zh-TW'],
[{ en: 'Customer', 'zh-Hant': '客戶' }, 'zh-CN'],
];

it.each(CROSS)('%j renamed in %s adds that tag, keeps the other script, and shows the new text', (map, locale) => {
const next = renamedLabel(map as I18nLabel, 'RENAMED', locale) as Record<string, string>;
expect(next).toEqual({ ...map, [locale]: 'RENAMED' });
expect(navItemLabelText(next as I18nLabel, locale)).toBe('RENAMED');
});

it.each(CROSS)('%j cleared in %s removes nothing', (map, locale) => {
expect(clearedLabel(map as I18nLabel, locale)).toEqual(map);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
*/

import { resolveI18nLabel, type I18nLabel } from '@objectstack/spec/ui';
import { clearLocalized, setLocalized } from '@object-ui/i18n';
import { resolveNavItemLabel, type NavTargetLabelResolver } from '@object-ui/layout';
import type { NavigationItem } from '@object-ui/types';

Expand Down Expand Up @@ -116,16 +117,19 @@ export function navEntryLabelText(
* `{ zh, 'zh-TW' }`, because bare `zh` means `zh-Hans`) — so the editor shows
* the new text straight after the edit. A map with none of these gains an
* entry under the exact tag; an entry the resolver only reached as another
* language's fallback (`default`, `en` under `zh-CN`, a sibling region) is left
* as it was. Because the new entry is under the exact
* language's fallback (`default`, `en` under `zh-CN`, a sibling region, or the
* other Chinese script's entry: `zh` under `zh-Hant`) is left as it was. The
* entry is chosen by the shared write limb (`setLocalized`, see
* {@link ownsAnEntry}). Because the new entry is under the exact
* tag, the next edit finds it first: an editor that writes on every keystroke
* adds that entry once, never one per keystroke.
*/
export function renamedLabel(cur: I18nLabel | undefined, next: string, locale: string): I18nLabel {
if (!cur || typeof cur !== 'object' || Array.isArray(cur)) return next;
const map: Record<string, string> = cur;
const key = ownEntryKey(map, locale) ?? locale.trim();
return { ...map, [key]: next };
const tag = locale.trim();
if (!ownsAnEntry(map, tag)) return { ...map, [tag]: next };
return setLocalized(map, tag, next) as Record<string, string>;
}

/**
Expand All @@ -142,37 +146,27 @@ export function renamedLabel(cur: I18nLabel | undefined, next: string, locale: s
*/
export function clearedLabel(cur: I18nLabel | undefined, locale: string): I18nLabel | undefined {
if (!cur || typeof cur !== 'object' || Array.isArray(cur)) return undefined;
const map: Record<string, string> = { ...cur };
const key = ownEntryKey(map, locale);
if (key !== undefined) delete map[key];
return Object.values(map).some((v) => typeof v === 'string') ? map : undefined;
const tag = locale.trim();
const map = ownsAnEntry(cur, tag) ? clearLocalized(cur, tag) : cur;
return map !== undefined && Object.values(map).some((v) => typeof v === 'string')
? (map as Record<string, string>)
: undefined;
}

/**
* The map entry an edit in `locale` owns: the exact tag, else the entry
* `resolveI18nLabel` reads in place of the bare language, else the bare
* language; `undefined` when the map has neither the tag nor the language.
*
* Which entry the resolver reads is asked of the resolver itself, over a map
* of each usable key to its own name, so this module keeps no copy of the
* resolver's limbs. With the bare language present the resolver reads it,
* unless it prefers a sibling of the locale's own script (`zh-TW` under
* `zh-Hant`, where bare `zh` means `zh-Hans`); then the sibling is the entry
* on screen and the one an edit owns. A sibling the resolver reaches only
* because the bare language is ABSENT stays another region's fallback, as
* before: the edit adds the exact tag.
* Whether an edit in `tag` owns an entry of `map` at all: the exact tag or the
* bare language is present. Which entry it then owns is the shared write limb's
* answer (`setLocalized` / `clearLocalized` in `@object-ui/i18n`), the entry
* the resolver shows: the exact tag, else the bare language, else, where the
* resolver prefers it, a sibling of the locale's own script (`zh-TW` under
* `zh-Hant` in `{ zh, 'zh-TW' }`). Where the platform table splits the language
* by script, an entry of the OTHER script is never owned: a `zh-Hant` edit over
* `{ en, zh }` adds `zh-Hant` and keeps the Simplified text. A sibling the
* resolver reaches only because the bare language is ABSENT stays another
* region's fallback, as before: the edit adds the exact tag. So this module
* keeps no copy of either side's limbs.
*/
function ownEntryKey(map: Record<string, string>, locale: string): string | undefined {
const tag = locale.trim();
function ownsAnEntry(map: Record<string, string>, tag: string): boolean {
const own = (key: string) => Object.prototype.hasOwnProperty.call(map, key) && typeof map[key] === 'string';
if (own(tag)) return tag;
const base = tag.split('-')[0];
if (!own(base)) return undefined;
const keyOfEachEntry: Record<string, string> = Object.fromEntries(
Object.keys(map)
.filter(own)
.map((key) => [key, key]),
);
const read = resolveI18nLabel(keyOfEachEntry, locale);
return read !== undefined && read !== base && read.split('-')[0] === base ? read : base;
return own(tag) || own(tag.split('-')[0]);
}
Loading
Loading