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
49 changes: 30 additions & 19 deletions examples/app-todo/src/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,26 +85,37 @@ export const en: TranslationData = {
description: 'Personal task management application',
},
},
// `messages` ids are single-segment, and that is the whole contract, not a
// style preference: `t()` resolves a key by walking its dot path
// (`key.split('.')`, identically in `packages/core/src/fallbacks/memory-i18n.ts`
// and `packages/services/service-i18n/src/file-i18n-adapter.ts`), while
// `messages` is a FLAT `Record<string, string>`. So an id that merely
// *contains* a dot — `'common.save'` — is one key NAMED `common.save`, and
// `t('messages.common.save', …)` looks for a nested `common` object, finds
// none, and returns the key string. `messages.commonSave` resolves (#18566).
// Rule: `content/docs/protocol/kernel/i18n-standard.mdx`; proof that every id
// below reaches its string through the real `t()`:
// `./message-id-resolution.test.ts`.
messages: {
'common.save': 'Save',
'common.cancel': 'Cancel',
'common.delete': 'Delete',
'common.edit': 'Edit',
'common.create': 'Create',
'common.search': 'Search',
'common.filter': 'Filter',
'common.sort': 'Sort',
'common.refresh': 'Refresh',
'common.export': 'Export',
'common.back': 'Back',
'common.confirm': 'Confirm',
'success.saved': 'Successfully saved',
'success.deleted': 'Successfully deleted',
'success.completed': 'Task marked as completed',
'confirm.delete': 'Are you sure you want to delete this task?',
'confirm.complete': 'Mark this task as completed?',
'error.required': 'This field is required',
'error.load_failed': 'Failed to load data',
commonSave: 'Save',
commonCancel: 'Cancel',
commonDelete: 'Delete',
commonEdit: 'Edit',
commonCreate: 'Create',
commonSearch: 'Search',
commonFilter: 'Filter',
commonSort: 'Sort',
commonRefresh: 'Refresh',
commonExport: 'Export',
commonBack: 'Back',
commonConfirm: 'Confirm',
successSaved: 'Successfully saved',
successDeleted: 'Successfully deleted',
successCompleted: 'Task marked as completed',
confirmDelete: 'Are you sure you want to delete this task?',
confirmComplete: 'Mark this task as completed?',
errorRequired: 'This field is required',
errorLoadFailed: 'Failed to load data',
},
// `validationMessages` retired in spec 17.0.0 (#4667) — no resolver ever read
// it, so the zh-CN / ja-JP strings here were never rendered and the `en` ones
Expand Down
40 changes: 21 additions & 19 deletions examples/app-todo/src/translations/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,25 +90,27 @@ export const jaJP: TranslationData = {
description: '個人タスク管理アプリケーション',
},
},
// Single-segment `messages` ids — `t()` walks the dot path, so an id
// containing a dot resolves to nothing; see the `en` bundle (#18566).
messages: {
'common.save': '保存',
'common.cancel': 'キャンセル',
'common.delete': '削除',
'common.edit': '編集',
'common.create': '新規作成',
'common.search': '検索',
'common.filter': 'フィルター',
'common.sort': '並べ替え',
'common.refresh': '更新',
'common.export': 'エクスポート',
'common.back': '戻る',
'common.confirm': '確認',
'success.saved': '保存しました',
'success.deleted': '削除しました',
'success.completed': 'タスクを完了にしました',
'confirm.delete': 'このタスクを削除してもよろしいですか?',
'confirm.complete': 'このタスクを完了にしますか?',
'error.required': 'この項目は必須です',
'error.load_failed': 'データの読み込みに失敗しました',
commonSave: '保存',
commonCancel: 'キャンセル',
commonDelete: '削除',
commonEdit: '編集',
commonCreate: '新規作成',
commonSearch: '検索',
commonFilter: 'フィルター',
commonSort: '並べ替え',
commonRefresh: '更新',
commonExport: 'エクスポート',
commonBack: '戻る',
commonConfirm: '確認',
successSaved: '保存しました',
successDeleted: '削除しました',
successCompleted: 'タスクを完了にしました',
confirmDelete: 'このタスクを削除してもよろしいですか?',
confirmComplete: 'このタスクを完了にしますか?',
errorRequired: 'この項目は必須です',
errorLoadFailed: 'データの読み込みに失敗しました',
},
};
110 changes: 110 additions & 0 deletions examples/app-todo/src/translations/message-id-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { createMemoryI18n } from '@objectstack/core';
import type { TranslationData } from '@objectstack/spec/system';
import { en } from './en';
import { zhCN } from './zh-CN';
import { jaJP } from './ja-JP';

/**
* Message-id reachability (#18566).
*
* This app's `messages` ids were authored dot-separated (`'common.save'`), and
* that spelling is unreachable rather than merely unconventional: `t()` resolves
* a key by `key.split('.')` and walking segment by segment — the same code in
* both shipped implementations — while `messages` is a FLAT
* `Record<string, string>`. `t('messages.common.save', locale)` therefore looks
* for a nested `common` object, finds none, and returns the key string.
*
* A reference example is what an author copies, so the repair is not asserted
* here, it is demonstrated: the real bundle is loaded into a real provider and
* every id is resolved through the public `t()` contract. The last suite is the
* control — it drives the OLD spelling through the same call and pins that it
* returns the key itself, so a green run above cannot be a green run of an
* assertion that could not fail.
*
* The provider driven here is the core in-memory fallback. `FileI18nAdapter`
* (`@objectstack/service-i18n`) is deliberately NOT imported: it would add a
* seventh entry to this package's shrink-only unaliased-artifact ledger
* (`scripts/check-test-source-alias.mjs`), whose remedy is an alias in
* `examples/app-todo/vitest.config.ts`. The two implementations resolve keys
* with the same code — `key.split('.')` walked segment by segment, in
* `packages/core/src/fallbacks/memory-i18n.ts` and in
* `packages/services/service-i18n/src/file-i18n-adapter.ts` — so what this
* suite proves about the ids holds for both.
*/

const BUNDLES: [string, TranslationData][] = [
['en', en],
['zh-CN', zhCN],
['ja-JP', jaJP],
];

/** Minimal read surface — both providers implement `II18nService`. */
interface Provider {
t(key: string, locale: string, params?: Record<string, unknown>): string;
loadTranslations(locale: string, data: Record<string, unknown>): void;
}

/**
* The shipped `II18nService` fallback, loaded with this app's real bundle.
* Constructed per call so no suite can observe another's writes.
*/
function providers(): [string, Provider][] {
const built: [string, Provider][] = [
['memory-i18n (core fallback)', createMemoryI18n() as unknown as Provider],
];
for (const [, provider] of built) {
for (const [locale, data] of BUNDLES) {
provider.loadTranslations(locale, data as unknown as Record<string, unknown>);
}
}
return built;
}

/** Every authored id of a locale, paired with the string it must resolve to. */
function idsOf(data: TranslationData): [string, string][] {
return Object.entries(data.messages ?? {});
}

describe.each(BUNDLES)('%s — authored `messages` ids', (locale, data) => {
const ids = idsOf(data);

it('authors at least one message id', () => {
// Guards the whole file against the zero-population reading: every
// `it.each` below would pass vacuously on an empty `messages` block.
expect(ids.length).toBeGreaterThan(0);
});

it.each(ids.map(([id]) => id))('`%s` is single-segment', (id) => {
expect(
id.includes('.'),
`[${locale}] message id "${id}" contains a dot, so t('messages.${id}', '${locale}') walks into a nested object that does not exist and returns the key`,
).toBe(false);
});
});

describe.each(providers())('%s', (_providerName, provider) => {
describe.each(BUNDLES)('%s', (locale, data) => {
it.each(idsOf(data))('t("messages.%s") resolves', (id, expected) => {
expect(provider.t(`messages.${id}`, locale)).toBe(expected);
});
});

it('resolves a nested group key too (positive control on the walk itself)', () => {
expect(provider.t('objects.todo_task.label', 'en')).toBe('Task');
expect(provider.t('objects.todo_task.label', 'zh-CN')).toBe('任务');
});

it.each([
'messages.common.save',
'messages.success.saved',
'messages.error.load_failed',
])('the retired dotted spelling `%s` still resolves to nothing', (key) => {
// The control. `t()` returns the key string when the walk finds no leaf, so
// this is the exact symptom the dotted ids shipped with — and it is why the
// suite above is capable of going red if anyone re-introduces one.
expect(provider.t(key, 'en')).toBe(key);
});
});
40 changes: 21 additions & 19 deletions examples/app-todo/src/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,25 +98,27 @@ export const zhCN: TranslationData = {
description: '个人任务管理应用',
},
},
// Single-segment `messages` ids — `t()` walks the dot path, so an id
// containing a dot resolves to nothing; see the `en` bundle (#18566).
messages: {
'common.save': '保存',
'common.cancel': '取消',
'common.delete': '删除',
'common.edit': '编辑',
'common.create': '新建',
'common.search': '搜索',
'common.filter': '筛选',
'common.sort': '排序',
'common.refresh': '刷新',
'common.export': '导出',
'common.back': '返回',
'common.confirm': '确认',
'success.saved': '保存成功',
'success.deleted': '删除成功',
'success.completed': '任务已标记为完成',
'confirm.delete': '确定要删除此任务吗?',
'confirm.complete': '确定将此任务标记为完成?',
'error.required': '此字段为必填项',
'error.load_failed': '数据加载失败',
commonSave: '保存',
commonCancel: '取消',
commonDelete: '删除',
commonEdit: '编辑',
commonCreate: '新建',
commonSearch: '搜索',
commonFilter: '筛选',
commonSort: '排序',
commonRefresh: '刷新',
commonExport: '导出',
commonBack: '返回',
commonConfirm: '确认',
successSaved: '保存成功',
successDeleted: '删除成功',
successCompleted: '任务已标记为完成',
confirmDelete: '确定要删除此任务吗?',
confirmComplete: '确定将此任务标记为完成?',
errorRequired: '此字段为必填项',
errorLoadFailed: '数据加载失败',
},
};
Loading