Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <projectName> [source] [targets...] [--extend <name>]
msg create project <projectName> [source] [targets...] [--extend <name>] [--format <MF1|MF2|NONE>]
```

| Argument | Required | Description |
Expand All @@ -93,17 +93,24 @@ msg create project <projectName> [source] [targets...] [--extend <name>]
| 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

Expand All @@ -115,10 +122,11 @@ msg create project -h

- Writes the file to `i18n/projects/<projectName>.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 <name>`, 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 <name>`, 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

Expand Down
31 changes: 26 additions & 5 deletions src/commands/create/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand All @@ -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<void> {
Expand Down Expand Up @@ -92,13 +107,16 @@ export default class CreateProject extends Command {
let targetLocales: Record<string, string[]> = {};
let pseudoLocale = "en-XA";
let resolvedSource = source?.trim();
let baseProject: Awaited<ReturnType<typeof importMsgProjectFile>> | 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 };
}
Expand All @@ -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 =
Expand All @@ -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';

Expand All @@ -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)},
Expand All @@ -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)},
Expand Down
24 changes: 23 additions & 1 deletion src/lib/create-project-helpers.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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)
Expand Down
35 changes: 31 additions & 4 deletions src/specs/create-project-command.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { MsgProject } from `@worldware/msg`;
export default = MsgProject.create({
project: {
name: <projectName>,
version: 1
version: 1,
format: <format> // MF1 | MF2 | NONE; defaults to MF2
},
locales: {
sourceLocale: <source>,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -102,9 +108,9 @@ When retrieving the path for the `i18n` and `l10n` directories from the package.

| Command | Arguments | Flags | Notes |
| --------- | ------------- | --------------- | ------- |
| `create project` | `<projectName>` `[source]` `[targets]` | `--extend=<existing project name>` | `creates a new MsgProject file in the projects dir` |
| `create project` | `<projectName>` `[source]` `[targets]` | `--extend=<existing project name>`, `--format=<MF1\|MF2\|NONE>` (`-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

Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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`.
Expand Down
51 changes: 51 additions & 0 deletions src/tests/create-project-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading
Loading