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
5 changes: 5 additions & 0 deletions .changeset/nasty-phones-accept.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'create-spectacle': minor
---

Added interactive CLI prompt
28 changes: 27 additions & 1 deletion packages/create-spectacle/README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,29 @@
# `create-spectacle`

TODO: Write a README for the migrated project generator.
This package contains `create-spectacle`, the boilerplate-generator for Spectacle. The simplest usage is to run one of the following commands (based on your package manager of choice):

```shell
yarn create spectacle # yarn
npm create spectacle # npm
npx create-spectacle # using npx
pnpm create spectacle # using pnpm
```

Once running the respective command, you will be prompted to provide information about the spectacle project you'd like to create. Once you provide necessary information, a new spectacle project will be created in the directory derived from the project name you provided.

## Flags

`create-spectacle`'s core usage is via the interactive prompts. However, there are a handful of arguments/flags that you can provide to pre-fill prompt options:

- Pass a project name as the main argument to specify a project name, e.g. `yarn create spectacle my-presentation`.
- Pass the `--type` or `-t` flag to specify the type of spectacle project you'd like to create. Options are `jsx`, `tsx`, or `onepage`. Example: `yarn create spectacle -t onepage my-presentation`.
- Pass the `--lang` or `-l` flag to specify the HTML lang attribute for your presentation. Example: `yarn create spectacle -l en my-presentation`.
- Pass the `--port` or `-p` flag to specify the port to run the presentation on. Example: `yarn create spectacle -p 8080 my-presentation`.

### Bypassing Prompts

If you want to bypass the prompts entirely, pass the `-t`, `-l`, and `-p` flags as well as the project name as the main argument. For example:

```shell
yarn create spectacle -t jsx -l en -p 8080 my-presentation
```
18 changes: 10 additions & 8 deletions packages/create-spectacle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,18 @@
"url": "https://github.com/FormidableLabs/spectacle.git"
},
"dependencies": {
"@types/yargs": "^17.0.11",
"chalk": "^4.1.2",
"clear": "^0.1.0",
"cli-spinners": "^2.6.1",
"commander": "^9.3.0",
"log-update": "4.0.0"
"log-update": "4.0.0",
"prompts": "^2.4.2",
"yargs": "^17.5.1"
},
"peerDependencies": {},
"devDependencies": {
"spectacle": "workspace:*",
"@types/node": "^18.0.3"
"@types/node": "^18.0.3",
"@types/prompts": "^2.0.14",
"spectacle": "workspace:*"
},
"resolutions": {},
"scripts": {
Expand All @@ -36,17 +38,17 @@
"examples:clean": "rimraf .examples",
"examples:test": "nps jest",
"examples:jsx:clean": "rimraf .examples/jsx",
"examples:jsx:create": "mkdirp .examples && cd .examples && node ../bin/cli.js -t jsx -n jsx",
"examples:jsx:create": "mkdirp .examples && cd .examples && node ../bin/cli.js jsx -t jsx -l en -p 3000",
"examples:jsx:install": "cd .examples/jsx && npm install",
"examples:jsx:build": "cd .examples/jsx && npm run build",
"examples:jsx:start": "cd .examples/jsx && npm start",
"examples:tsx:clean": "rimraf .examples/tsx",
"examples:tsx:create": "mkdirp .examples && cd .examples && node ../bin/cli.js -t tsx -n tsx",
"examples:tsx:create": "mkdirp .examples && cd .examples && node ../bin/cli.js tsx -t tsx -l en -p 3000",
"examples:tsx:install": "cd .examples/tsx && npm install",
"examples:tsx:build": "cd .examples/tsx && npm run build",
"examples:tsx:start": "cd .examples/tsx && npm start",
"examples:onepage:clean": "rimraf .examples/onepage",
"examples:onepage:create": "mkdirp .examples/onepage && cd .examples/onepage && node ../../bin/cli.js -t onepage -n index",
"examples:onepage:create": "mkdirp .examples/onepage && cd .examples/onepage && node ../../bin/cli.js index -t onepage -l en",
"examples:onepage:install": "echo unused",
"examples:onepage:build": "echo unused",
"examples:onepage:start": "pnpm exec serve .examples/onepage"
Expand Down
158 changes: 124 additions & 34 deletions packages/create-spectacle/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
#!/usr/bin/env node

import fs from 'node:fs';
import path from 'node:path';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import chalk from 'chalk';
import { Command } from 'commander';
import cliSpinners from 'cli-spinners';
import logUpdate from 'log-update';
import prompts from 'prompts';
import {
FileOptions,
writeWebpackProjectFiles,
Expand All @@ -12,49 +16,127 @@ import {
// @ts-ignore
import { version, devDependencies } from '../package.json';

type CLIOptions = {
type: 'tsx' | 'jsx' | 'mdx' | 'onepage';
name: string;
lang?: string;
port?: number;
};
const argv = yargs(hideBin(process.argv)).argv;
const cwd = process.cwd();

enum ArgName {
type = 'type',
name = 'name',
lang = 'lang',
port = 'port',
overwrite = 'overwrite'
}

const DeckTypeOptions = [
{ title: chalk.cyan('tsx'), value: 'tsx' },
{ title: chalk.yellow('jsx'), value: 'jsx' },
// { title: chalk.red('mdx'), value: 'mdx' },
{ title: chalk.green('One Page'), value: 'onepage' }
];

let progressInterval: NodeJS.Timer;
const log = console.log;
const program = new Command();
const printConsoleError = (message: string) =>
chalk.whiteBright.bgRed.bold(' ! ') + chalk.red.bold(' ' + message + '\n');
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

const main = async () => {
log(chalk.whiteBright.bgMagenta.bold(' Spectacle CLI '));

program
.name('create-spectacle')
.description('CLI to bootstrap Spectacle decks')
.version(version)
.showHelpAfterError()
.configureOutput({
outputError: (message, write) =>
write(
chalk.whiteBright.bgRed.bold(' ! ') +
chalk.red.bold(' ' + message.replace('error: ', ''))
)
})
.requiredOption(
'-t, --type <type>',
'deck source type (choices: "tsx", "jsx", "mdx", "onepage")'
)
.requiredOption('-n, --name [name]', 'name of presentation')
.option(
'-l, --lang [lang]',
'language code for generated HTML document, default: en'
)
.option('-p, --port [port]', 'port for webpack dev server, default: 3000')
.parse(process.argv);

let i = 0;
const { type, name, lang = 'en', port = 3000 } = program.opts<CLIOptions>();
let type = argv[ArgName.type] || argv['t'];
let name = argv['_']?.[0];
let lang = argv[ArgName.lang] || argv['l'] || 'en';
let port = argv[ArgName.port] || argv['p'] || 3000;

const isTryingToOverwrite = Boolean(name) && !isFolderNameAvailable(name);

/**
* If type/name not both provided via CLI flags, prompt for them.
*/
const hasType = Boolean(type);
const hasName = Boolean(name);
const hasLang = Boolean(lang);
const hasPort = type === 'onepage' || Boolean(port); // onepage has no port
if (!(hasType && hasName && hasLang && hasPort) || isTryingToOverwrite) {
try {
const response = await prompts(
[
// Name prompt
{
type: 'text',
name: ArgName.name as string,
message: 'What is the name of the presentation?',
initial: name,
validate: async (val) => {
return val.trim().length > 0 ? true : 'Name is required';
}
},
// If output directory already exists, prompt to overwrite
{
type: (val) => (isFolderNameAvailable(val) ? null : 'confirm'),
name: ArgName.overwrite as string,
message: (val) =>
`Target directory ${formatProjectDirName(
val
)} already exists. Overwrite and continue?`
},
// Check overwrite comes back false, we need to abort.
{
type: (_, answers) => {
if (answers?.[ArgName.overwrite] === false) {
throw new Error('❌ Operation cancelled');
}
return null;
},
name: 'overwriteAborter'
},
{
type: 'select',
name: ArgName.type as string,
message: 'What type of deck do you want to create?',
choices: DeckTypeOptions,
initial: (() => {
const ind = DeckTypeOptions.findIndex((o) => o.value === type);
return ind > -1 ? ind : 0;
})()
},
// Language prompt
{
type: 'text',
name: ArgName.lang as string,
message:
'What is the language code for the generated HTML document?',
initial: lang,
validate: async (val) => {
return val.trim().length > 0 ? true : 'Language code is required';
}
},
{
// Don't prompt for this if onepage
type: (_, answers) =>
answers?.[ArgName.type] === 'onepage' ? null : 'text',
name: ArgName.port as string,
message: 'What port should the webpack dev server run on?',
initial: port
}
],
{
onCancel: () => {
throw new Error('❌ Operation cancelled');
}
}
);

if (response.type) type = response.type;
if (response.name) name = response.name;
lang = response.lang;
port = response.port;
} catch (err) {
console.log(chalk.red(err.message));
return;
}
}

progressInterval = setInterval(() => {
const { frames } = cliSpinners.aesthetic;
Expand All @@ -67,7 +149,7 @@ const main = async () => {
await sleep(750);

const fileOptions: FileOptions = {
snakeCaseName: name.toLowerCase().replace(/([^a-z0-9]+)/gi, '-'),
snakeCaseName: formatProjectDirName(name),
name,
lang,
port,
Expand All @@ -94,6 +176,14 @@ const main = async () => {
);
};

const formatProjectDirName = (name: string) =>
name.toLowerCase().replace(/([^a-z0-9]+)/gi, '-');

const isFolderNameAvailable = (name: string) => {
const dir = path.join(cwd, formatProjectDirName(name));
return !fs.existsSync(dir);
};

main().catch((err) => {
clearInterval(progressInterval);
logUpdate(printConsoleError(err.message));
Expand Down
5 changes: 3 additions & 2 deletions packages/create-spectacle/test/e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { Browser, Page } from 'puppeteer';
import puppeteer from 'puppeteer';
import { getLaunchOptions } from './util';

describe('App.js', () => {
let browser;
let page;
let browser: Browser;
let page: Page;

beforeAll(async () => {
const launchOpts = await getLaunchOptions();
Expand Down
Loading