diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md index a38a9a1..77354c0 100644 --- a/GETTING_STARTED.md +++ b/GETTING_STARTED.md @@ -70,6 +70,9 @@ Set `format` to `'MF1'`, `'MF2'`, or `'NONE'` on a project, resource, or message. It is a TypeScript union type (there is no enum), and lower levels inherit from higher levels unless they override it. +Message `lang`, `dir`, and `dnt` also inherit from the resource when omitted +(including in compact translation JSON). An explicit per-message value wins. + ```typescript // MF1 syntax (ICU MessageFormat 1) resource.add('files', '{count, plural, one {# file} other {# files}}', { format: 'MF1' }); diff --git a/README.md b/README.md index 704ed6a..1640e8b 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,8 @@ Every message is formatted according to its resolved `format` attribute: The `format` is inheritable: a resource inherits its project's `format` unless it sets its own, and a message inherits its resource's `format` unless it sets its own. The default is `MF2`, so existing code keeps working unchanged. Use a TypeScript union (`'MF1' | 'MF2' | 'NONE'`) — there is no enum. +`lang`, `dir`, and `dnt` inherit the same way on messages: omitted fields (or omitted `attributes`) take the resource's values, and an explicit per-message value still wins. Compact translation JSON can therefore list only overrides. `create()`, `add()`, `translate()`, and `getTranslation()` all apply this merge. + The following is a **separate illustration** of format inheritance and per-message overrides (not the CLI `Main` / `Messages` scaffold above): ```typescript diff --git a/src/classes/MsgResource/MsgResource.ts b/src/classes/MsgResource/MsgResource.ts index 73e5bc3..1ed5fd8 100644 --- a/src/classes/MsgResource/MsgResource.ts +++ b/src/classes/MsgResource/MsgResource.ts @@ -149,6 +149,9 @@ export class MsgResource extends Map implements MsgInterface /** * Adds a message to this resource, merging resource attributes with any overrides. * + * Unspecified `lang`, `dir`, and `dnt` inherit from this resource; explicit + * values on the message win. + * * @returns This resource, for chaining. */ public add(key: string, value: string, attributes?: MsgAttributes, notes?: MsgNote[]) { @@ -169,7 +172,8 @@ export class MsgResource extends Map implements MsgInterface * Builds a translated copy of this resource from translation data. * * Messages present in the translation replace the source; missing keys keep - * the source message. Notes are carried over from the source. + * the source message. Notes are carried over from the source. Unspecified + * message `lang`, `dir`, and `dnt` inherit from the translated resource. * * @throws {TypeError} When the translation title does not match this resource. */ @@ -194,17 +198,16 @@ export class MsgResource extends Map implements MsgInterface messages?.forEach(messageData => { const {key, value, attributes} = messageData; - const msg = MsgMessage.create({ + const source = this.get(key); + // Inherit lang/dir/dnt from the translated resource (same merge as add()), + // while still preserving the source message's format when the translation + // does not set one. + translated.add( key, value, - // preserve the source message's format unless the translation overrides it - attributes: this.preserveFormat(attributes, this.get(key)?.attributes.format), - }); - const notes = this.get(key)?.notes || []; // transfer the notes - notes.forEach(note => { - msg.addNote(note.type, note.content); - }) - translated.set(key, msg); + this.preserveFormat(attributes, source?.attributes.format), + source?.notes + ); }) return translated; diff --git a/src/tests/MsgResource.test.ts b/src/tests/MsgResource.test.ts index aa3f26a..091d5c5 100644 --- a/src/tests/MsgResource.test.ts +++ b/src/tests/MsgResource.test.ts @@ -71,6 +71,37 @@ describe('MsgResource tests', () => { expect(resource.has('test-2')).toBe(true); expect(resource.get('test-1')?.value).toBe('This is test 1'); expect(resource.get('test-2')?.value).toBe('This is test 2'); + expect(resource.get('test-1')?.attributes.lang).toBe('en'); + expect(resource.get('test-1')?.attributes.dir).toBe('ltr'); + expect(resource.get('test-1')?.attributes.dnt).toBe(false); + }); + + test('MsgResource: "create" inherits unspecified message lang, dir, and dnt from the resource', () => { + const project = MsgProject.create(testProjectData); + const resource = MsgResource.create({ + title: 'TestResource', + attributes: { lang: 'en', dir: 'rtl', dnt: true }, + messages: [ + { key: 'omitted', value: 'No attributes' }, + { key: 'dnt-only', value: 'DNT override', attributes: { dnt: false } }, + { key: 'lang-only', value: 'Lang override', attributes: { lang: 'fr' } } + ] + }, project); + + const omitted = resource.get('omitted'); + expect(omitted?.attributes.lang).toBe('en'); + expect(omitted?.attributes.dir).toBe('rtl'); + expect(omitted?.attributes.dnt).toBe(true); + + const dntOnly = resource.get('dnt-only'); + expect(dntOnly?.attributes.lang).toBe('en'); + expect(dntOnly?.attributes.dir).toBe('rtl'); + expect(dntOnly?.attributes.dnt).toBe(false); + + const langOnly = resource.get('lang-only'); + expect(langOnly?.attributes.lang).toBe('fr'); + expect(langOnly?.attributes.dir).toBe('rtl'); + expect(langOnly?.attributes.dnt).toBe(true); }); test('MsgResource: "create" static method with notes', () => { @@ -549,6 +580,44 @@ describe('MsgResource tests', () => { expect(msg?.attributes.dnt).toBe(true); }); + test('MsgResource: "translate" inherits unspecified message lang, dir, and dnt from the translated resource', () => { + const project = MsgProject.create(testProjectData); + const original = MsgResource.create({ + title: 'TestResource', + attributes: { lang: 'en', dir: 'ltr', dnt: false }, + messages: [ + { key: 'omitted', value: 'Original omitted' }, + { key: 'dnt-only', value: 'Original dnt' }, + { key: 'lang-only', value: 'Original lang' } + ] + }, project); + + const translated = original.translate({ + title: 'TestResource', + attributes: { lang: 'zh', dir: 'rtl', dnt: true }, + messages: [ + { key: 'omitted', value: '翻译 omitted' }, + { key: 'dnt-only', value: '翻译 dnt', attributes: { dnt: false } }, + { key: 'lang-only', value: '翻译 lang', attributes: { lang: 'fr' } } + ] + }); + + const omitted = translated.get('omitted'); + expect(omitted?.attributes.lang).toBe('zh'); + expect(omitted?.attributes.dir).toBe('rtl'); + expect(omitted?.attributes.dnt).toBe(true); + + const dntOnly = translated.get('dnt-only'); + expect(dntOnly?.attributes.lang).toBe('zh'); + expect(dntOnly?.attributes.dir).toBe('rtl'); + expect(dntOnly?.attributes.dnt).toBe(false); + + const langOnly = translated.get('lang-only'); + expect(langOnly?.attributes.lang).toBe('fr'); + expect(langOnly?.attributes.dir).toBe('rtl'); + expect(langOnly?.attributes.dnt).toBe(true); + }); + test('MsgResource: "translate" method transfers notes to new messages', () => { const project = MsgProject.create(testProjectData); const original = MsgResource.create({ @@ -609,6 +678,50 @@ describe('MsgResource tests', () => { expect(translated.size).toBe(2); expect(translated.get('test-1')?.value).toBe('这是测试 1'); expect(translated.get('test-2')?.value).toBe('这是测试 2'); + expect(translated.get('test-1')?.attributes.lang).toBe('zh'); + expect(translated.get('test-1')?.attributes.dir).toBe('ltr'); + expect(translated.get('test-1')?.attributes.dnt).toBe(false); + }); + + test('MsgResource: "getTranslation" inherits unspecified message lang, dir, and dnt from the translated resource', async () => { + const project = MsgProject.create({ + ...testProjectData, + loader: async () => ({ + title: 'TestResource', + attributes: { lang: 'zh', dir: 'rtl', dnt: true }, + messages: [ + { key: 'omitted', value: '翻译 omitted' }, + { key: 'dnt-only', value: '翻译 dnt', attributes: { dnt: false } }, + { key: 'lang-only', value: '翻译 lang', attributes: { lang: 'fr' } } + ] + }) + }); + const resource = MsgResource.create({ + title: 'TestResource', + attributes: { lang: 'en', dir: 'ltr', dnt: false }, + messages: [ + { key: 'omitted', value: 'Original omitted' }, + { key: 'dnt-only', value: 'Original dnt' }, + { key: 'lang-only', value: 'Original lang' } + ] + }, project); + + const translated = await resource.getTranslation('zh'); + + const omitted = translated.get('omitted'); + expect(omitted?.attributes.lang).toBe('zh'); + expect(omitted?.attributes.dir).toBe('rtl'); + expect(omitted?.attributes.dnt).toBe(true); + + const dntOnly = translated.get('dnt-only'); + expect(dntOnly?.attributes.lang).toBe('zh'); + expect(dntOnly?.attributes.dir).toBe('rtl'); + expect(dntOnly?.attributes.dnt).toBe(false); + + const langOnly = translated.get('lang-only'); + expect(langOnly?.attributes.lang).toBe('fr'); + expect(langOnly?.attributes.dir).toBe('rtl'); + expect(langOnly?.attributes.dnt).toBe(true); }); test('MsgResource: "getTranslation" method without lang clones the resource', async () => {