Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(frontity): add create-package command #218

Merged
merged 12 commits into from Oct 2, 2019
@@ -0,0 +1,31 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`create-package should ask for missing name 1`] = `
Array [
Array [
Array [
Object {
"default": "my-frontity-package",
"message": "Enter a name for the package:",
"name": "name",
"type": "input",
},
],
],
]
`;

exports[`create-package should ask for missing namespace 1`] = `
Array [
Array [
Array [
Object {
"default": "theme",
"message": "Enter the namespace of the package:",
"name": "namespace",
"type": "input",
},
],
],
]
`;
58 changes: 58 additions & 0 deletions packages/frontity/src/actions/__tests__/create-package.tests.ts
@@ -0,0 +1,58 @@
import createPackage from "../create-package";
import inquirer from "inquirer";
import * as utils from "../../utils";

jest.mock("../../functions/create-package");
jest.mock("inquirer");
jest.mock("events");
jest.mock("../../utils");

const mockedInquirer = inquirer as jest.Mocked<typeof inquirer>;
const mockedUtils = utils as jest.Mocked<typeof utils>;
const mockedExit = jest.spyOn(process, "exit");

beforeEach(() => {
mockedInquirer.prompt.mockRestore();
mockedUtils.isFrontityProjectRoot.mockRestore();
mockedExit.mockRestore();
mockedExit.mockImplementation(() => {
throw new Error();
});
});

describe("create-package", () => {
test("should not ask for passed options", async () => {
const name = "example-theme";
const namespace = "theme";
await createPackage(name, { namespace });
expect(mockedInquirer.prompt).not.toHaveBeenCalled();
});

test("should ask for missing name", async () => {
mockedInquirer.prompt.mockResolvedValueOnce({ name: "example-theme" });

const name = undefined;
const namespace = "theme";
await createPackage(name, { namespace });
expect(mockedInquirer.prompt).toHaveBeenCalled();
expect(mockedInquirer.prompt.mock.calls).toMatchSnapshot();
});

test("should ask for missing namespace", async () => {
mockedInquirer.prompt.mockResolvedValueOnce({ namespace: "theme" });

const name = "example-theme";
const namespace = undefined;
await createPackage(name, { namespace });
expect(mockedInquirer.prompt).toHaveBeenCalled();
expect(mockedInquirer.prompt.mock.calls).toMatchSnapshot();
});

test.todo("should fail in a invalid directory");
test.todo("should work in a valid directory");
test.todo("should fail with an invalid name");
test.todo("should work with a valid name");
test.todo("should work with a valid name with scope");
test.todo("should fail with an invalid namespace");
test.todo("should work with a valid namespace");
});
103 changes: 103 additions & 0 deletions packages/frontity/src/actions/create-package.ts
@@ -0,0 +1,103 @@
import ora from "ora";
import chalk from "chalk";
import { normalize } from "path";
import { prompt, Question } from "inquirer";
import createPackage from "../functions/create-package";
import { errorLogger, isFrontityProjectRoot, isThemeNameValid } from "../utils";
import { EventEmitter } from "events";
import { Options } from "../functions/create-package/types";

// Command:
// create-package [name] [--typescript]
//
// Steps:
// 1. validate project location
// 2. ask for the package name if it wasn't passed as argument and validate
// 3. ask for the package namespace if it wasn't passed as argument
// 4. create package

export default async (
name: string,
{
namespace,
typescript
}: {
namespace?: string;
typescript?: boolean;
}
) => {
// Init options
const options: Options = { typescript };

// Init event emitter
const emitter = new EventEmitter();
emitter.on("error", errorLogger);
emitter.on("create", (message, action) => {
if (action) ora.promise(action, message);
else console.log(message);
});

// 1. validate project location
options.projectPath = process.cwd();
if (!(await isFrontityProjectRoot(options.projectPath))) {
emitter.emit(
"error",
new Error(
"You must execute this command in the root folder of a Frontity project."
)
);
}

// 2. ask for the package name if it wasn't passed as argument and validate
if (!name) {
const questions: Question[] = [
{
name: "name",
type: "input",
message: "Enter a name for the package:",
default: "my-frontity-package"
}
];

const answers = await prompt(questions);
options.name = answers.name;
console.log();
} else {
options.name = name;
}

if (!isThemeNameValid(options.name)) {
emitter.emit(
"error",
new Error("The name of the package is not a valid npm package name.")
);
}

// 2.1 set the package path
options.packagePath = normalize(
`packages/${options.name.replace(/(?:@.+\/)/i, "")}`
);

// 3. ask for the package namespace if it wasn't passed as argument
if (!namespace) {
const questions: Question[] = [
{
name: "namespace",
type: "input",
message: "Enter the namespace of the package:",
default: "theme"
}
];

const answers = await prompt(questions);
options.namespace = answers.namespace;
console.log();
} else {
options.namespace = namespace;
}

// 4. create package
await createPackage(options, emitter);

console.log(chalk.bold(`\nNew package "${options.name}" created.\n`));
};
1 change: 1 addition & 0 deletions packages/frontity/src/actions/index.ts
Expand Up @@ -5,3 +5,4 @@ export { default as serve } from "./serve";
export { default as subscribe } from "./subscribe";
export { default as info } from "./info";
export { default as unknown } from "./unknown";
export { default as createPackage } from "./create-package";
18 changes: 17 additions & 1 deletion packages/frontity/src/commands.ts
Expand Up @@ -28,7 +28,16 @@ tsNode.register({
import program from "commander";
import { readFileSync } from "fs-extra";
import { resolve } from "path";
import { create, dev, build, serve, subscribe, info, unknown } from "./actions";
import {
create,
dev,
build,
serve,
subscribe,
info,
unknown,
createPackage
} from "./actions";

const { version } = JSON.parse(
readFileSync(resolve(__dirname, "../package.json"), { encoding: "utf8" })
Expand All @@ -50,6 +59,13 @@ program
.description("Creates a new Frontity project.")
.action(create);

program
.command("create-package [name]")
.option("-n, --namespace <value>", "Sets the namespace for this package")
.option("-t, --typescript", "Adds support for TypeScript")
.description("Creates a new Frontity package in a project.")
.action(createPackage);

program
.command("dev")
.option("-p, --production", "Builds the project for production.")
Expand Down
@@ -0,0 +1,3 @@
describe("steps", () => {
test.todo("add tests");
});
60 changes: 60 additions & 0 deletions packages/frontity/src/functions/create-package/index.ts
@@ -0,0 +1,60 @@
import chalk from "chalk";
import { EventEmitter } from "events";
import { Options } from "./types";
import {
ensurePackageDir,
createPackageJson,
createSrcIndexJs,
installPackage,
revertProgress
} from "./steps";

// Steps:
// 1. create /packages/[name]/src folder
// 2. add /packages/[name]/package.json file using template
// 3. if [--typescript]
// 3.a.1 add /packages/[name]/src/index.ts file using template
// 3.a.2 add /packages/[name]/types.ts file using template
// 3. else
// 3.b add /packages/[name]/src/index.js file using template
// 4. install package

export default async (options?: Options, emitter?: EventEmitter) => {
// This functions will emit an event if an emitter is passed in options.
const emit = (message: string, step?: Promise<void>) => {
if (emitter) emitter.emit("create", message, step);
};

let step: Promise<any>;
let dirExisted: boolean;

process.on("SIGINT", async () => {
if (typeof dirExisted !== "undefined") await revertProgress(options);
});

try {
// 1. create ./packages/[name] folder
step = ensurePackageDir(options);
emit(`Ensuring ${chalk.yellow(options.packagePath)} folder.`, step);
dirExisted = await step;

// 2. Creates `package.json`.
step = createPackageJson(options);
emit(`Adding ${chalk.yellow("package.json")}.`, step);
await step;

// 3. Creates `src/index.js`.
step = createSrcIndexJs(options);
emit(`Adding ${chalk.yellow("src/index.js")}.`, step);
await step;

// 4. Install package
step = installPackage(options);
emit(`Installing package ${chalk.yellow(options.name)}.`, step);
await step;
} catch (error) {
if (typeof dirExisted !== "undefined") await revertProgress(options);
if (emitter) emitter.emit("error", error);
else throw error;
}
};
81 changes: 81 additions & 0 deletions packages/frontity/src/functions/create-package/steps.ts
@@ -0,0 +1,81 @@
import { EOL } from "os";
import { resolve as resolvePath, join } from "path";
import { ensureDir, writeFile, remove } from "fs-extra";
import { exec } from "child_process";
import { promisify } from "util";
import { Options } from "./types";

// This function ensures all directories that needs a package exist.
export const ensurePackageDir = ({ packagePath }: Options): Promise<void> => {
return ensureDir(join(packagePath, "src"));
};

// This function creates a `package.json` file.
export const createPackageJson = async ({
name,
namespace,
projectPath,
packagePath
}: Options) => {
const filePath = resolvePath(projectPath, packagePath, "package.json");
const fileData = `{
"name": "${name}",
"version": "1.0.0",
"description": "Frontity package created using the Frontity CLI.",
"keywords": [
"frontity",
"frontity-${namespace}"
],
"license": "Apache-2.0",
"dependencies": {
"frontity": "^1.3.1"
}
}${EOL}`;
await writeFile(filePath, fileData);
};

// This function creates an `index.js` file.
export const createSrcIndexJs = async ({
name,
namespace,
projectPath,
packagePath
}: Options) => {
const filePath = resolvePath(projectPath, packagePath, "src/index.js");
const fileData = `import React from "react";

const Root = () => {
return (
<div>
You can edit your package in:
<pre>${packagePath}/src/index.js</pre>
</div>
);
};

export default {
name: "${name}",
roots: {
${namespace}: Root
},
state: {
${namespace}: {}
},
actions: {
${namespace}: {}
}
};${EOL}`;
await writeFile(filePath, fileData);
};

// This function executes the `npm i` command to add the
// created package
export const installPackage = async ({ projectPath, packagePath }: Options) => {
await promisify(exec)(`npm install ${packagePath}`, { cwd: projectPath });
};

// This function removes the files and directories created
// with `frontity create-package`.
export const revertProgress = async ({ projectPath, packagePath }: Options) => {
await remove(join(projectPath, packagePath));
};
13 changes: 13 additions & 0 deletions packages/frontity/src/functions/create-package/types.ts
@@ -0,0 +1,13 @@
// Options passed to the `create-package` function.
export type Options = {
// Name of the package.
name?: string;
// Namespace of the package.
namespace?: string;
// Path of the Frontity project.
projectPath?: string;
// Path where the package should be created (relative to `projectPath`).
packagePath?: string;
// Support for TypeScript.
typescript?: boolean;
};