From 8bb908e5c57bc0bc94387a117e876a03a53e0543 Mon Sep 17 00:00:00 2001 From: Joel Sahleen Date: Sat, 15 Aug 2026 19:35:14 -0600 Subject: [PATCH 1/3] scaffold: add tests for empty and omitted dir defaulting Empty-string dir currently overrides DEFAULT_ATTRIBUTES.dir, and standalone messages skip defaults entirely. Capture the intended auto default before implementation. Refs #63 Co-authored-by: Cursor --- src/tests/MsgMessage.test.ts | 47 +++++++++++++++++++++++++++++---- src/tests/MsgResource.test.ts | 49 +++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/src/tests/MsgMessage.test.ts b/src/tests/MsgMessage.test.ts index c8159f1..09c5478 100644 --- a/src/tests/MsgMessage.test.ts +++ b/src/tests/MsgMessage.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest'; import { MsgMessage } from '../classes/MsgMessage/MsgMessage.js'; -import { MsgNote } from '../classes/MsgInterface/MsgInterface.js'; +import { DEFAULT_ATTRIBUTES, MsgNote } from '../classes/MsgInterface/MsgInterface.js'; describe('MsgMessage tests', () => { @@ -13,7 +13,8 @@ describe('MsgMessage tests', () => { expect(msg1.key).toBe('my-key'); expect(msg1.value).toBe('My Value'); - expect(msg1.attributes).toStrictEqual({}); + expect(msg1.attributes).toStrictEqual(DEFAULT_ATTRIBUTES); + expect(msg1.attributes.dir).toBe('auto'); expect(msg1.notes).toStrictEqual([]); }); @@ -65,7 +66,7 @@ describe('MsgMessage tests', () => { expect(msg4.key).toBe('my-key'); expect(msg4.value).toBe('My Value'); - expect(msg4.attributes).toStrictEqual({}); + expect(msg4.attributes).toStrictEqual(DEFAULT_ATTRIBUTES); expect(msg4.notes).toStrictEqual([{type: 'DESCRIPTION', content: 'This is a test description'}]); expect(msg4.notes.length).toBe(1); @@ -150,13 +151,49 @@ describe('MsgMessage tests', () => { value: 'My Value' }); - expect(msg5.attributes).toStrictEqual({}); + expect(msg5.attributes).toStrictEqual(DEFAULT_ATTRIBUTES); + }); + + test('Create with empty-string dir normalizes to auto', () => { + const msg = MsgMessage.create({ + key: 'my-key', + value: 'My Value', + attributes: { + lang: 'en', + dir: '' + } + }); + + expect(msg.attributes.lang).toBe('en'); + expect(msg.attributes.dir).toBe('auto'); + }); + + test('Create with explicit dir is preserved', () => { + const ltr = MsgMessage.create({ + key: 'ltr-key', + value: 'Left', + attributes: { dir: 'ltr' } + }); + const rtl = MsgMessage.create({ + key: 'rtl-key', + value: 'Right', + attributes: { dir: 'rtl' } + }); + const auto = MsgMessage.create({ + key: 'auto-key', + value: 'Auto', + attributes: { dir: 'auto' } + }); + + expect(ltr.attributes.dir).toBe('ltr'); + expect(rtl.attributes.dir).toBe('rtl'); + expect(auto.attributes.dir).toBe('auto'); }); const output = JSON.stringify({ "key": "my-key", "value": "My Value", - "attributes": {} + "attributes": DEFAULT_ATTRIBUTES }, null, 2); test('Test generic functions', () => { diff --git a/src/tests/MsgResource.test.ts b/src/tests/MsgResource.test.ts index d5aaa1b..aa3f26a 100644 --- a/src/tests/MsgResource.test.ts +++ b/src/tests/MsgResource.test.ts @@ -152,6 +152,35 @@ describe('MsgResource tests', () => { expect(resource.attributes.dir).toBe('auto'); // default value }); + test('MsgResource: "create" normalizes empty-string dir to auto', () => { + const project = MsgProject.create(testProjectData); + const resource = MsgResource.create({ + title: 'TestResource', + attributes: { + lang: 'en', + dir: '' + } + }, project); + + expect(resource.attributes.lang).toBe('en'); + expect(resource.attributes.dir).toBe('auto'); + }); + + test('MsgResource: "create" preserves explicit dir', () => { + const project = MsgProject.create(testProjectData); + const ltr = MsgResource.create({ + title: 'LtrResource', + attributes: { lang: 'en', dir: 'ltr' } + }, project); + const rtl = MsgResource.create({ + title: 'RtlResource', + attributes: { lang: 'ar', dir: 'rtl' } + }, project); + + expect(ltr.attributes.dir).toBe('ltr'); + expect(rtl.attributes.dir).toBe('rtl'); + }); + test('MsgResource: title getter and setter', () => { const project = MsgProject.create(testProjectData); const resource = MsgResource.create({ @@ -197,6 +226,10 @@ describe('MsgResource tests', () => { expect(resource.attributes.lang).toBe('fr'); expect(resource.attributes.dir).toBe('rtl'); expect(resource.attributes.dnt).toBe(true); + + resource.attributes = { lang: 'de', dir: '' }; + expect(resource.attributes.lang).toBe('de'); + expect(resource.attributes.dir).toBe('auto'); }); test('MsgResource: notes getter and setter', () => { @@ -279,6 +312,22 @@ describe('MsgResource tests', () => { expect(message?.attributes.dnt).toBe(true); // overridden }); + test('MsgResource: "add" method normalizes empty-string dir override to auto', () => { + const project = MsgProject.create(testProjectData); + const resource = MsgResource.create({ + title: 'TestResource', + attributes: { + lang: 'en', + dir: 'ltr' + } + }, project); + + resource.add('test-key', 'Test Value', { dir: '' }); + + expect(resource.get('test-key')?.attributes.dir).toBe('auto'); + expect(resource.get('test-key')?.attributes.lang).toBe('en'); + }); + test('MsgResource: "add" method with notes', () => { const project = MsgProject.create(testProjectData); const resource = MsgResource.create({ From d1416404f14c8e2a917cf378ab3add323f439c19 Mon Sep 17 00:00:00 2001 From: Joel Sahleen Date: Sat, 15 Aug 2026 19:35:19 -0600 Subject: [PATCH 2/3] implement: normalize empty dir to auto on resources and messages Spread-merging let dir: '' win over DEFAULT_ATTRIBUTES. Centralize attribute defaults so omitted or empty dir always becomes auto. Refs #63 Co-authored-by: Cursor --- src/classes/MsgMessage/MsgMessage.ts | 7 ++++--- src/classes/MsgResource/MsgResource.ts | 7 ++++--- src/lib/apply-attributes.ts | 18 ++++++++++++++++++ 3 files changed, 26 insertions(+), 6 deletions(-) create mode 100644 src/lib/apply-attributes.ts diff --git a/src/classes/MsgMessage/MsgMessage.ts b/src/classes/MsgMessage/MsgMessage.ts index 8badb24..1f5734f 100644 --- a/src/classes/MsgMessage/MsgMessage.ts +++ b/src/classes/MsgMessage/MsgMessage.ts @@ -1,6 +1,7 @@ import { MessageFormat, type MessageFormatOptions } from "messageformat"; import { mf1ToMessage } from "@messageformat/icu-messageformat-1"; -import { MsgInterface, DEFAULT_ATTRIBUTES, MSG_DEFAULT_FORMAT, type MsgAttributes, type MsgFormat, type MsgNote, type NoteTypes } from "../MsgInterface/MsgInterface.js"; +import { MsgInterface, MSG_DEFAULT_FORMAT, type MsgAttributes, type MsgFormat, type MsgNote, type NoteTypes } from "../MsgInterface/MsgInterface.js"; +import { applyAttributes } from "../../lib/apply-attributes.js"; /** * Plain data used to create a {@link MsgMessage}. @@ -35,8 +36,8 @@ export class MsgMessage implements MsgInterface { this._key = key; this._value = value; - // merge in any attributes - this._attributes = attributes ? {...DEFAULT_ATTRIBUTES, ...attributes} : {}; + // Always apply library defaults so omitted or empty `dir` becomes `'auto'`. + this._attributes = applyAttributes(attributes); // add any notes if (notes) { diff --git a/src/classes/MsgResource/MsgResource.ts b/src/classes/MsgResource/MsgResource.ts index 4f301dc..73e5bc3 100644 --- a/src/classes/MsgResource/MsgResource.ts +++ b/src/classes/MsgResource/MsgResource.ts @@ -1,5 +1,6 @@ import { type MsgMessageData, MsgMessage } from "../MsgMessage/MsgMessage.js"; -import { DEFAULT_ATTRIBUTES, MSG_DEFAULT_FORMAT, MsgInterface, type MsgAttributes, type MsgNote, type NoteTypes } from "../MsgInterface/MsgInterface.js"; +import { MSG_DEFAULT_FORMAT, MsgInterface, type MsgAttributes, type MsgNote, type NoteTypes } from "../MsgInterface/MsgInterface.js"; +import { applyAttributes } from "../../lib/apply-attributes.js"; import { MsgProject } from "../MsgProject/MsgProject.js"; import { pseudoLocalize } from "../../lib/pseudo-localize.js"; @@ -61,7 +62,7 @@ export class MsgResource extends Map implements MsgInterface this._title = title; // Inherit the project's format unless the resource specifies its own. - this._attributes = {...DEFAULT_ATTRIBUTES, format: project.format, ...attributes}; + this._attributes = applyAttributes(attributes, { format: project.format }); this._project = project; if (notes) { @@ -96,7 +97,7 @@ export class MsgResource extends Map implements MsgInterface /** Replaces the resource's locale and formatting metadata. */ public set attributes(attributes: MsgAttributes) { - this._attributes = attributes; + this._attributes = applyAttributes(attributes); } /** Notes attached to this resource for translators and tooling. */ diff --git a/src/lib/apply-attributes.ts b/src/lib/apply-attributes.ts new file mode 100644 index 0000000..fd1d7bf --- /dev/null +++ b/src/lib/apply-attributes.ts @@ -0,0 +1,18 @@ +import { DEFAULT_ATTRIBUTES, type MsgAttributes } from '../classes/MsgInterface/MsgInterface.js'; + +/** + * Merges {@link DEFAULT_ATTRIBUTES} with optional extras and caller attributes. + * + * An empty-string `dir` is treated as unset and becomes `'auto'`. This helper + * is module-internal (not re-exported from the public API). + */ +export function applyAttributes( + attributes?: MsgAttributes, + extras?: MsgAttributes +): MsgAttributes { + const merged: MsgAttributes = { ...DEFAULT_ATTRIBUTES, ...extras, ...attributes }; + if (!merged.dir) { + merged.dir = 'auto'; + } + return merged; +} From 6f6378be6ec88da490a23343e2ccbfee8c36268e Mon Sep 17 00:00:00 2001 From: Joel Sahleen Date: Sat, 15 Aug 2026 19:35:23 -0600 Subject: [PATCH 3/3] document: show dir auto in loader fallback and attribute docs Keep copy-paste samples aligned with the library default so empty string dir does not reappear in new projects. Refs #63 Co-authored-by: Cursor --- README.md | 4 ++-- src/classes/MsgInterface/MsgInterface.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index dc77cf6..704ed6a 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ const loader = async (project, title, language) => { console.warn(`Translations for locale ${language} could not be loaded.`, error); return { title, - attributes: { lang: language, dir: '' }, + attributes: { lang: language, dir: 'auto' }, notes: [], messages: [] }; @@ -332,7 +332,7 @@ const data = resource.getData(); ### Types - `MsgFormat` - `'MF1' | 'MF2' | 'NONE'`; the formatting syntax for a message. -- `MsgAttributes` - `{ lang?: string; dir?: string; dnt?: boolean; format?: MsgFormat }`. +- `MsgAttributes` - `{ lang?: string; dir?: string; dnt?: boolean; format?: MsgFormat }`. Omitted or empty `dir` resolves to `'auto'`. ### Pseudo-localization helpers diff --git a/src/classes/MsgInterface/MsgInterface.ts b/src/classes/MsgInterface/MsgInterface.ts index 3a74ae4..fde86e7 100644 --- a/src/classes/MsgInterface/MsgInterface.ts +++ b/src/classes/MsgInterface/MsgInterface.ts @@ -34,7 +34,7 @@ export const MSG_DEFAULT_FORMAT: MsgFormat = 'MF2'; export type MsgAttributes = { /** BCP 47 language tag for the content (for example, `en` or `zh-Hans`). */ lang?: string - /** Text direction: typically `ltr`, `rtl`, or `auto`. */ + /** Text direction: typically `ltr`, `rtl`, or `auto`. Omitted or empty values resolve to `'auto'`. */ dir?: string /** When true, the content should not be translated (Do Not Translate). */ dnt?: boolean