diff --git a/README.md b/README.md index 1b44bcb..52e9f77 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ msg init -i Create a new MsgProject file in the i18n projects directory. Requires `package.json` with `directories.i18n` and `directories.l10n` (run `msg init` first). ```bash -msg create project [source] [targets...] [--extend ] +msg create project [source] [targets...] [--extend ] [--format ] ``` | Argument | Required | Description | @@ -93,17 +93,24 @@ msg create project [source] [targets...] [--extend ] | Flag | Short | Description | |------------|-------|--------------------------------| | `--extend` | `-e` | Extend an existing project. | +| `--format` | `-f` | Default message format: `MF1`, `MF2`, or `NONE` (default `MF2`). When omitted with `--extend`, inherits the base project's format. | | `--help` | `-h` | Show help for create project. | **Examples:** ```bash -# Create project myApp with source en and targets fr, de +# Create project myApp with source en and targets fr, de (format defaults to MF2) msg create project myApp en fr de -# Extend an existing project (inherits source and targets from base) +# Create with MessageFormat 1 as the project default +msg create project myApp en fr -f MF1 + +# Extend an existing project (inherits source, targets, and format from base) msg create project extendedApp --extend base +# Extend and override format +msg create project extendedApp --extend base --format NONE + # Extend and add/override locales msg create project extendedApp en de --extend base @@ -115,10 +122,11 @@ msg create project -h - Writes the file to `i18n/projects/.js` (always `.js`). - Uses ES module or CommonJS export syntax based on `package.json` `"type"` or presence of `tsconfig.json`. +- Always includes `format` on `project` settings in the generated file (`MF2` by default). - Generates a translation loader that imports from `l10n/translations` using the relative path from `i18n/projects` (from `directories` in package.json). - Includes `pseudoLocale: 'en-XA'` by default (or inherits from the base project when extending), for use with msg's `getTranslation(pseudoLocale)` pseudolocalization support. -- With `--extend `, merges target locales and pseudoLocale from the existing project. If `source` and `targets` are omitted, they are inherited from the base project. -- Errors if the project name already exists, package.json is missing or invalid, or required directories are not configured. +- With `--extend `, merges target locales and pseudoLocale from the existing project. If `source` and `targets` are omitted, they are inherited from the base project. If `--format` is omitted, format is inherited from the base project when set. +- Errors if the project name already exists, package.json is missing or invalid, required directories are not configured, or `--format` is not one of `MF1` / `MF2` / `NONE`. ### create resource diff --git a/src/commands/create/project.ts b/src/commands/create/project.ts index 670aea3..79d1c10 100644 --- a/src/commands/create/project.ts +++ b/src/commands/create/project.ts @@ -2,9 +2,11 @@ import { Args, Command, Flags } from "@oclif/core"; import { existsSync } from "fs"; import { join } from "path"; import { + CREATE_PROJECT_FORMATS, calculateRelativePath, importMsgProjectFile, loadPackageJsonForCreateProject, + resolveCreateProjectFormat, writeMsgProjectFile, } from "../../lib/create-project-helpers.js"; import { findPackageJsonPath } from "../../lib/init-helpers.js"; @@ -18,6 +20,13 @@ export default class CreateProject extends Command { static override strict = false; + static override examples = [ + "<%= config.bin %> <%= command.id %> myApp en fr de", + "<%= config.bin %> <%= command.id %> myApp en fr -f MF1", + "<%= config.bin %> <%= command.id %> extendedApp --extend base", + "<%= config.bin %> <%= command.id %> extendedApp --extend base --format NONE", + ]; + static override args = { projectName: Args.string({ required: false, @@ -39,6 +48,12 @@ export default class CreateProject extends Command { char: "e", description: "Extend an existing project", }), + format: Flags.option({ + char: "f", + description: "Default message format for the project (MF1, MF2, or NONE)", + options: CREATE_PROJECT_FORMATS, + // No default: omission must be distinguishable from an explicit MF2 for --extend inheritance. + })(), }; public async run(): Promise { @@ -92,13 +107,16 @@ export default class CreateProject extends Command { let targetLocales: Record = {}; let pseudoLocale = "en-XA"; let resolvedSource = source?.trim(); + let baseProject: Awaited> | undefined; const hasUserSourceAndTargets = Boolean(resolvedSource && targets?.length && targets.some((t) => t?.trim())); - if (flags.extend) { - const base = await importMsgProjectFile(projectsDir, flags.extend); + if (useExtend) { + const extendName = flags.extend!.trim(); + const base = await importMsgProjectFile(projectsDir, extendName); if (!base) { - this.error(`Project '${flags.extend}' could not be found to extend.`, { exit: 1 }); + this.error(`Project '${extendName}' could not be found to extend.`, { exit: 1 }); } + baseProject = base; if (base.locales?.targetLocales && typeof base.locales.targetLocales === "object") { targetLocales = { ...base.locales.targetLocales }; } @@ -120,6 +138,8 @@ export default class CreateProject extends Command { } } + const format = resolveCreateProjectFormat(flags.format, baseProject); + const loaderPathLine = "const path = `${TRANSLATION_IMPORT_PATH}/${project}/${language}/${title}.json`;"; const loaderWarnLine = @@ -139,6 +159,7 @@ export default class CreateProject extends Command { }`; const importPath = relPath.replace(/\\/g, "/"); + const projectSettings = `project: { name: ${JSON.stringify(projectName)}, version: 1, format: ${JSON.stringify(format)} }`; const content = isEsm ? `import { MsgProject } from '@worldware/msg'; @@ -148,7 +169,7 @@ const loader = async (project, title, language) => { }; export default MsgProject.create({ - project: { name: ${JSON.stringify(projectName)}, version: 1 }, + ${projectSettings}, locales: { sourceLocale: ${JSON.stringify(resolvedSource)}, pseudoLocale: ${JSON.stringify(pseudoLocale)}, @@ -165,7 +186,7 @@ const loader = async (project, title, language) => { }; module.exports = MsgProject.create({ - project: { name: ${JSON.stringify(projectName)}, version: 1 }, + ${projectSettings}, locales: { sourceLocale: ${JSON.stringify(resolvedSource)}, pseudoLocale: ${JSON.stringify(pseudoLocale)}, diff --git a/src/lib/create-project-helpers.ts b/src/lib/create-project-helpers.ts index 6c27b61..39bc278 100644 --- a/src/lib/create-project-helpers.ts +++ b/src/lib/create-project-helpers.ts @@ -1,13 +1,17 @@ import { existsSync, mkdirSync, writeFileSync } from "fs"; import { dirname, join, relative } from "path"; import { pathToFileURL } from "url"; +import { MSG_DEFAULT_FORMAT } from "@worldware/msg"; import { dynamicImportFromUrl } from "./create-resource-helpers.js"; import type { PackageJson } from "./init-helpers.js"; import { loadPackageJsonForMsg } from "./init-helpers.js"; +import type { MsgFormat } from "./msg-format.js"; /** Minimal type for MsgProject-like data we read from an existing project file. */ export interface MsgProjectFileData { - project?: { name?: string; version?: number }; + project?: { name?: string; version?: number; format?: MsgFormat }; + /** Resolved format getter on MsgProject instances. */ + format?: MsgFormat; locales?: { sourceLocale?: string; pseudoLocale?: string; @@ -16,6 +20,24 @@ export interface MsgProjectFileData { loader?: unknown; } +/** Allowed `--format` / `-f` values for `create project`. */ +export const CREATE_PROJECT_FORMATS = ["MF1", "MF2", "NONE"] as const; + +/** + * Resolves the format to write into a new MsgProject file. + * Explicit flag wins; otherwise inherit from an extended project; else library default. + * @param flagFormat - Value from `--format` / `-f`, if provided + * @param base - Imported base project when `--extend` is used + */ +export function resolveCreateProjectFormat( + flagFormat: MsgFormat | undefined, + base?: MsgProjectFileData +): MsgFormat { + if (flagFormat) return flagFormat; + const inherited = base?.project?.format ?? base?.format; + return inherited ?? MSG_DEFAULT_FORMAT; +} + /** * Calculates the relative path from the i18n projects directory to the l10n translations directory. * @param projectsDir - Absolute path to i18n/projects (e.g. root/i18n/projects) diff --git a/src/specs/create-project-command.spec.md b/src/specs/create-project-command.spec.md index 75eae37..afaf883 100644 --- a/src/specs/create-project-command.spec.md +++ b/src/specs/create-project-command.spec.md @@ -8,7 +8,8 @@ import { MsgProject } from `@worldware/msg`; export default = MsgProject.create({ project: { name: , - version: 1 + version: 1, + format: // MF1 | MF2 | NONE; defaults to MF2 }, locales: { sourceLocale: , @@ -60,6 +61,7 @@ When retrieving the path for the `i18n` and `l10n` directories from the package. - As a `software developer`, I want to `be able to template a MsgProject file`, so that `I don't have to do it myself`. - As a `software developer`, I want `the loader function to be automatically configured based on the relative path`, so that `I don't have to do it myself`. - As a `software developer`, I want `the MsgProject file to use CommonJS or ES modules based on what is set in package.json`, so that `it fits into my project`. +- As a `software developer`, I want `to specify the project message format with --format / -f`, so that `resources inherit MF1, MF2, or NONE by default`. ## 3. Functionality @@ -88,6 +90,10 @@ When retrieving the path for the `i18n` and `l10n` directories from the package. - It should require all arguments be passed and error with a message if any are missing - It should accecpt a flag `--extend` which takes the name of an existing project to extend - It should merge the new data with the information from the existing project if `--extend` is used +- It should accept a flag `--format` / `-f` with values `MF1`, `MF2`, or `NONE` +- 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 an importable file. ### Constraints @@ -102,9 +108,9 @@ When retrieving the path for the `i18n` and `l10n` directories from the package. | Command | Arguments | Flags | Notes | | --------- | ------------- | --------------- | ------- | -| `create project` | `` `[source]` `[targets]` | `--extend=` | `creates a new MsgProject file in the projects dir` | +| `create project` | `` `[source]` `[targets]` | `--extend=`, `--format=` (`-f`) | `creates a new MsgProject file in the projects dir` | -* Note: Do not include the angle brackets above in the argument names. `source` and `targets` are optional when `--extend` is used; they are inherited from the base project. +* Note: Do not include the angle brackets above in the argument names. `source` and `targets` are optional when `--extend` is used; they are inherited from the base project. When `--extend` is used without `--format`, format is inherited from the base project when set; otherwise it defaults to `MF2`. ### Inputs @@ -124,6 +130,7 @@ When retrieving the path for the `i18n` and `l10n` directories from the package. | Option | Type | Short | Long | Notes | | -------- | ------ | ------- | ------ | ------- | | `extend` | `string` | `-e` | `--extend` | `Used to extend an existing project` | +| `format` | `MF1` \| `MF2` \| `NONE` | `-f` | `--format` | `Project default message format; defaults to MF2; inherited from base when using --extend without --format` | ### Outputs @@ -225,7 +232,12 @@ When retrieving the path for the `i18n` and `l10n` directories from the package. - **Basic project creation with single target** - Given: A project root with a valid `package.json` containing `directories.i18n` and `directories.l10n`, and an existing `i18n/projects` directory. - When: User runs `msg create project myApp en fr`. - - Then: An MsgProject file is created at `i18n/projects/myApp.ts` (or `.js` based on project config) with correct `project.name`, `sourceLocale`, `targetLocales` (en and fr), and a loader function using the calculated relative path from `i18n/projects` to `l10n/translations`; actions are logged to STDOUT; the file exports a MsgProject instance and is importable. + - Then: An MsgProject file is created at `i18n/projects/myApp.ts` (or `.js` based on project config) with correct `project.name`, `format: "MF2"`, `sourceLocale`, `targetLocales` (en and fr), and a loader function using the calculated relative path from `i18n/projects` to `l10n/translations`; actions are logged to STDOUT; the file exports a MsgProject instance and is importable. + +- **Project creation with explicit format** + - Given: Same as above. + - When: User runs `msg create project myApp en fr -f MF1` or `msg create project myApp en fr --format NONE`. + - Then: The created MsgProject file includes `project.format` set to the requested value; the imported `MsgProject` instance reports the same format. - **Project creation with multiple target locales** - Given: Same as above. @@ -262,6 +274,16 @@ When retrieving the path for the `i18n` and `l10n` directories from the package. - When: User runs `msg create project extendedApp --extend base` (no source or targets). - Then: A new MsgProject file is created at `i18n/projects/extendedApp.ts`; source locale and target locales are inherited from the base project; actions are logged to STDOUT. +- **Extend inherits format from base** + - Given: An existing MsgProject with `project.format` set to `MF1` (or `NONE`). + - When: User runs `msg create project extendedApp --extend base` without `--format`. + - Then: The new project file has the same `format` as the base project. + +- **Explicit format overrides extend inheritance** + - Given: An existing MsgProject with `project.format` set to `MF1`. + - When: User runs `msg create project extendedApp --extend base --format NONE`. + - Then: The new project file has `format: "NONE"`. + - **Help** - Given: Any project directory. - When: User runs `msg create project -h` or `msg create project --help`. @@ -316,6 +338,11 @@ When retrieving the path for the `i18n` and `l10n` directories from the package. - When: User runs `msg create project myApp en fr --extend nonexistent`. - Then: Command fails with an error indicating that the project to extend could not be found; no file is created; error on STDERR. +- **Invalid format value** + - Given: A valid project setup. + - When: User runs `msg create project myApp en fr --format ICU`. + - Then: Command fails with an oclif validation error listing allowed values (`MF1`, `MF2`, `NONE`); no file is created; error on STDERR. + - **i18n or l10n directories not configured** - Given: A `package.json` that lacks `directories.i18n` or `directories.l10n` entries. - When: User runs `msg create project myApp en fr`. diff --git a/src/tests/create-project-helpers.test.ts b/src/tests/create-project-helpers.test.ts index 314fd48..6990aea 100644 --- a/src/tests/create-project-helpers.test.ts +++ b/src/tests/create-project-helpers.test.ts @@ -3,16 +3,67 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; import { join, dirname } from "path"; import { tmpdir } from "os"; import { fileURLToPath } from "url"; +import { MSG_DEFAULT_FORMAT } from "@worldware/msg"; import { calculateRelativePath, loadPackageJsonForCreateProject, writeMsgProjectFile, importMsgProjectFile, + resolveCreateProjectFormat, } from "../lib/create-project-helpers.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); describe("create-project-helpers", () => { + describe("resolveCreateProjectFormat", () => { + test("defaults to library default when flag and base are omitted", () => { + expect(resolveCreateProjectFormat(undefined)).toBe(MSG_DEFAULT_FORMAT); + }); + + test("uses explicit flag over base project format", () => { + expect( + resolveCreateProjectFormat("NONE", { + project: { name: "base", format: "MF1" }, + }) + ).toBe("NONE"); + expect( + resolveCreateProjectFormat("MF1", { + project: { name: "base", format: "MF2" }, + }) + ).toBe("MF1"); + }); + + test("inherits format from base.project.format when flag is omitted", () => { + expect( + resolveCreateProjectFormat(undefined, { + project: { name: "base", format: "MF1" }, + }) + ).toBe("MF1"); + expect( + resolveCreateProjectFormat(undefined, { + project: { name: "base", format: "NONE" }, + }) + ).toBe("NONE"); + }); + + test("inherits format from MsgProject-like format getter when project.format missing", () => { + expect( + resolveCreateProjectFormat(undefined, { + project: { name: "base" }, + format: "MF1", + }) + ).toBe("MF1"); + }); + + test("falls back to library default when base has no format", () => { + expect( + resolveCreateProjectFormat(undefined, { + project: { name: "base" }, + }) + ).toBe(MSG_DEFAULT_FORMAT); + }); + }); + describe("calculateRelativePath", () => { test("returns relative path from projects to translations (sibling dirs)", () => { const projects = "/root/i18n/projects"; diff --git a/src/tests/create-project.test.ts b/src/tests/create-project.test.ts index 705d05f..fd3efa8 100644 --- a/src/tests/create-project.test.ts +++ b/src/tests/create-project.test.ts @@ -9,7 +9,8 @@ import { } from "fs"; import { join, dirname } from "path"; import { tmpdir } from "os"; -import { fileURLToPath } from "url"; +import { fileURLToPath, pathToFileURL } from "url"; +import { MSG_DEFAULT_FORMAT } from "@worldware/msg"; import CreateProject from "../commands/create/project.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -180,6 +181,71 @@ describe("CreateProject command", () => { expect(content).toMatch(/"fr":\s*\["fr"\]/); }); + test("defaults project format to MF2 in boilerplate when flag omitted", async () => { + setupValidProject(tmp); + await CreateProject.run(["myApp", "en", "fr"], CLI_ROOT); + + const content = readFileSync(join(tmp, "i18n", "projects", "myApp.js"), "utf-8"); + expect(content).toMatch( + /project:\s*\{\s*name:\s*["']myApp["']\s*,\s*version:\s*1\s*,\s*format:\s*["']MF2["']\s*\}/ + ); + }); + + test("writes --format value into project settings", async () => { + setupValidProject(tmp); + await CreateProject.run(["myApp", "en", "fr", "--format", "MF1"], CLI_ROOT); + + const content = readFileSync(join(tmp, "i18n", "projects", "myApp.js"), "utf-8"); + expect(content).toMatch( + /project:\s*\{\s*name:\s*["']myApp["']\s*,\s*version:\s*1\s*,\s*format:\s*["']MF1["']\s*\}/ + ); + }); + + test("writes -f short option value into project settings", async () => { + setupValidProject(tmp); + await CreateProject.run(["myApp", "en", "fr", "-f", "NONE"], CLI_ROOT); + + const content = readFileSync(join(tmp, "i18n", "projects", "myApp.js"), "utf-8"); + expect(content).toMatch( + /project:\s*\{\s*name:\s*["']myApp["']\s*,\s*version:\s*1\s*,\s*format:\s*["']NONE["']\s*\}/ + ); + }); + + test("extend inherits format from base project when --format omitted", async () => { + setupValidProject(tmp); + const baseContent = `module.exports = { + project: { name: 'base', version: 1, format: 'MF1' }, + locales: { sourceLocale: 'en', pseudoLocale: 'zxx', targetLocales: { en: ['en'], fr: ['fr'] } }, + loader: async () => ({ title: '', attributes: { lang: '', dir: '' }, notes: [], messages: [] }) +};`; + writeFileSync(join(tmp, "i18n", "projects", "base.js"), baseContent); + await CreateProject.run(["extendedApp", "--extend", "base"], CLI_ROOT); + + const content = readFileSync(join(tmp, "i18n", "projects", "extendedApp.js"), "utf-8"); + expect(content).toMatch( + /project:\s*\{\s*name:\s*["']extendedApp["']\s*,\s*version:\s*1\s*,\s*format:\s*["']MF1["']\s*\}/ + ); + }); + + test("explicit --format overrides format inherited from --extend", async () => { + setupValidProject(tmp); + const baseContent = `module.exports = { + project: { name: 'base', version: 1, format: 'MF1' }, + locales: { sourceLocale: 'en', pseudoLocale: 'zxx', targetLocales: { en: ['en'], fr: ['fr'] } }, + loader: async () => ({ title: '', attributes: { lang: '', dir: '' }, notes: [], messages: [] }) +};`; + writeFileSync(join(tmp, "i18n", "projects", "base.js"), baseContent); + await CreateProject.run( + ["extendedApp", "--extend", "base", "--format", "NONE"], + CLI_ROOT + ); + + const content = readFileSync(join(tmp, "i18n", "projects", "extendedApp.js"), "utf-8"); + expect(content).toMatch( + /project:\s*\{\s*name:\s*["']extendedApp["']\s*,\s*version:\s*1\s*,\s*format:\s*["']NONE["']\s*\}/ + ); + }); + test("source locale same as one target", async () => { setupValidProject(tmp); await CreateProject.run(["myApp", "en", "en", "fr"], CLI_ROOT); @@ -233,6 +299,46 @@ describe("CreateProject command", () => { }); }); + describe("Format integration", () => { + test("generated boilerplate loads as MsgProject with default format", async () => { + setupValidProject(tmp); + await CreateProject.run(["myApp", "en", "fr"], CLI_ROOT); + + const outPath = join(tmp, "i18n", "projects", "myApp.js"); + const mod = await import(pathToFileURL(outPath).href); + const project = mod.default; + expect(project.format).toBe(MSG_DEFAULT_FORMAT); + expect(project.project.format).toBe(MSG_DEFAULT_FORMAT); + }); + + test("generated boilerplate with -f MF1 loads with format MF1", async () => { + setupValidProject(tmp); + await CreateProject.run(["myApp", "en", "fr", "-f", "MF1"], CLI_ROOT); + + const outPath = join(tmp, "i18n", "projects", "myApp.js"); + const mod = await import(pathToFileURL(outPath).href); + expect(mod.default.format).toBe("MF1"); + expect(mod.default.project.format).toBe("MF1"); + }); + + test("extend inherits format from a real MsgProject base instance", async () => { + setupValidProject(tmp); + const baseContent = `const { MsgProject } = require('@worldware/msg'); +module.exports = MsgProject.create({ + project: { name: 'base', version: 1, format: 'NONE' }, + locales: { sourceLocale: 'en', pseudoLocale: 'zxx', targetLocales: { en: ['en'], fr: ['fr'] } }, + loader: async () => ({ title: '', attributes: { lang: '', dir: '' }, notes: [], messages: [] }) +});`; + writeFileSync(join(tmp, "i18n", "projects", "base.js"), baseContent); + await CreateProject.run(["extendedApp", "--extend", "base"], CLI_ROOT); + + const outPath = join(tmp, "i18n", "projects", "extendedApp.js"); + const mod = await import(pathToFileURL(outPath).href); + expect(mod.default.format).toBe("NONE"); + expect(mod.default.project.format).toBe("NONE"); + }); + }); + describe("Edge cases", () => { test("extend base with no sourceLocale fails when not providing source and targets", async () => { setupValidProject(tmp); @@ -360,5 +466,12 @@ describe("CreateProject command", () => { /package\.json could not be imported|Invalid package\.json/ ); }); + + test("invalid --format value fails", async () => { + setupValidProject(tmp); + await expect( + CreateProject.run(["myApp", "en", "fr", "--format", "ICU"], CLI_ROOT) + ).rejects.toThrow(/Expected --format=ICU to be one of|MF1|MF2|NONE/i); + }); }); });