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/.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/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" }, diff --git a/src/lib/create-resource-helpers.ts b/src/lib/create-resource-helpers.ts index 668fd32..6fb985a 100644 --- a/src/lib/create-resource-helpers.ts +++ b/src/lib/create-resource-helpers.ts @@ -114,8 +114,19 @@ 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, "\\'"); +} + /** * 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 + * `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 */ @@ -127,55 +138,78 @@ export function generateMsgResourceContent(params: { isEsm: boolean; }): string { 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 titleStr = `'${escapeSingleQuoted(title)}'`; + const langStr = `'${escapeSingleQuoted(sourceLocale)}'`; const dirStr = `'${dir}'`; + const resourceNote = `This is the ${escapeSingleQuoted(title)} resource.`; + const projectImport = `#i18n/projects/${escapeSingleQuoted(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 getMessages() { + 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 getMessages() { + return await resource.getTranslation(getLang()); +} + +module.exports = { + resource, + getMessages +} `; } diff --git a/src/lib/export-helpers.ts b/src/lib/export-helpers.ts index ab9c8b8..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"; @@ -78,14 +101,13 @@ 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 resource = resolveMsgResourceExport(mod); + 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/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 diff --git a/src/tests/create-resource-helpers.test.ts b/src/tests/create-resource-helpers.test.ts index e8735da..ffc2402 100644 --- a/src/tests/create-resource-helpers.test.ts +++ b/src/tests/create-resource-helpers.test.ts @@ -181,37 +181,44 @@ describe("create-resource-helpers", () => { }); 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 +233,7 @@ describe("create-resource-helpers", () => { expect(content).toContain("dir: 'rtl'"); }); - test("handles title with hyphens", () => { + test("always names the loader getMessages regardless of title", () => { const content = generateMsgResourceContent({ title: "my-messages", projectName: "app", @@ -235,6 +242,7 @@ describe("create-resource-helpers", () => { isEsm: true, }); expect(content).toContain("title: 'my-messages'"); + expect(content).toContain("export async function getMessages()"); }); test("handles short projectName and title", () => { @@ -246,7 +254,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 getMessages()"); }); test("escapes single quotes in title", () => { @@ -261,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", @@ -271,6 +291,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..ddef2df 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("getMessages"); }); 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("getMessages"); }); 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"); }); }); 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", () => {