UNPKG

frontity

Version:

Frontity cli and entry point to other packages

426 lines (394 loc) 12 kB
import { EOL } from "os"; import { resolve as resolvePath } from "path"; import { execSync, exec } from "child_process"; import { promisify } from "util"; import { ensureDir, readdir as readDir, readFile, writeFile, createWriteStream, remove, pathExists, appendFileSync, } from "fs-extra"; import { extract } from "tar"; import fetch from "node-fetch"; import { mergeRight } from "ramda"; import { isPackageNameValid, isThemeNameValid, fetchPackageVersion, } from "../utils"; import { CreateCommandOptions, PackageJson } from "./types"; const allowedExistingContent = [ "readme.md", "license", ".git", ".gitignore", ".ds_store", ]; const faviconUrl = "https://frontity.org/wp-content/plugins/frontity-favicon/favicon.ico"; /** * This function normalizes and validates options. * * @param defaultOptions - The default options. Defined in {@link * CreateCommandOptions}. * @param passedOptions - The options from the user. Defined in {@link * CreateCommandOptions}. * * @returns The final options, normalized. Defined in {@link * CreateCommandOptions}. */ export const normalizeOptions = ( defaultOptions: CreateCommandOptions, passedOptions: CreateCommandOptions ): CreateCommandOptions => { const options = mergeRight(defaultOptions, passedOptions); // Normalize and validate `name` option. options.name = options.name.replace(/[\s_-]+/g, "-").toLowerCase(); if (!isPackageNameValid(options.name)) throw new Error( "The name of the package is not valid. Please enter a valid one (only letters and dashes)." ); return options; }; /** * This function ensures the path exists and checks if it's empty or it's a new * repo. Also returns a boolean indicating if the directory existed already. * * @param path - The path where the project will be installed. * * @returns A promise that resolves once the check has been made. */ export const ensureProjectDir = async (path: string): Promise<boolean> => { const dirExisted = await pathExists(path); if (dirExisted) { // Check if the directory is a new repo. const dirContent = await readDir(path); const notAllowedContent = dirContent.filter( (content) => !allowedExistingContent.includes(content.toLowerCase()) ); // If it's not, throw. if (notAllowedContent.length) { throw new Error("The directory passed to `create` function is not empty"); } } else { await ensureDir(path); } return dirExisted; }; /** * Create a `package.json` file. * * @param name - The name of the project. * @param theme - The theme that will be cloned and installed locally. * @param path - The path where the file will be created. * @param typescript - Indicates if the typescript flag is active. */ export const createPackageJson = async ( name: string, theme: string, path: string, typescript: boolean ) => { const packages = [ "frontity", "@frontity/core", "@frontity/wp-source", "@frontity/tiny-router", "@frontity/html2react", ]; // Add Frontity packages to the dependencies. const dependencies = ( await Promise.all( packages.map(async (pkg) => { // Get the version of each package. const version = await fetchPackageVersion(pkg); return [pkg, `^${version}`]; }) ) ).reduce((final, current) => { // Reduce the packages into a dependecies object. final[current[0]] = current[1]; return final; }, {}); // Add the starter theme to the dependencies. const themeName = (theme.match(/\/?([\w-]+)$/) || ["", ""])[1]; dependencies[theme] = `./packages/${themeName}`; const packageJson: PackageJson = { name, version: "1.0.0", private: true, description: "Frontity project", keywords: ["frontity"], engines: { node: ">=10.0.0", npm: ">=6.0.0", }, scripts: { dev: "frontity dev", build: "frontity build", serve: "frontity serve", }, prettier: {}, dependencies, }; // If the typescript flag is active, add the needed devDependencies. if (typescript) { const devPackages = ["@types/react", "@types/node-fetch"]; const devDependencies = ( await Promise.all( devPackages.map(async (pkg) => { // Get the version of each package. const version = await fetchPackageVersion(pkg); return [pkg, `^${version}`]; }) ) ).reduce((final, current) => { // Reduce the packages into a dependecies object. final[current[0]] = current[1]; return final; }, {}); packageJson.devDependencies = devDependencies; } const filePath = resolvePath(path, "package.json"); const fileData = `${JSON.stringify(packageJson, null, 2)}${EOL}`; await writeFile(filePath, fileData); }; /** * Create a `README.md` file. * * @param name - The name of the project. * @param path - The path where the file will be created. */ export const createReadme = async ( name: string, path: string ): Promise<void> => { const fileTemplate = await readFile( resolvePath(__dirname, "../../templates/README.md"), { encoding: "utf8", } ); const filePath = resolvePath(path, "README.md"); const fileData = fileTemplate.replace(/\$name\$/g, name); await writeFile(filePath, fileData); }; /** * Create a `frontity.settings` file. * * @param extension - The extension of the file, either `.js` or `.ts`. * @param name - The name of the project. * @param path - The path where the file will be created. * @param theme - The theme installed in the project. */ export const createFrontitySettings = async ( extension: string, name: string, path: string, theme: string ) => { const frontitySettings = { name, state: { frontity: { url: "https://test.frontity.org", title: "Test Frontity Blog", description: "WordPress installation for Frontity development", }, }, packages: [ { name: theme, state: { theme: { menu: [ ["Home", "/"], ["Nature", "/category/nature/"], ["Travel", "/category/travel/"], ["Japan", "/tag/japan/"], ["About Us", "/about-us/"], ], featured: { showOnList: false, showOnPost: false, }, }, }, }, { name: "@frontity/wp-source", state: { source: { url: "https://test.frontity.org", }, }, }, "@frontity/tiny-router", "@frontity/html2react", ], }; const fileTemplate = await readFile( resolvePath(__dirname, `../../templates/settings-${extension}-template`), { encoding: "utf8" } ); const filePath = resolvePath(path, `frontity.settings.${extension}`); const fileData = fileTemplate.replace(/\$([\w-]+)\$/g, (_match, key) => { if (key === "settings") return JSON.stringify(frontitySettings, null, 2); if (key === "theme") return theme; }); await writeFile(filePath, fileData); }; /** * Create a `tsconfig.json` file. * * @param path - The path where the file will be created. */ export const createTsConfig = async (path: string) => { const fileTemplate = await readFile( resolvePath(__dirname, "../../templates/tsconfig.json"), { encoding: "utf8", } ); const filePath = resolvePath(path, "tsconfig.json"); await writeFile(filePath, fileTemplate); }; /** * Clone the starter theme. * * @param theme - The name of the theme. * @param path - The path where it needs to be installed. */ export const cloneStarterTheme = async (theme: string, path: string) => { const packageJsonPath = resolvePath(path, "./package.json"); const packageJson = JSON.parse( await readFile(packageJsonPath, { encoding: "utf8" }) ); const themePath = resolvePath(path, packageJson.dependencies[theme]); await ensureDir(themePath); if (!isThemeNameValid(theme)) throw new Error("The name of the theme is not a valid npm package name."); await promisify(exec)(`npm pack ${theme}`, { cwd: themePath }); const tarball = (await readDir(themePath)).find((file) => /\.tgz$/.test(file) ); const tarballPath = resolvePath(themePath, tarball); await extract({ cwd: themePath, file: tarballPath, strip: 1 }); await remove(tarballPath); }; /** * Install the Frontity packages. * * @param path - The location where `npm install` should be run. */ export const installDependencies = async (path: string) => { await promisify(exec)("npm install", { cwd: path }); }; /** * Downlaod the favicon file. * * @param path - The path where the favicon should be downloaded. */ export const downloadFavicon = async (path: string) => { const response = await fetch(faviconUrl); const fileStream = createWriteStream(resolvePath(path, "favicon.ico")); response.body.pipe(fileStream); await new Promise((resolve) => fileStream.on("finish", resolve)); }; /** * Initializes a new git repository. * * @param path - The path where git should be initialized. */ export const initializeGit = async (path: string) => { try { execSync("git init", { cwd: path, stdio: "ignore" }); execSync("git add .", { cwd: path, stdio: "ignore" }); execSync('git commit -m "Initialized with Frontity"', { cwd: path, stdio: "ignore", }); } catch (e) { // If there is any issue we want to revert to "pre-git" state await remove(resolvePath(path, ".git")); // Rethrow the error so that it can be caught by the `create` command. throw e; } }; /** * Creates a .gitignore file. * * @param path - The path where .gitignore file should be created. * @returns - A promise which resolves with a cleanup function which * should be called if any subsequent step fails. */ export const createGitignore = async (path: string) => { const fileTemplate = await readFile( resolvePath(__dirname, "../../templates/gitignore-template"), { encoding: "utf8", } ); const gitignorePath = resolvePath(path, ".gitignore"); const gitignoreExists = await pathExists(gitignorePath); if (gitignoreExists) { // Keep the existing .gitignore in memory in case we need to revert to it later. const backupGitignore = await readFile(gitignorePath, { encoding: "utf8", }); // Append if there's already a `.gitignore` file appendFileSync(gitignorePath, "node_modules\nbuild"); // Return a "cleanup" function return async () => { await writeFile(gitignorePath, backupGitignore); }; } else { await writeFile(gitignorePath, fileTemplate); // Return a "cleanup" function return async () => { // Remove the .gitignore file await remove(gitignorePath); }; } }; /** * Remove the files and directories created with `frontity create` in case there * was a problem and we need to revert everything. * * @param dirExisted - If the directory existed already. * @param path - The path of the direcotry. */ export const revertProgress = async (dirExisted: boolean, path: string) => { if (dirExisted) { const content = await readDir(path); const removableContent = content .filter((item) => !allowedExistingContent.includes(item.toLowerCase())) .map((item) => resolvePath(path, item)); for (const content of removableContent) await remove(content); } else { await remove(path); } }; /** * Check if an email is valid or not. * * @param email - The email to be checked. * * @returns True or false depending if the email is valid. */ // const isEmailValid = (email: string): boolean => // /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,63}$/i.test(email); /** * Subscribe an email to the newsletter service. * * @param email - The email to be subscribed. * * @returns The response of the subscription. */ export const subscribe = async () => { throw new Error("Frontity newsletter is currently disabled"); };