From dfd8b2612b9aabef621fa90612e57409e30473b9 Mon Sep 17 00:00:00 2001 From: Joel Sahleen Date: Sat, 15 Aug 2026 19:55:13 -0600 Subject: [PATCH 1/4] implement: bump @worldware/msg to ^0.12.0 Pick up library dir defaulting and message attribute inheritance from msg #63 and #64 so CLI round-trips match. Refs #33 Co-authored-by: Cursor --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 89e8130..6419e37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@messageformat/icu-messageformat-1": "^0.12.0", "@messageformat/parser": "^5.1.1", "@oclif/core": "^3.21.2", - "@worldware/msg": "^0.11.0", + "@worldware/msg": "^0.12.0", "fast-xml-parser": "^5.3.4", "messageformat": "^4.0.0-10" }, @@ -2284,9 +2284,9 @@ } }, "node_modules/@worldware/msg": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@worldware/msg/-/msg-0.11.0.tgz", - "integrity": "sha512-ystMGAvpvPyoVpXzfFAcfy/t0SDAo4BY9YB1+w9NSXN6VV6JZFhL+dqhtvfNLyszz+Cs55pXpnzlnKzulnPeVw==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@worldware/msg/-/msg-0.12.0.tgz", + "integrity": "sha512-pLFpp6RE8BOgy3XQt/heV0KB68AUTZKUHY4Y/u1J0IGhOW+5aNUNs4KYwI3uYAQVNp8c3q4CoWeHCzbkW2k14g==", "license": "MIT", "dependencies": { "@messageformat/icu-messageformat-1": "^0.12.0", diff --git a/package.json b/package.json index 43311f7..9072845 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "@messageformat/icu-messageformat-1": "^0.12.0", "@messageformat/parser": "^5.1.1", "@oclif/core": "^3.21.2", - "@worldware/msg": "^0.11.0", + "@worldware/msg": "^0.12.0", "fast-xml-parser": "^5.3.4", "messageformat": "^4.0.0-10" }, From 2955012d95b7c3af80cc464a99d156b84472f1d8 Mon Sep 17 00:00:00 2001 From: Joel Sahleen Date: Sat, 15 Aug 2026 19:55:17 -0600 Subject: [PATCH 2/4] scaffold: add tests for dir auto on create, import, and export Cover loader fallback attributes and warn signature, missing trgDir import default, and omitting srcDir when dir is auto. Refs #33 Co-authored-by: Cursor --- src/tests/create-project.test.ts | 26 ++++++++++++++++++++++++++ src/tests/export-helpers.test.ts | 23 +++++++++++++++++++++++ src/tests/import-helpers.test.ts | 20 ++++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/src/tests/create-project.test.ts b/src/tests/create-project.test.ts index fd3efa8..b6de4a9 100644 --- a/src/tests/create-project.test.ts +++ b/src/tests/create-project.test.ts @@ -128,6 +128,32 @@ describe("CreateProject command", () => { expect(content).not.toContain("export default"); }); + test("loader fallback uses dir auto and warns without the error object (CJS)", async () => { + setupValidProject(tmp); + await CreateProject.run(["myApp", "en", "fr"], CLI_ROOT); + + const content = readFileSync(join(tmp, "i18n", "projects", "myApp.js"), "utf-8"); + expect(content).toContain("dir: 'auto'"); + expect(content).not.toContain("dir: ''"); + expect(content).toContain( + "console.warn(`Translations for locale ${language} could not be loaded.`);" + ); + expect(content).not.toContain("could not be loaded.`, error)"); + }); + + test("loader fallback uses dir auto and warns without the error object (ESM)", async () => { + setupValidProject(tmp, { type: "module" }); + await CreateProject.run(["myApp", "en", "fr"], CLI_ROOT); + + const content = readFileSync(join(tmp, "i18n", "projects", "myApp.js"), "utf-8"); + expect(content).toContain("dir: 'auto'"); + expect(content).not.toContain("dir: ''"); + expect(content).toContain( + "console.warn(`Translations for locale ${language} could not be loaded.`);" + ); + expect(content).not.toContain("could not be loaded.`, error)"); + }); + test("TypeScript project writes .js file", async () => { setupValidProject(tmp); writeFileSync(join(tmp, "tsconfig.json"), JSON.stringify({ compilerOptions: {} })); diff --git a/src/tests/export-helpers.test.ts b/src/tests/export-helpers.test.ts index 90a9e4d..3319a37 100644 --- a/src/tests/export-helpers.test.ts +++ b/src/tests/export-helpers.test.ts @@ -474,6 +474,29 @@ one {{One item}} expect(result[0].xliff).toContain('srcDir="rtl"'); }); + test("omits file srcDir when resource dir is auto", () => { + const res = createTestResource("R", "P", [{ key: "k1", value: "v1" }], { + attributes: { dir: "auto" }, + }); + const result = serializeResourceGroupsToXliff([ + { project: "P", resources: [res] }, + ]); + expect(result[0].xliff).not.toContain("srcDir"); + }); + + test("omits unit srcDir when message dir is auto", () => { + const res = createTestResource("R", "P", [ + { key: "k1", value: "v1", attributes: { dir: "auto" } }, + ], { + attributes: { dir: "ltr" }, + }); + const xliff = serializeResourceGroupsToXliff([ + { project: "P", resources: [res] }, + ])[0]!.xliff; + expect(xliff).toContain('srcDir="ltr"'); + expect(xliff).not.toMatch(/]*srcDir="auto"/); + }); + test("sets unit type from resolved message format", () => { const res = createTestResource( "R", diff --git a/src/tests/import-helpers.test.ts b/src/tests/import-helpers.test.ts index 7ee69ee..96c2cb5 100644 --- a/src/tests/import-helpers.test.ts +++ b/src/tests/import-helpers.test.ts @@ -499,6 +499,26 @@ one {{一}} expect(data.notes![0].content).toBe("File note"); }); + test("defaults resource dir to auto when file has no trgDir", () => { + const fileEl = { + "@_original": "R.json", + "@_trgLang": "zh", + unit: { + "@_id": "u1", + "@_name": "k1", + segment: { source: "S", target: "T" }, + }, + }; + const result = extractResourceFromXliffFile( + fileEl as unknown as Record, + "zh", + project, + ["zh"] + ); + expect(result).toBeInstanceOf(MsgResource); + expect(result!.attributes.dir).toBe("auto"); + }); + test("extracts from groups (nested structure)", () => { const fileEl = { "@_original": "Grouped.json", From dce50af11268b86b28367d56da3497e80e8f24db Mon Sep 17 00:00:00 2001 From: Joel Sahleen Date: Sat, 15 Aug 2026 19:55:23 -0600 Subject: [PATCH 3/4] implement: default dir to auto and omit it on XLIFF export Align create-project loaders, XLIFF import, and export with the library auto default (Option B: skip srcDir when dir is auto). Refs #33 Co-authored-by: Cursor --- src/commands/create/project.ts | 4 ++-- src/lib/export-helpers.ts | 4 ++-- src/lib/import-helpers.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/commands/create/project.ts b/src/commands/create/project.ts index 79d1c10..c031bbe 100644 --- a/src/commands/create/project.ts +++ b/src/commands/create/project.ts @@ -143,7 +143,7 @@ export default class CreateProject extends Command { const loaderPathLine = "const path = `${TRANSLATION_IMPORT_PATH}/${project}/${language}/${title}.json`;"; const loaderWarnLine = - "console.warn(`Translations for locale ${language} could not be loaded.`, error);"; + "console.warn(`Translations for locale ${language} could not be loaded.`);"; const loaderBody = `${loaderPathLine} try { const module = await import(path, { with: { type: 'json' } }); @@ -152,7 +152,7 @@ export default class CreateProject extends Command { ${loaderWarnLine} return { title, - attributes: { lang: language, dir: '' }, + attributes: { lang: language, dir: 'auto' }, notes: [], messages: [] }; diff --git a/src/lib/export-helpers.ts b/src/lib/export-helpers.ts index 33320d1..a42aaa6 100644 --- a/src/lib/export-helpers.ts +++ b/src/lib/export-helpers.ts @@ -269,7 +269,7 @@ function resourceGroupToXliff22(group: ResourceGroup): string { `id="${fileId}"`, `original="${escapeXml(orig)}"`, ]; - if (attrs.dir) { + if (attrs.dir && attrs.dir !== "auto") { fileAttrs.push(`srcDir="${escapeXml(attrs.dir)}"`); } if (attrs.dnt === true) { @@ -295,7 +295,7 @@ function resourceGroupToXliff22(group: ResourceGroup): string { if (msgAttr?.dnt === true) { msgAttrs.push('translate="no"'); } - if (msgAttr?.dir) { + if (msgAttr?.dir && msgAttr.dir !== "auto") { msgAttrs.push(`srcDir="${escapeXml(msgAttr.dir)}"`); } diff --git a/src/lib/import-helpers.ts b/src/lib/import-helpers.ts index 81a96dd..0c8ef27 100644 --- a/src/lib/import-helpers.ts +++ b/src/lib/import-helpers.ts @@ -381,7 +381,7 @@ export function extractResourceFromXliffFile( const attributes = { lang: trgLang, - dir: trgDir ?? "", + dir: trgDir ?? "auto", dnt, }; From 55b6c5d8e16f799af0ce44aa2d34cfc77b11d736 Mon Sep 17 00:00:00 2001 From: Joel Sahleen Date: Sat, 15 Aug 2026 19:55:28 -0600 Subject: [PATCH 4/4] document: align specs and README with dir auto defaults Keep create-project loader samples, import JSON examples, and export srcDir notes consistent with the library. Refs #33 Co-authored-by: Cursor --- README.md | 8 ++++---- src/specs/create-project-command.spec.md | 6 ++++-- src/specs/export-command.spec.md | 2 +- src/specs/import-command.spec.md | 1 + 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 52e9f77..e5f734d 100644 --- a/README.md +++ b/README.md @@ -232,9 +232,9 @@ msg export -p myApp - **Message keys** — Stored as unit `id` (sanitized for XML) and `name` (original key). - **Resource notes** — Emitted as file-level `` with category (e.g. `description`, `comment`). -- **Resource attributes** — `dir` → file `srcDir`; `dnt` → file `translate="no"`. +- **Resource attributes** — `dir` → file `srcDir` when it is not the default `"auto"`; `dnt` → file `translate="no"`. - **Message notes** — Emitted as unit-level `` with category (e.g. `description`, `context`, `parameters`). -- **Message attributes** — `dnt` → unit `translate="no"`; message `dir` is serialized as the unit’s `srcDir` attribute (XLIFF text direction for the segment). +- **Message attributes** — `dnt` → unit `translate="no"`; message `dir` is serialized as the unit’s `srcDir` unless it is `"auto"`. - **Message format** — Resolved `format` (`NONE` / `MF1` / `MF2`, including inheritance from resource/project) is written as the unit `type` attribute using the XLIFF custom form `msg:NONE`, `msg:MF1`, or `msg:MF2`. **Plural, gender, select (PGS):** Classifiable plural/select messages are exported to the [XLIFF 2.2 Plural, Gender, and Select module](https://docs.oasis-open.org/xliff/xliff-core/v2.2/xliff-extended-v2.2-part2.html) (`xmlns:pgs="urn:oasis:names:tc:xliff:pgs:1.0"`): `pgs:switch` on the ``, and one `` per variant with `pgs:case`. @@ -354,14 +354,14 @@ msg import # or: msg import --project myApp --language fr ``` -**5. Resulting French translation file** — `l10n/translations/myApp/fr/messages.json` (notes omitted; `attributes.dir` is empty unless the XLIFF `` carries `trgDir`): +**5. Resulting French translation file** — `l10n/translations/myApp/fr/messages.json` (notes omitted; `attributes.dir` is `"auto"` unless the XLIFF `` carries `trgDir`): ```json { "title": "messages", "attributes": { "lang": "fr", - "dir": "", + "dir": "auto", "dnt": false }, "messages": [ diff --git a/src/specs/create-project-command.spec.md b/src/specs/create-project-command.spec.md index afaf883..e536c04 100644 --- a/src/specs/create-project-command.spec.md +++ b/src/specs/create-project-command.spec.md @@ -34,12 +34,12 @@ const loader = async (project, title, language) => { const module = await import(path, { with: {type: 'json'}}); return module.default; } catch (error) { - console.warn(`Translations for locale ${language} could not be loaded.`, error); + console.warn(`Translations for locale ${language} could not be loaded.`); return { title, attributes: { lang: language, - dir: '' + dir: 'auto' }, notes: [], messages: [] @@ -94,6 +94,8 @@ When retrieving the path for the `i18n` and `l10n` directories from the package. - It should default `format` to `MF2` when `--format` is omitted and not inherited - It should inherit `format` from the base project when `--extend` is used and `--format` is omitted - It should always write `format` on the generated `project` settings object +- It should write `dir: 'auto'` in the loader fallback attributes +- It should warn when translations cannot be loaded without passing the caught error object - It should write an importable file. ### Constraints diff --git a/src/specs/export-command.spec.md b/src/specs/export-command.spec.md index 5441f6f..cfe6e44 100644 --- a/src/specs/export-command.spec.md +++ b/src/specs/export-command.spec.md @@ -125,7 +125,7 @@ Each unit’s resolved message `format` (`NONE` / `MF1` / `MF2`) is written as ` 2. Dynamically import the `MsgResource` objects using the array of file paths, and return an array of `MsgResource` objects. 3. Group the `MsgResource` objects by their associated `MsgProject` project name, and return an array of objects with type `{ project: string, resources: MsgResource[] }` 4. If the `--project [projectName]` filter out the objects where the `project` property does not match `projectName`. Return the filtered array. -5. Serialize each `resources` array to an XLIFF 2.0 string, and return an array of objects with type `{project: string, xliff: string}` +5. Serialize each `resources` array to an XLIFF 2.0 string, and return an array of objects with type `{project: string, xliff: string}`. Omit file/unit `srcDir` when `dir` is the default `"auto"`. 6. Iterate through the array of objects, writing each `xliff` string to a file in the `l10n/xliff` directory that has the `project` name as the filename. ## 7. Implementation diff --git a/src/specs/import-command.spec.md b/src/specs/import-command.spec.md index 0308cf9..7dc164f 100644 --- a/src/specs/import-command.spec.md +++ b/src/specs/import-command.spec.md @@ -137,6 +137,7 @@ Unit `type` values `msg:NONE` / `msg:MF1` / `msg:MF2` (or bare format tokens) ar - Iterate through the file objects in the parsed xliff object in order to create new translated `MsgResource` objects. - Use the `original` attribute of the file object to extract the `title` from the file name. - Use the `trgLang`, `trgDir`, and `translate` attributes to create an object of type `MsgAttributes`. + - Default `dir` to `"auto"` when the file has no `trgDir`. - If there is `notes` object directly inside the `file` object, use it to create an array of `MsgNote` objects. - Use `MsgResource.create` with the information just gathered to create a new resource with an empty `messages` array - Recursively iterate through the file object's `unit` and `group` objects (if any) in order to add messages: