From bda2716a505fd0a459947dd748eb17efe96c4b36 Mon Sep 17 00:00:00 2001 From: Joel Sahleen Date: Sat, 8 Aug 2026 18:14:46 -0600 Subject: [PATCH 1/8] scaffold: bump @worldware/msg to ^0.10.0 Need getLang/setLang and current note APIs for the updated create resource boilerplate. Refs #24 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 f8b336e..79920cb 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.8.1", + "@worldware/msg": "^0.10.0", "fast-xml-parser": "^5.3.4", "messageformat": "^4.0.0-10" }, @@ -1943,9 +1943,9 @@ } }, "node_modules/@worldware/msg": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@worldware/msg/-/msg-0.8.1.tgz", - "integrity": "sha512-7c0YTYSLCXIrCgAF/gl/3hwNwQ95Dqb8olQuwqbrQmkyP89K+yUtWJSNRPwLP6Um5oOmxk86D9C4xBHI0Vc/hQ==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@worldware/msg/-/msg-0.10.0.tgz", + "integrity": "sha512-XPtA3h0dT0SkTF6llxkHf7bQ9xSBYNpYfeXM8eMNN/KJbAIxjD0pev71ahG8h++NwYpBje2YgdhZXWLwG+rf4w==", "license": "MIT", "dependencies": { "@messageformat/icu-messageformat-1": "^0.12.0", diff --git a/package.json b/package.json index 5ae52e6..0700060 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.8.1", + "@worldware/msg": "^0.10.0", "fast-xml-parser": "^5.3.4", "messageformat": "^4.0.0-10" }, From f1b0129941c88fc733ac96196076cdd93800caa8 Mon Sep 17 00:00:00 2001 From: Joel Sahleen Date: Sat, 8 Aug 2026 18:14:46 -0600 Subject: [PATCH 2/8] scaffold: add failing tests for resource pre-translate boilerplate Expect #i18n imports, getLang, named resource export, and get() loader; stub resourceLoaderName for TDD. Refs #24 Co-authored-by: Cursor <cursoragent@cursor.com> --- src/lib/create-resource-helpers.ts | 16 +++++ src/tests/create-resource-helpers.test.ts | 79 ++++++++++++++++++----- src/tests/create-resource.test.ts | 50 ++++++++++---- 3 files changed, 116 insertions(+), 29 deletions(-) diff --git a/src/lib/create-resource-helpers.ts b/src/lib/create-resource-helpers.ts index 668fd32..a54950e 100644 --- a/src/lib/create-resource-helpers.ts +++ b/src/lib/create-resource-helpers.ts @@ -114,8 +114,22 @@ export async function importMsgProjectForResource( return undefined; } +/** + * Builds the async loader function name for a resource title (e.g. Messages → getMessages). + * @param title - Resource title + * @returns Valid JS identifier starting with `get` + */ +export function resourceLoaderName(title: string): string { + // Scaffold stub — implemented in phase 4 (Refs #24) + throw new Error("Not implemented"); +} + /** * Generates the MsgResource file content as a string. + * Emits ESM or CJS boilerplate that creates a MsgResource, adds sample + * messages via chainable `.add()`, and exports `resource` plus an async + * loader (`get<Title>`) that calls `resource.getTranslation(getLang())`. + * Project import uses the `#i18n/projects/<name>` alias from `msg init`. * @param params - Title, projectName, sourceLocale, dir, and isEsm * @returns The generated file content */ @@ -126,6 +140,8 @@ export function generateMsgResourceContent(params: { dir: "ltr" | "rtl"; isEsm: boolean; }): string { + // Scaffold: keep previous generator so unit tests for the new boilerplate fail (TDD). + // Replaced in phase 4 (Refs #24). const { title, projectName, sourceLocale, dir, isEsm } = params; const projectImport = isEsm ? `../projects/${projectName}.js` diff --git a/src/tests/create-resource-helpers.test.ts b/src/tests/create-resource-helpers.test.ts index e8735da..5aa37a9 100644 --- a/src/tests/create-resource-helpers.test.ts +++ b/src/tests/create-resource-helpers.test.ts @@ -9,6 +9,7 @@ import { readPackageJsonForCreateResource, importMsgProjectForResource, generateMsgResourceContent, + resourceLoaderName, writeMsgResourceFile, } from "../lib/create-resource-helpers.js"; @@ -180,38 +181,67 @@ describe("create-resource-helpers", () => { }); }); + describe("resourceLoaderName", () => { + test("derives getMessages from Messages", () => { + expect(resourceLoaderName("Messages")).toBe("getMessages"); + }); + + test("capitalizes first letter for lowercase titles", () => { + expect(resourceLoaderName("messages")).toBe("getMessages"); + }); + + test("camelCases hyphenated titles", () => { + expect(resourceLoaderName("my-messages")).toBe("getMyMessages"); + }); + + test("handles short titles", () => { + expect(resourceLoaderName("t")).toBe("getT"); + }); + + test("falls back when title has no identifier characters", () => { + expect(resourceLoaderName("---")).toBe("getResource"); + }); + }); + describe("generateMsgResourceContent", () => { - test("generates ESM content with correct structure", () => { + test("generates ESM content with getLang, named resource, and loader", () => { const content = generateMsgResourceContent({ - title: "messages", - projectName: "myProject", + title: "Messages", + projectName: "Main", sourceLocale: "en", dir: "ltr", isEsm: true, }); - expect(content).toContain("import { MsgResource } from '@worldware/msg'"); - expect(content).toContain("import project from '../projects/myProject.js'"); - expect(content).toContain("title: 'messages'"); + expect(content).toContain("import { MsgResource, getLang } from '@worldware/msg'"); + expect(content).toContain("import project from '#i18n/projects/Main'"); + expect(content).toContain("title: 'Messages'"); expect(content).toContain("lang: 'en'"); expect(content).toContain("dir: 'ltr'"); - expect(content).toContain("export default MsgResource.create"); + expect(content).toContain("export const resource = MsgResource.create"); + expect(content).toContain(".add('sampleKey'"); + expect(content).toContain(".add('sampleKey2'"); + expect(content).toContain("export async function getMessages()"); + expect(content).toContain("resource.getTranslation(getLang())"); + expect(content).not.toContain("export default"); expect(content).not.toContain("module.exports"); - expect(content).toContain("example.message"); - expect(content).toContain("Example message."); + expect(content).not.toContain("../projects/"); }); - test("generates CJS content with module.exports", () => { + test("generates CJS content with named exports object", () => { const content = generateMsgResourceContent({ - title: "messages", - projectName: "myProject", + title: "Messages", + projectName: "Main", sourceLocale: "en", dir: "ltr", isEsm: false, }); - expect(content).toContain("require('@worldware/msg')"); - expect(content).toContain("require('../projects/myProject')"); - expect(content).toContain("module.exports = MsgResource.create"); + expect(content).toContain("const { MsgResource, getLang } = require('@worldware/msg')"); + expect(content).toContain("require('#i18n/projects/Main')"); + expect(content).toContain("const resource = MsgResource.create"); + expect(content).toContain("async function getMessages()"); + expect(content).toContain("module.exports = {\n resource,\n getMessages\n}"); expect(content).not.toContain("export default"); + expect(content).not.toContain("export const resource"); expect(content).toContain("dir: 'ltr'"); }); @@ -226,7 +256,7 @@ describe("create-resource-helpers", () => { expect(content).toContain("dir: 'rtl'"); }); - test("handles title with hyphens", () => { + test("handles title with hyphens and matching loader name", () => { const content = generateMsgResourceContent({ title: "my-messages", projectName: "app", @@ -235,6 +265,7 @@ describe("create-resource-helpers", () => { isEsm: true, }); expect(content).toContain("title: 'my-messages'"); + expect(content).toContain("export async function getMyMessages()"); }); test("handles short projectName and title", () => { @@ -246,7 +277,8 @@ describe("create-resource-helpers", () => { isEsm: true, }); expect(content).toContain("title: 't'"); - expect(content).toContain("../projects/p.js"); + expect(content).toContain("import project from '#i18n/projects/p'"); + expect(content).toContain("export async function getT()"); }); test("escapes single quotes in title", () => { @@ -271,6 +303,19 @@ describe("create-resource-helpers", () => { }); expect(content).toMatch(/lang:\s*'[^']*'/); }); + + test("includes resource DESCRIPTION note derived from title", () => { + const content = generateMsgResourceContent({ + title: "Messages", + projectName: "Main", + sourceLocale: "en", + dir: "ltr", + isEsm: true, + }); + expect(content).toContain( + "{type: 'DESCRIPTION', content: 'This is the Messages resource.'}" + ); + }); }); describe("writeMsgResourceFile", () => { diff --git a/src/tests/create-resource.test.ts b/src/tests/create-resource.test.ts index 1195f70..06ef38e 100644 --- a/src/tests/create-resource.test.ts +++ b/src/tests/create-resource.test.ts @@ -28,14 +28,26 @@ function setupValidProject( projectContent?: string, skipProjectFile?: boolean ) { + const directories = { + i18n: "i18n", + l10n: "l10n", + root: ".", + ...((pkgOverrides.directories as Record<string, string> | undefined) ?? {}), + }; const pkg = { name: "test-app", version: "1.0.0", - directories: { i18n: "i18n", l10n: "l10n", root: "." }, + directories, + imports: { + "#i18n/*": `./${directories.i18n}/*`, + "#l10n/*": `./${directories.l10n}/*`, + "#root/*": "./*", + }, ...pkgOverrides, + directories, }; writeFileSync(join(tmp, "package.json"), JSON.stringify(pkg, null, 2)); - const i18nDir = join(tmp, (pkg.directories as { i18n: string }).i18n); + const i18nDir = join(tmp, directories.i18n); const projectsDir = join(i18nDir, "projects"); const resourcesDir = join(i18nDir, "resources"); for (const d of [projectsDir, resourcesDir]) { @@ -90,11 +102,13 @@ describe("CreateResource command", () => { const outPath = join(tmp, "i18n", "resources", "messages.msg.js"); expect(existsSync(outPath)).toBe(true); const content = readFileSync(outPath, "utf-8"); - expect(content).toContain("import { MsgResource } from '@worldware/msg'"); - expect(content).toContain("import project from '../projects/myProject.js'"); + expect(content).toContain("import { MsgResource, getLang } from '@worldware/msg'"); + expect(content).toContain("import project from '#i18n/projects/myProject'"); expect(content).toContain("title: 'messages'"); expect(content).toContain("dir: 'ltr'"); - expect(content).toContain("export default MsgResource.create"); + expect(content).toContain("export const resource = MsgResource.create"); + expect(content).toContain("export async function getMessages()"); + expect(content).not.toContain("export default"); }); test("creates resource file in CommonJS project", async () => { @@ -102,10 +116,12 @@ describe("CreateResource command", () => { await CreateResource.run(["myProject", "messages"], CLI_ROOT); const content = readFileSync(join(tmp, "i18n", "resources", "messages.msg.js"), "utf-8"); - expect(content).toContain("require('@worldware/msg')"); - expect(content).toContain("require('../projects/myProject')"); - expect(content).toContain("module.exports = MsgResource.create"); + expect(content).toContain("const { MsgResource, getLang } = require('@worldware/msg')"); + expect(content).toContain("require('#i18n/projects/myProject')"); + expect(content).toContain("async function getMessages()"); + expect(content).toContain("module.exports = {\n resource,\n getMessages\n}"); expect(content).toContain("dir: 'ltr'"); + expect(content).not.toContain("export default"); }); test("produces JavaScript file with ESM when tsconfig present", async () => { @@ -116,8 +132,9 @@ describe("CreateResource command", () => { expect(existsSync(join(tmp, "i18n", "resources", "messages.msg.js"))).toBe(true); expect(existsSync(join(tmp, "i18n", "resources", "messages.msg.ts"))).toBe(false); const content = readFileSync(join(tmp, "i18n", "resources", "messages.msg.js"), "utf-8"); - expect(content).toContain("import { MsgResource } from '@worldware/msg'"); - expect(content).toContain("export default MsgResource.create"); + expect(content).toContain("import { MsgResource, getLang } from '@worldware/msg'"); + expect(content).toContain("export const resource = MsgResource.create"); + expect(content).toContain("export async function getMessages()"); expect(content).not.toContain("module.exports"); }); @@ -176,6 +193,8 @@ describe("CreateResource command", () => { expect(existsSync(outPath)).toBe(true); const content = readFileSync(outPath, "utf-8"); expect(content).toContain("MsgResource.create"); + expect(content).toContain("getLang"); + expect(content).toContain("getMessages"); expect(content).toContain("title:"); expect(content).toContain("messages"); }); @@ -189,7 +208,7 @@ describe("CreateResource command", () => { const outPath = join(tmp, "i18n", "resources", "messages.msg.js"); expect(existsSync(outPath)).toBe(true); const content = readFileSync(outPath, "utf-8"); - expect(content).toContain("import project from '../projects/myApp.js'"); + expect(content).toContain("import project from '#i18n/projects/myApp'"); expect(content).toMatch(/lang:\s*['\"]en['\"]/); expect(content).toMatch(/dir:\s*['\"]ltr['\"]/); }); @@ -203,6 +222,7 @@ describe("CreateResource command", () => { expect(existsSync(join(tmp, "i18n", "resources", "my-messages.msg.js"))).toBe(true); const content = readFileSync(join(tmp, "i18n", "resources", "my-messages.msg.js"), "utf-8"); expect(content).toContain("title: 'my-messages'"); + expect(content).toContain("getMyMessages"); }); test("short projectName and title", async () => { @@ -218,7 +238,8 @@ describe("CreateResource command", () => { expect(existsSync(join(tmp, "i18n", "resources", "t.msg.js"))).toBe(true); const content = readFileSync(join(tmp, "i18n", "resources", "t.msg.js"), "utf-8"); expect(content).toContain("title: 't'"); - expect(content).toContain("../projects/p"); + expect(content).toContain("#i18n/projects/p"); + expect(content).toContain("getT"); }); test("custom i18n path", async () => { @@ -236,6 +257,11 @@ describe("CreateResource command", () => { await CreateResource.run(["myProject", "messages"], CLI_ROOT); expect(existsSync(join(tmp, "lib", "i18n", "resources", "messages.msg.js"))).toBe(true); + const content = readFileSync( + join(tmp, "lib", "i18n", "resources", "messages.msg.js"), + "utf-8" + ); + expect(content).toContain("#i18n/projects/myProject"); }); }); From 7e894f775e83bd58a231f3920369f3ff7718988e Mon Sep 17 00:00:00 2001 From: Joel Sahleen <joel@sahleen.net> Date: Sat, 8 Aug 2026 18:16:02 -0600 Subject: [PATCH 3/8] implement: generate pre-translate create resource boilerplate Emit ESM/CJS files with getLang, #i18n project imports, chainable .add() samples, named resource export, and get<Title>() loader. Refs #24 Co-authored-by: Cursor <cursoragent@cursor.com> --- src/lib/create-resource-helpers.ts | 121 +++++++++++++++++++---------- 1 file changed, 78 insertions(+), 43 deletions(-) diff --git a/src/lib/create-resource-helpers.ts b/src/lib/create-resource-helpers.ts index a54950e..9d4b541 100644 --- a/src/lib/create-resource-helpers.ts +++ b/src/lib/create-resource-helpers.ts @@ -114,14 +114,27 @@ export async function importMsgProjectForResource( return undefined; } +/** + * Escapes a string for use inside a single-quoted JS literal. + */ +function escapeSingleQuoted(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); +} + /** * Builds the async loader function name for a resource title (e.g. Messages → getMessages). * @param title - Resource title * @returns Valid JS identifier starting with `get` */ export function resourceLoaderName(title: string): string { - // Scaffold stub — implemented in phase 4 (Refs #24) - throw new Error("Not implemented"); + const parts = title.split(/[^a-zA-Z0-9]+/).filter(Boolean); + if (parts.length === 0) { + return "getResource"; + } + const pascal = parts + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); + return `get${pascal}`; } /** @@ -140,58 +153,80 @@ export function generateMsgResourceContent(params: { dir: "ltr" | "rtl"; isEsm: boolean; }): string { - // Scaffold: keep previous generator so unit tests for the new boilerplate fail (TDD). - // Replaced in phase 4 (Refs #24). const { title, projectName, sourceLocale, dir, isEsm } = params; - const projectImport = isEsm - ? `../projects/${projectName}.js` - : `../projects/${projectName}`; - const messagesBlock = ` messages: [ - { - key: 'example.message', - value: 'Example message.', - notes: [ - { type: 'DESCRIPTION', content: 'This is an example message. You can delete it.' } - ] - } - ]`; - - const titleStr = `'${title.replace(/'/g, "\\'")}'`; - const langStr = `'${sourceLocale.replace(/'/g, "\\'")}'`; + const loaderName = resourceLoaderName(title); + const titleStr = `'${escapeSingleQuoted(title)}'`; + const langStr = `'${escapeSingleQuoted(sourceLocale)}'`; const dirStr = `'${dir}'`; + const resourceNote = `This is the ${escapeSingleQuoted(title)} resource.`; + const projectImport = `#i18n/projects/${projectName}`; + + const createAndAdd = `MsgResource.create({ + title: ${titleStr}, + attributes: { + lang: ${langStr}, + dir: ${dirStr} + }, + notes: [ + {type: 'DESCRIPTION', content: '${resourceNote}'} + ] + }, project); + +/** + * Add messages to the resource using add(key, value, attributes, notes) + * The add method is chainable. + */ + +resource + .add('sampleKey', 'Sample value.', {}, [ + { type: 'DESCRIPTION', content: 'This is first message.' } + ]) + .add('sampleKey2', 'Hi, {name}', { dnt: true }, [ + { type: 'DESCRIPTION', content: 'This is the second message.' }, + { type: 'PARAMETERS', content: 'The {name} parameter holds the user name.' } + ]);`; if (isEsm) { - return `import { MsgResource } from '@worldware/msg'; + return `/** ESM module **/ + +import { MsgResource, getLang } from '@worldware/msg'; import project from '${projectImport}'; -export default MsgResource.create({ - title: ${titleStr}, - attributes: { - lang: ${langStr}, - dir: ${dirStr} - }, - notes: [ - { type: 'DESCRIPTION', content: 'This is a generated file. Replace this description with your own.' } - ], -${messagesBlock} -}, project); +/** Create a MsgResource object */ + +export const resource = ${createAndAdd} + +/** + * An async function to get a translated version of the resource + * If the runtime language has not been set using \`setLang()\`, + * it will return the original resource + */ +export async function ${loaderName}() { + return await resource.getTranslation(getLang()); +} `; } - return `const { MsgResource } = require('@worldware/msg'); + return `/** CJS implementation **/ + +const { MsgResource, getLang } = require('@worldware/msg'); const project = require('${projectImport}'); -module.exports = MsgResource.create({ - title: ${titleStr}, - attributes: { - lang: ${langStr}, - dir: ${dirStr} - }, - notes: [ - { type: 'DESCRIPTION', content: 'This is a generated file. Replace this description with your own.' } - ], -${messagesBlock} -}, project); +const resource = ${createAndAdd} + +/** + * An async function to get a translated version of the resource + * If a runtime language has not been set using \`setLang()\`, + * it will return the original resource + */ +async function ${loaderName}() { + return await resource.getTranslation(getLang()); +} + +module.exports = { + resource, + ${loaderName} +} `; } From 1e8b1f9a44e9d35b6bccaf42a849a3d1c36d96c6 Mon Sep 17 00:00:00 2001 From: Joel Sahleen <joel@sahleen.net> Date: Sat, 8 Aug 2026 18:17:23 -0600 Subject: [PATCH 4/8] implement: always name resource loader getMessages Loader name is fixed, not derived from the resource title. Refs #24 Co-authored-by: Cursor <cursoragent@cursor.com> --- src/lib/create-resource-helpers.ts | 25 +++---------------- src/tests/create-resource-helpers.test.ts | 30 +++-------------------- src/tests/create-resource.test.ts | 5 ++-- 3 files changed, 11 insertions(+), 49 deletions(-) diff --git a/src/lib/create-resource-helpers.ts b/src/lib/create-resource-helpers.ts index 9d4b541..8277f9d 100644 --- a/src/lib/create-resource-helpers.ts +++ b/src/lib/create-resource-helpers.ts @@ -121,27 +121,11 @@ function escapeSingleQuoted(value: string): string { return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); } -/** - * Builds the async loader function name for a resource title (e.g. Messages → getMessages). - * @param title - Resource title - * @returns Valid JS identifier starting with `get` - */ -export function resourceLoaderName(title: string): string { - const parts = title.split(/[^a-zA-Z0-9]+/).filter(Boolean); - if (parts.length === 0) { - return "getResource"; - } - const pascal = parts - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(""); - return `get${pascal}`; -} - /** * Generates the MsgResource file content as a string. * Emits ESM or CJS boilerplate that creates a MsgResource, adds sample * messages via chainable `.add()`, and exports `resource` plus an async - * loader (`get<Title>`) that calls `resource.getTranslation(getLang())`. + * `getMessages` loader that calls `resource.getTranslation(getLang())`. * Project import uses the `#i18n/projects/<name>` alias from `msg init`. * @param params - Title, projectName, sourceLocale, dir, and isEsm * @returns The generated file content @@ -154,7 +138,6 @@ export function generateMsgResourceContent(params: { isEsm: boolean; }): string { const { title, projectName, sourceLocale, dir, isEsm } = params; - const loaderName = resourceLoaderName(title); const titleStr = `'${escapeSingleQuoted(title)}'`; const langStr = `'${escapeSingleQuoted(sourceLocale)}'`; const dirStr = `'${dir}'`; @@ -201,7 +184,7 @@ export const resource = ${createAndAdd} * If the runtime language has not been set using \`setLang()\`, * it will return the original resource */ -export async function ${loaderName}() { +export async function getMessages() { return await resource.getTranslation(getLang()); } `; @@ -219,13 +202,13 @@ const resource = ${createAndAdd} * If a runtime language has not been set using \`setLang()\`, * it will return the original resource */ -async function ${loaderName}() { +async function getMessages() { return await resource.getTranslation(getLang()); } module.exports = { resource, - ${loaderName} + getMessages } `; } diff --git a/src/tests/create-resource-helpers.test.ts b/src/tests/create-resource-helpers.test.ts index 5aa37a9..359418e 100644 --- a/src/tests/create-resource-helpers.test.ts +++ b/src/tests/create-resource-helpers.test.ts @@ -9,7 +9,6 @@ import { readPackageJsonForCreateResource, importMsgProjectForResource, generateMsgResourceContent, - resourceLoaderName, writeMsgResourceFile, } from "../lib/create-resource-helpers.js"; @@ -181,28 +180,6 @@ describe("create-resource-helpers", () => { }); }); - describe("resourceLoaderName", () => { - test("derives getMessages from Messages", () => { - expect(resourceLoaderName("Messages")).toBe("getMessages"); - }); - - test("capitalizes first letter for lowercase titles", () => { - expect(resourceLoaderName("messages")).toBe("getMessages"); - }); - - test("camelCases hyphenated titles", () => { - expect(resourceLoaderName("my-messages")).toBe("getMyMessages"); - }); - - test("handles short titles", () => { - expect(resourceLoaderName("t")).toBe("getT"); - }); - - test("falls back when title has no identifier characters", () => { - expect(resourceLoaderName("---")).toBe("getResource"); - }); - }); - describe("generateMsgResourceContent", () => { test("generates ESM content with getLang, named resource, and loader", () => { const content = generateMsgResourceContent({ @@ -256,7 +233,7 @@ describe("create-resource-helpers", () => { expect(content).toContain("dir: 'rtl'"); }); - test("handles title with hyphens and matching loader name", () => { + test("always names the loader getMessages regardless of title", () => { const content = generateMsgResourceContent({ title: "my-messages", projectName: "app", @@ -265,7 +242,8 @@ describe("create-resource-helpers", () => { isEsm: true, }); expect(content).toContain("title: 'my-messages'"); - expect(content).toContain("export async function getMyMessages()"); + expect(content).toContain("export async function getMessages()"); + expect(content).not.toContain("getMyMessages"); }); test("handles short projectName and title", () => { @@ -278,7 +256,7 @@ describe("create-resource-helpers", () => { }); expect(content).toContain("title: 't'"); expect(content).toContain("import project from '#i18n/projects/p'"); - expect(content).toContain("export async function getT()"); + expect(content).toContain("export async function getMessages()"); }); test("escapes single quotes in title", () => { diff --git a/src/tests/create-resource.test.ts b/src/tests/create-resource.test.ts index 06ef38e..6561e5a 100644 --- a/src/tests/create-resource.test.ts +++ b/src/tests/create-resource.test.ts @@ -222,7 +222,8 @@ describe("CreateResource command", () => { expect(existsSync(join(tmp, "i18n", "resources", "my-messages.msg.js"))).toBe(true); const content = readFileSync(join(tmp, "i18n", "resources", "my-messages.msg.js"), "utf-8"); expect(content).toContain("title: 'my-messages'"); - expect(content).toContain("getMyMessages"); + expect(content).toContain("getMessages"); + expect(content).not.toContain("getMyMessages"); }); test("short projectName and title", async () => { @@ -239,7 +240,7 @@ describe("CreateResource command", () => { const content = readFileSync(join(tmp, "i18n", "resources", "t.msg.js"), "utf-8"); expect(content).toContain("title: 't'"); expect(content).toContain("#i18n/projects/p"); - expect(content).toContain("getT"); + expect(content).toContain("getMessages"); }); test("custom i18n path", async () => { From d2ab1583cd9ed2b5723416015bd9afc027e4d526 Mon Sep 17 00:00:00 2001 From: Joel Sahleen <joel@sahleen.net> Date: Sat, 8 Aug 2026 18:18:44 -0600 Subject: [PATCH 5/8] optimize: escape projectName in resource import path Also drop leftover getMyMessages negative assertions. Refs #24 Co-authored-by: Cursor <cursoragent@cursor.com> --- src/lib/create-resource-helpers.ts | 2 +- src/tests/create-resource-helpers.test.ts | 12 +++++++++++- src/tests/create-resource.test.ts | 1 - 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/lib/create-resource-helpers.ts b/src/lib/create-resource-helpers.ts index 8277f9d..6fb985a 100644 --- a/src/lib/create-resource-helpers.ts +++ b/src/lib/create-resource-helpers.ts @@ -142,7 +142,7 @@ export function generateMsgResourceContent(params: { const langStr = `'${escapeSingleQuoted(sourceLocale)}'`; const dirStr = `'${dir}'`; const resourceNote = `This is the ${escapeSingleQuoted(title)} resource.`; - const projectImport = `#i18n/projects/${projectName}`; + const projectImport = `#i18n/projects/${escapeSingleQuoted(projectName)}`; const createAndAdd = `MsgResource.create({ title: ${titleStr}, diff --git a/src/tests/create-resource-helpers.test.ts b/src/tests/create-resource-helpers.test.ts index 359418e..ffc2402 100644 --- a/src/tests/create-resource-helpers.test.ts +++ b/src/tests/create-resource-helpers.test.ts @@ -243,7 +243,6 @@ describe("create-resource-helpers", () => { }); expect(content).toContain("title: 'my-messages'"); expect(content).toContain("export async function getMessages()"); - expect(content).not.toContain("getMyMessages"); }); test("handles short projectName and title", () => { @@ -271,6 +270,17 @@ describe("create-resource-helpers", () => { expect(content).not.toMatch(/title: 'O'Brien'/); }); + test("escapes single quotes in projectName", () => { + const content = generateMsgResourceContent({ + title: "messages", + projectName: "O'Brien", + sourceLocale: "en", + dir: "ltr", + isEsm: true, + }); + expect(content).toContain("import project from '#i18n/projects/O\\'Brien'"); + }); + test("escapes single quotes in sourceLocale", () => { const content = generateMsgResourceContent({ title: "messages", diff --git a/src/tests/create-resource.test.ts b/src/tests/create-resource.test.ts index 6561e5a..ddef2df 100644 --- a/src/tests/create-resource.test.ts +++ b/src/tests/create-resource.test.ts @@ -223,7 +223,6 @@ describe("CreateResource command", () => { const content = readFileSync(join(tmp, "i18n", "resources", "my-messages.msg.js"), "utf-8"); expect(content).toContain("title: 'my-messages'"); expect(content).toContain("getMessages"); - expect(content).not.toContain("getMyMessages"); }); test("short projectName and title", async () => { From 92adda60f38f14bc23fded634f02a5b394cdae12 Mon Sep 17 00:00:00 2001 From: Joel Sahleen <joel@sahleen.net> Date: Sat, 8 Aug 2026 18:20:05 -0600 Subject: [PATCH 6/8] validate: import CJS named resource exports for export CJS module.exports = { resource } was misread via default interop; resolve MsgResource-like candidates so create resource output works with msg export. Refs #24 Co-authored-by: Cursor <cursoragent@cursor.com> --- src/lib/export-helpers.ts | 21 ++++++++-- src/tests/export-helpers.test.ts | 69 ++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/src/lib/export-helpers.ts b/src/lib/export-helpers.ts index ab9c8b8..4fa6023 100644 --- a/src/lib/export-helpers.ts +++ b/src/lib/export-helpers.ts @@ -64,6 +64,8 @@ export async function findMsgResourceFilePaths( /** * Dynamically imports MsgResource objects from an array of file paths. + * Accepts default export, named `resource` / `MsgResource`, or CJS + * `module.exports = { resource }` (where `default` is the exports object). * @param filePaths - Array of absolute paths to .msg.(ts|js) files * @returns Promise resolving to array of MsgResource instances * @throws Error if any file cannot be imported as a valid MsgResource @@ -78,14 +80,25 @@ export async function importMsgResourcesFromPaths( } const url = pathToFileURL(filePath).href; const mod = await dynamicImportFromUrl(url); - const resource: unknown = - mod.default ?? (mod as { resource?: unknown }).resource ?? (mod as { MsgResource?: unknown }).MsgResource; - if (!isMsgResourceLike(resource)) { + const defaultExport = mod.default; + const defaultRecord = + defaultExport && typeof defaultExport === "object" + ? (defaultExport as Record<string, unknown>) + : undefined; + const candidates: unknown[] = [ + defaultExport, + mod.resource, + mod.MsgResource, + defaultRecord?.resource, + defaultRecord?.MsgResource, + ]; + const resource = candidates.find(isMsgResourceLike); + if (!resource) { throw new Error( `Failed to import MsgResource from ${filePath}: no valid export found` ); } - result.push(resource as MsgResource); + result.push(resource); } return result; } diff --git a/src/tests/export-helpers.test.ts b/src/tests/export-helpers.test.ts index 9434d91..90a9e4d 100644 --- a/src/tests/export-helpers.test.ts +++ b/src/tests/export-helpers.test.ts @@ -166,6 +166,75 @@ describe("export-helpers", () => { const result = await importMsgResourcesFromPaths(twoPaths); expect(result).toHaveLength(2); }); + + test("imports MsgResource from named resource export (create resource shape)", async () => { + const tmp = join(tmpdir(), `msg-named-resource-${Date.now()}`); + mkdirSync(tmp, { recursive: true }); + const cliRoot = join(__dirname, "..", ".."); + try { + const { symlinkSync } = await import("fs"); + try { + const target = join(cliRoot, "node_modules"); + if (existsSync(target)) { + symlinkSync(target, join(tmp, "node_modules"), "dir"); + } + } catch { + // Skip if symlink fails (e.g. sandbox) + } + + writeFileSync( + join(tmp, "Named.msg.js"), + `const { MsgProject, MsgResource, getLang } = require('@worldware/msg'); +const project = MsgProject.create({ + project: { name: 'namedProj' }, + locales: { + sourceLocale: 'en', + pseudoLocale: 'en-XA', + targetLocales: { en: ['en'] }, + }, + loader: async () => ({ + title: '', + attributes: { lang: '', dir: '', dnt: false }, + messages: [], + }), +}); +const resource = MsgResource.create({ + title: 'Named', + attributes: { lang: 'en', dir: 'ltr' }, + notes: [{ type: 'DESCRIPTION', content: 'This is the Named resource.' }], +}, project); +resource.add('sampleKey', 'Sample value.', {}, [ + { type: 'DESCRIPTION', content: 'This is first message.' }, +]); +async function getMessages() { + return await resource.getTranslation(getLang()); +} +module.exports = { resource, getMessages }; +`, + "utf-8" + ); + + const result = await importMsgResourcesFromPaths([ + join(tmp, "Named.msg.js"), + ]); + expect(result).toHaveLength(1); + expect(result[0].title).toBe("Named"); + expect(result[0].getProject().project.name).toBe("namedProj"); + expect( + result[0].getData().messages?.some((m) => m.key === "sampleKey") + ).toBe(true); + + const { createRequire } = await import("module"); + const req = createRequire(join(tmp, "Named.msg.js")); + const mod = req(join(tmp, "Named.msg.js")) as { + getMessages: () => Promise<MsgResource>; + }; + const translated = await mod.getMessages(); + expect(translated.title).toBe("Named"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); }); describe("groupResourcesByProject", () => { From a4f67ce2343fa5c52ae2a2bcf52467a20f7387cd Mon Sep 17 00:00:00 2001 From: Joel Sahleen <joel@sahleen.net> Date: Sat, 8 Aug 2026 18:22:22 -0600 Subject: [PATCH 7/8] document: update create resource boilerplate docs Reflect named resource/getMessages exports, #i18n imports, always-.js output, and export named-export support. Refs #24 Co-authored-by: Cursor <cursoragent@cursor.com> --- README.md | 35 +++++++- src/specs/create-resource-command.spec.md | 104 ++++++++++++++-------- 2 files changed, 96 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 2ebb8de..743d9de 100644 --- a/README.md +++ b/README.md @@ -154,12 +154,39 @@ msg create resource myProject messages --edit **Behavior:** -- Writes the file to `i18n/resources/<title>.msg.js` (always `.js`). -- Uses ES module or CommonJS export syntax based on `package.json` `"type"` or presence of `tsconfig.json`. +- Writes the file to `i18n/resources/<title>.msg.js` (always `.js`, including TypeScript projects). +- Uses ES module or CommonJS syntax based on `package.json` `"type"` or presence of `tsconfig.json`. +- Imports the project via the `#i18n/projects/<projectName>` alias (added by `msg init`). - Sets `lang` from the project's `sourceLocale` and `dir` to `rtl` for Arabic/Hebrew, `ltr` otherwise. -- Includes a minimal example message. Validates that the generated file is importable. +- Exports a named `resource` plus an async `getMessages()` helper that returns `resource.getTranslation(getLang())`, so the resource can be pre-translated for the runtime locale. +- Includes sample messages added via chainable `.add()`. Validates that the generated file is importable. - Errors if i18n/projects or i18n/resources does not exist, the project is not found, or the resource file already exists (unless `--force`). +**Generated file (ESM excerpt):** + +```javascript +import { MsgResource, getLang } from '@worldware/msg'; +import project from '#i18n/projects/myProject'; + +export const resource = MsgResource.create({ /* title, attributes, notes */ }, project); + +resource + .add('sampleKey', 'Sample value.', {}, [/* notes */]) + .add('sampleKey2', 'Hi, {name}', { dnt: true }, [/* notes */]); + +export async function getMessages() { + return await resource.getTranslation(getLang()); +} +``` + +**Usage:** + +```javascript +import { getMessages } from '#i18n/resources/messages.msg.js'; + +const messages = await getMessages(); +``` + ### export Serialize all MsgResource files in `i18n/resources` to XLIFF 2.2 files in `l10n/xliff`, one file per project. Does not send files for translation; use your own translation workflow with the generated XLIFF. Requires `package.json` with `directories.i18n` and `directories.l10n` (run `msg init` first). @@ -187,7 +214,7 @@ msg export -p myApp **Behavior:** - Recursively finds all `.msg.js` and `.msg.ts` files under `i18n/resources`. -- Imports each file as a MsgResource; errors if any file is invalid. +- Imports each file as a MsgResource (default export, or named `resource` / `MsgResource`, including CJS `module.exports = { resource }`); errors if any file is invalid. - Groups resources by project name and writes one XLIFF 2.2 file per project to `l10n/xliff` (e.g. `myApp.xliff`). - With `--project`, only that project is exported; existing other files in `l10n/xliff` are not removed. - If no MsgResource files are found, exits with an informational message (no error). diff --git a/src/specs/create-resource-command.spec.md b/src/specs/create-resource-command.spec.md index 49c510a..fe55bfa 100644 --- a/src/specs/create-resource-command.spec.md +++ b/src/specs/create-resource-command.spec.md @@ -1,34 +1,57 @@ ## 1. Summary -The `create resource` command creates a `MsgResource` file in the `resources` subdirectory of the `i18n` created by the `init` command. Running the `init` command is a prerequisite to running the `create resource` command. An `MsgResource` file is a javascript or typescript file that exports a `MsgResource` instance. These files have `.msg.` right before the file extension, and are named after the resource `title`. For example, `messages.msg.js`. The `create resource` command should create a file that exports a minimal `MsgResource` instance, using the `projectName` and `title` arguments passed to the command. The `projectName` argument is used to reference the project file in the `i18n/projects` subdirectory. Project files use the project name as the file name, so the correct file to reference in the `MsgResource` import can be easily determined. The generated `MsgResource` file should look like approximately like this: +The `create resource` command creates a `MsgResource` file in the `resources` subdirectory of the `i18n` created by the `init` command. Running the `init` command is a prerequisite to running the `create resource` command. An `MsgResource` file is a JavaScript file that exports a `MsgResource` instance (and a `getMessages` loader). These files have `.msg.` right before the `.js` extension, and are named after the resource `title`. For example, `messages.msg.js`. The command always writes a `.js` file, even when the surrounding project uses TypeScript. + +The generated file uses the `projectName` and `title` arguments, imports the project via the `#i18n/projects/<projectName>` alias (configured by `msg init`), and scaffolds sample messages plus an async `getMessages()` helper that calls `resource.getTranslation(getLang())` so callers can load a pre-translated resource for the runtime locale. An ESM example: ```javascript -import { MsgResource } from '@worldware/msg'; -import project from '../projects/<projectName>'; - -export default MsgResource.create({ - title: <title>, - attributes: { - lang: <project.locales.sourceLocale>, - dir: <'rtl' or 'ltr'> - }, - notes: [ - { type: 'DESCRIPTION', content: 'This is a generated file. Replace this description with your own.'} - ], - messages: [ - { - key: 'example.message', - value: 'Example message.' - notes: [ - { type: 'DESCRIPTION', content 'This is an example message. You can delete it.' } - ] - } - ] -}, project); +/** ESM module **/ + +import { MsgResource, getLang } from '@worldware/msg'; +import project from '#i18n/projects/<projectName>'; + +/** Create a MsgResource object */ + +export const resource = MsgResource.create({ + title: '<title>', + attributes: { + lang: '<project.locales.sourceLocale>', + dir: "<'rtl' or 'ltr'>" + }, + notes: [ + {type: 'DESCRIPTION', content: 'This is the <title> resource.'} + ] + }, project); + +/** + * Add messages to the resource using add(key, value, attributes, notes) + * The add method is chainable. + */ + +resource + .add('sampleKey', 'Sample value.', {}, [ + { type: 'DESCRIPTION', content: 'This is first message.' } + ]) + .add('sampleKey2', 'Hi, {name}', { dnt: true }, [ + { type: 'DESCRIPTION', content: 'This is the second message.' }, + { type: 'PARAMETERS', content: 'The {name} parameter holds the user name.' } + ]); + +/** + * An async function to get a translated version of the resource + * If the runtime language has not been set using `setLang()`, + * it will return the original resource + */ +export async function getMessages() { + return await resource.getTranslation(getLang()); +} ``` -The terms in angle brackets `<>` above are variables to be replaced with the actual values. If the sourceLocale uses `ar` or `he` as the language subtag, set `dir` to `'rtl'`. Otherwise, it should be set to `''ltr'` by default. -The resulting file should be importable either as the default export of an ES module or as the main export of a CommonJS module. Which module format is used should depend on the module type being used in the surrounding project. If the project is using typescript, create a typescript file, but the content of that file would be no different from a javascript file. +CommonJS projects get the same structure with `require` / `module.exports = { resource, getMessages }`. + +The terms in angle brackets `<>` above are variables to be replaced with the actual values. If the sourceLocale uses `ar` or `he` as the language subtag, set `dir` to `'rtl'`. Otherwise, it should be set to `'ltr'` by default. + +Which module format is used depends on the surrounding project (`package.json` `"type": "module"` or TypeScript → ESM; otherwise CJS). The general order of operations for the command happy path should be as follows: @@ -37,7 +60,7 @@ The general order of operations for the command happy path should be as follows: 3. Import the project file associated with the `projectName` argument from the `i18n/projects` directory. 4. Retrieve the sourceLocale from the project `locales` settings. 5. Create a template string based on the code above that inserts the necessary variable values. -6. Write the string to file in the `i18n/resources` directory, using the pattern: `<title>.msg.<ext>` +6. Write the string to file in the `i18n/resources` directory, using the pattern: `<title>.msg.js` ## 2. Context @@ -49,6 +72,7 @@ The general order of operations for the command happy path should be as follows: - As a `Application Developer`, I want `to quickly scaffold a resource file`, so that `I do not have to do it myself`. - As a `Application Developer`, I want `the option to automatically open the file`, so that `I can quickly start editing it`. +- As a `Application Developer`, I want `the scaffolded resource to expose getMessages()`, so that `I can load a pre-translated resource for the runtime locale`. ## 3. Functionality @@ -56,6 +80,7 @@ The general order of operations for the command happy path should be as follows: - It `creates a MsgResource file in the i18n/resources directory` in order to `make it easy to start defining a MsgResource`. - It `associates every MsgResource with a MsgProject` in order to `pass on the project configuration to the resource and facilitate export to xliff`. +- It `exports resource and getMessages` in order to `support pre-translated loading via getLang()/getTranslation()`. ### Secondary Functions @@ -63,9 +88,9 @@ The general order of operations for the command happy path should be as follows: - It `imports the package.json file and gets the module type` in order to `determine what module type to use for the generated file` - It `retrieves the sourceLocale from the MsgProject instance` in order to `set the language on the MsgResource file` - It `tries to calculate the base direction (dir) based on the language subtag` in order to `set the direction on the MsgResource file` -- It `creates a template string for a minimal MsgResource file` in order to `produce the content for the MsgResource file` +- It `creates a template string for a MsgResource file with sample .add() messages and getMessages` in order to `produce the content for the MsgResource file` - It `determines the module type of the surrounding project` in order to `produce the correct export syntax for the generated content` -- It `writes the generated content to a file named after the title followed .msg.` in order to `persist the MsgResource` +- It `writes the generated content to a file named <title>.msg.js` in order to `persist the MsgResource` ## 4. Behavior @@ -75,8 +100,10 @@ The general order of operations for the command happy path should be as follows: - It `must` throw an error if the file cannot be generated. - It `must` validate that the file is valid and importable, throwing an error if it is not. - It `must` be able to work on different platforms. -- It `must` name the generated file using the `title` argument. -- It `should` produce a typescript file if a `tsconfig.json` file is present. +- It `must` name the generated file using the `title` argument with a `.msg.js` suffix. +- It `must` always write a `.js` file (not `.ts`), even when `tsconfig.json` is present. +- It `must` import the project via `#i18n/projects/<projectName>`. +- It `must` export a named `resource` and an async `getMessages()` loader. - It `should` error if the `i18n/projects` or `i18n/resources` directories do not exist and prompt to run the `init` command. - It `should` error if the `projectName` or `title` arguments are not provided. - It `should` provide an option to open the MsgResource file after it is created. @@ -212,18 +239,17 @@ The general order of operations for the command happy path should be as follows: - _[Create resource in ES module project]_ - Given: A project with `init` run, `package.json` with `"type": "module"`, and a project file `i18n/projects/myProject.js` - When: User runs `create resource myProject messages` - - Then: A file `i18n/resources/messages.msg.js` is created with valid MsgResource content, correct `title`, import from `../projects/myProject`, and default `dir: 'ltr'` for non-RTL sourceLocale. + - Then: A file `i18n/resources/messages.msg.js` is created with valid MsgResource content, correct `title`, import from `#i18n/projects/myProject`, named `resource` export, `getMessages()`, and default `dir: 'ltr'` for non-RTL sourceLocale. - _[Create resource in CommonJS project]_ - Given: A project with `init` run, `package.json` without `"type": "module"` (or `"type": "commonjs"`), and a project file in `i18n/projects` - When: User runs `create resource myProject messages` - - Then: A file `i18n/resources/messages.msg.cjs` (or appropriate CJS extension) is created with CommonJS-compatible export and valid MsgResource content. + - Then: A file `i18n/resources/messages.msg.js` is created with CommonJS `module.exports = { resource, getMessages }` and valid MsgResource content. -- _[Create resource produces TypeScript when tsconfig present]_ +- _[Create resource always writes .js even when tsconfig present]_ - Given: A project with `init` run, `tsconfig.json` at project root, and a project file in `i18n/projects` - When: User runs `create resource myProject messages` - - Then: A file `i18n/resources/messages.msg.ts` is created with valid MsgResource content and correct TypeScript syntax. - + - Then: A file `i18n/resources/messages.msg.js` is created (not `.ts`) with ESM-style `resource` and `getMessages` exports. - _[Create resource sets dir to RTL for Arabic sourceLocale]_ - Given: A project with `init` run and a project file whose `locales.sourceLocale` is `ar` or `ar-*` - When: User runs `create resource myProject messages` @@ -252,19 +278,19 @@ The general order of operations for the command happy path should be as follows: - _[Generated file is valid and importable]_ - Given: A project with `init` run and a project file in `i18n/projects` - When: User runs `create resource myProject messages` - - Then: The written file can be imported as default export (ES) or main export (CJS) and exports a valid MsgResource instance. + - Then: The written file exports a named `resource` (ESM `export const resource` or CJS `module.exports.resource`) plus `getMessages`, and `resource` is a valid MsgResource instance. #### Edge Cases - _[Title used as filename with safe naming]_ - Given: A project with `init` run and a project file in `i18n/projects` - When: User runs `create resource myProject my-messages` or `create resource myProject my_messages` - - Then: The file is created as `i18n/resources/my-messages.msg.<ext>` (or `my_messages.msg.<ext>`) with content `title: 'my-messages'` (or matching title). + - Then: The file is created as `i18n/resources/my-messages.msg.js` (or `my_messages.msg.js`) with content `title: 'my-messages'` (or matching title) and loader still named `getMessages`. - _[Project name matches exactly one project file]_ - Given: `i18n/projects/app.js` and `i18n/projects/other.js` exist - When: User runs `create resource app dashboard` - - Then: The generated file imports from `../projects/app` and no ambiguity error occurs. + - Then: The generated file imports from `#i18n/projects/app` and no ambiguity error occurs. - _[Source locale with compound tag still drives RTL]_ - Given: A project file with `locales.sourceLocale` set to `ar-SA` or `he-IL` @@ -279,7 +305,7 @@ The general order of operations for the command happy path should be as follows: - _[Short and minimal projectName and title]_ - Given: A project with `init` run - When: User runs `create resource p t` - - Then: A file `i18n/resources/t.msg.<ext>` is created with `title: 't'` and project import from `../projects/p`, and the file is valid. + - Then: A file `i18n/resources/t.msg.js` is created with `title: 't'` and project import from `#i18n/projects/p`, and the file is valid. #### Errors From 6451e47c88e84ea23e1f9d3ed6b5808c2724ce0f Mon Sep 17 00:00:00 2001 From: Joel Sahleen <joel@sahleen.net> Date: Sat, 8 Aug 2026 18:23:06 -0600 Subject: [PATCH 8/8] refactor: extract resolveMsgResourceExport helper Clarify named/default/CJS interop resolution used by export. Refs #24 Co-authored-by: Cursor <cursoragent@cursor.com> --- src/lib/export-helpers.ts | 39 ++++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/src/lib/export-helpers.ts b/src/lib/export-helpers.ts index 4fa6023..33320d1 100644 --- a/src/lib/export-helpers.ts +++ b/src/lib/export-helpers.ts @@ -36,6 +36,29 @@ function isMsgResourceLike(value: unknown): value is MsgResource { ); } +/** + * Picks a MsgResource from a dynamically imported module. + * Supports default export, named `resource` / `MsgResource`, and CJS + * `module.exports = { resource }` (where `default` is the exports object). + */ +function resolveMsgResourceExport( + mod: Record<string, unknown> +): MsgResource | undefined { + const defaultExport = mod.default; + const defaultRecord = + defaultExport && typeof defaultExport === "object" + ? (defaultExport as Record<string, unknown>) + : undefined; + const candidates: unknown[] = [ + defaultExport, + mod.resource, + mod.MsgResource, + defaultRecord?.resource, + defaultRecord?.MsgResource, + ]; + return candidates.find(isMsgResourceLike); +} + const XLIFF22_NS = "urn:oasis:names:tc:xliff:document:2.2"; const PGS_NS = "urn:oasis:names:tc:xliff:pgs:1.0"; @@ -64,8 +87,6 @@ export async function findMsgResourceFilePaths( /** * Dynamically imports MsgResource objects from an array of file paths. - * Accepts default export, named `resource` / `MsgResource`, or CJS - * `module.exports = { resource }` (where `default` is the exports object). * @param filePaths - Array of absolute paths to .msg.(ts|js) files * @returns Promise resolving to array of MsgResource instances * @throws Error if any file cannot be imported as a valid MsgResource @@ -80,19 +101,7 @@ export async function importMsgResourcesFromPaths( } const url = pathToFileURL(filePath).href; const mod = await dynamicImportFromUrl(url); - const defaultExport = mod.default; - const defaultRecord = - defaultExport && typeof defaultExport === "object" - ? (defaultExport as Record<string, unknown>) - : undefined; - const candidates: unknown[] = [ - defaultExport, - mod.resource, - mod.MsgResource, - defaultRecord?.resource, - defaultRecord?.MsgResource, - ]; - const resource = candidates.find(isMsgResourceLike); + const resource = resolveMsgResourceExport(mod); if (!resource) { throw new Error( `Failed to import MsgResource from ${filePath}: no valid export found`