Skip to content

Commit d12d7e8

Browse files
authored
create-spectacle prompts (#1195)
* Initial prompts setup * Overwrite flags * Update e2e tests * Add package README, allow for prompt bypass for onepage if no port provided * Remove mdx as option * Explicate boolean logic a bit * Add changeset * Remove mdx from README
1 parent afb28e4 commit d12d7e8

File tree

6 files changed

+1090
-967
lines changed

6 files changed

+1090
-967
lines changed

.changeset/nasty-phones-accept.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'create-spectacle': minor
3+
---
4+
5+
Added interactive CLI prompt
Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,29 @@
11
# `create-spectacle`
22

3-
TODO: Write a README for the migrated project generator.
3+
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):
4+
5+
```shell
6+
yarn create spectacle # yarn
7+
npm create spectacle # npm
8+
npx create-spectacle # using npx
9+
pnpm create spectacle # using pnpm
10+
```
11+
12+
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.
13+
14+
## Flags
15+
16+
`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:
17+
18+
- Pass a project name as the main argument to specify a project name, e.g. `yarn create spectacle my-presentation`.
19+
- 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`.
20+
- Pass the `--lang` or `-l` flag to specify the HTML lang attribute for your presentation. Example: `yarn create spectacle -l en my-presentation`.
21+
- Pass the `--port` or `-p` flag to specify the port to run the presentation on. Example: `yarn create spectacle -p 8080 my-presentation`.
22+
23+
### Bypassing Prompts
24+
25+
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:
26+
27+
```shell
28+
yarn create spectacle -t jsx -l en -p 8080 my-presentation
29+
```

packages/create-spectacle/package.json

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,18 @@
1414
"url": "https://github.com/FormidableLabs/spectacle.git"
1515
},
1616
"dependencies": {
17+
"@types/yargs": "^17.0.11",
1718
"chalk": "^4.1.2",
1819
"clear": "^0.1.0",
1920
"cli-spinners": "^2.6.1",
20-
"commander": "^9.3.0",
21-
"log-update": "4.0.0"
21+
"log-update": "4.0.0",
22+
"prompts": "^2.4.2",
23+
"yargs": "^17.5.1"
2224
},
23-
"peerDependencies": {},
2425
"devDependencies": {
25-
"spectacle": "workspace:*",
26-
"@types/node": "^18.0.3"
26+
"@types/node": "^18.0.3",
27+
"@types/prompts": "^2.0.14",
28+
"spectacle": "workspace:*"
2729
},
2830
"resolutions": {},
2931
"scripts": {
@@ -36,17 +38,17 @@
3638
"examples:clean": "rimraf .examples",
3739
"examples:test": "nps jest",
3840
"examples:jsx:clean": "rimraf .examples/jsx",
39-
"examples:jsx:create": "mkdirp .examples && cd .examples && node ../bin/cli.js -t jsx -n jsx",
41+
"examples:jsx:create": "mkdirp .examples && cd .examples && node ../bin/cli.js jsx -t jsx -l en -p 3000",
4042
"examples:jsx:install": "cd .examples/jsx && npm install",
4143
"examples:jsx:build": "cd .examples/jsx && npm run build",
4244
"examples:jsx:start": "cd .examples/jsx && npm start",
4345
"examples:tsx:clean": "rimraf .examples/tsx",
44-
"examples:tsx:create": "mkdirp .examples && cd .examples && node ../bin/cli.js -t tsx -n tsx",
46+
"examples:tsx:create": "mkdirp .examples && cd .examples && node ../bin/cli.js tsx -t tsx -l en -p 3000",
4547
"examples:tsx:install": "cd .examples/tsx && npm install",
4648
"examples:tsx:build": "cd .examples/tsx && npm run build",
4749
"examples:tsx:start": "cd .examples/tsx && npm start",
4850
"examples:onepage:clean": "rimraf .examples/onepage",
49-
"examples:onepage:create": "mkdirp .examples/onepage && cd .examples/onepage && node ../../bin/cli.js -t onepage -n index",
51+
"examples:onepage:create": "mkdirp .examples/onepage && cd .examples/onepage && node ../../bin/cli.js index -t onepage -l en",
5052
"examples:onepage:install": "echo unused",
5153
"examples:onepage:build": "echo unused",
5254
"examples:onepage:start": "pnpm exec serve .examples/onepage"

packages/create-spectacle/src/cli.ts

Lines changed: 124 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
#!/usr/bin/env node
22

3+
import fs from 'node:fs';
4+
import path from 'node:path';
5+
import yargs from 'yargs';
6+
import { hideBin } from 'yargs/helpers';
37
import chalk from 'chalk';
4-
import { Command } from 'commander';
58
import cliSpinners from 'cli-spinners';
69
import logUpdate from 'log-update';
10+
import prompts from 'prompts';
711
import {
812
FileOptions,
913
writeWebpackProjectFiles,
@@ -12,49 +16,127 @@ import {
1216
// @ts-ignore
1317
import { version, devDependencies } from '../package.json';
1418

15-
type CLIOptions = {
16-
type: 'tsx' | 'jsx' | 'mdx' | 'onepage';
17-
name: string;
18-
lang?: string;
19-
port?: number;
20-
};
19+
const argv = yargs(hideBin(process.argv)).argv;
20+
const cwd = process.cwd();
21+
22+
enum ArgName {
23+
type = 'type',
24+
name = 'name',
25+
lang = 'lang',
26+
port = 'port',
27+
overwrite = 'overwrite'
28+
}
29+
30+
const DeckTypeOptions = [
31+
{ title: chalk.cyan('tsx'), value: 'tsx' },
32+
{ title: chalk.yellow('jsx'), value: 'jsx' },
33+
// { title: chalk.red('mdx'), value: 'mdx' },
34+
{ title: chalk.green('One Page'), value: 'onepage' }
35+
];
2136

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

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

32-
program
33-
.name('create-spectacle')
34-
.description('CLI to bootstrap Spectacle decks')
35-
.version(version)
36-
.showHelpAfterError()
37-
.configureOutput({
38-
outputError: (message, write) =>
39-
write(
40-
chalk.whiteBright.bgRed.bold(' ! ') +
41-
chalk.red.bold(' ' + message.replace('error: ', ''))
42-
)
43-
})
44-
.requiredOption(
45-
'-t, --type <type>',
46-
'deck source type (choices: "tsx", "jsx", "mdx", "onepage")'
47-
)
48-
.requiredOption('-n, --name [name]', 'name of presentation')
49-
.option(
50-
'-l, --lang [lang]',
51-
'language code for generated HTML document, default: en'
52-
)
53-
.option('-p, --port [port]', 'port for webpack dev server, default: 3000')
54-
.parse(process.argv);
55-
5646
let i = 0;
57-
const { type, name, lang = 'en', port = 3000 } = program.opts<CLIOptions>();
47+
let type = argv[ArgName.type] || argv['t'];
48+
let name = argv['_']?.[0];
49+
let lang = argv[ArgName.lang] || argv['l'] || 'en';
50+
let port = argv[ArgName.port] || argv['p'] || 3000;
51+
52+
const isTryingToOverwrite = Boolean(name) && !isFolderNameAvailable(name);
53+
54+
/**
55+
* If type/name not both provided via CLI flags, prompt for them.
56+
*/
57+
const hasType = Boolean(type);
58+
const hasName = Boolean(name);
59+
const hasLang = Boolean(lang);
60+
const hasPort = type === 'onepage' || Boolean(port); // onepage has no port
61+
if (!(hasType && hasName && hasLang && hasPort) || isTryingToOverwrite) {
62+
try {
63+
const response = await prompts(
64+
[
65+
// Name prompt
66+
{
67+
type: 'text',
68+
name: ArgName.name as string,
69+
message: 'What is the name of the presentation?',
70+
initial: name,
71+
validate: async (val) => {
72+
return val.trim().length > 0 ? true : 'Name is required';
73+
}
74+
},
75+
// If output directory already exists, prompt to overwrite
76+
{
77+
type: (val) => (isFolderNameAvailable(val) ? null : 'confirm'),
78+
name: ArgName.overwrite as string,
79+
message: (val) =>
80+
`Target directory ${formatProjectDirName(
81+
val
82+
)} already exists. Overwrite and continue?`
83+
},
84+
// Check overwrite comes back false, we need to abort.
85+
{
86+
type: (_, answers) => {
87+
if (answers?.[ArgName.overwrite] === false) {
88+
throw new Error('❌ Operation cancelled');
89+
}
90+
return null;
91+
},
92+
name: 'overwriteAborter'
93+
},
94+
{
95+
type: 'select',
96+
name: ArgName.type as string,
97+
message: 'What type of deck do you want to create?',
98+
choices: DeckTypeOptions,
99+
initial: (() => {
100+
const ind = DeckTypeOptions.findIndex((o) => o.value === type);
101+
return ind > -1 ? ind : 0;
102+
})()
103+
},
104+
// Language prompt
105+
{
106+
type: 'text',
107+
name: ArgName.lang as string,
108+
message:
109+
'What is the language code for the generated HTML document?',
110+
initial: lang,
111+
validate: async (val) => {
112+
return val.trim().length > 0 ? true : 'Language code is required';
113+
}
114+
},
115+
{
116+
// Don't prompt for this if onepage
117+
type: (_, answers) =>
118+
answers?.[ArgName.type] === 'onepage' ? null : 'text',
119+
name: ArgName.port as string,
120+
message: 'What port should the webpack dev server run on?',
121+
initial: port
122+
}
123+
],
124+
{
125+
onCancel: () => {
126+
throw new Error('❌ Operation cancelled');
127+
}
128+
}
129+
);
130+
131+
if (response.type) type = response.type;
132+
if (response.name) name = response.name;
133+
lang = response.lang;
134+
port = response.port;
135+
} catch (err) {
136+
console.log(chalk.red(err.message));
137+
return;
138+
}
139+
}
58140

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

69151
const fileOptions: FileOptions = {
70-
snakeCaseName: name.toLowerCase().replace(/([^a-z0-9]+)/gi, '-'),
152+
snakeCaseName: formatProjectDirName(name),
71153
name,
72154
lang,
73155
port,
@@ -94,6 +176,14 @@ const main = async () => {
94176
);
95177
};
96178

179+
const formatProjectDirName = (name: string) =>
180+
name.toLowerCase().replace(/([^a-z0-9]+)/gi, '-');
181+
182+
const isFolderNameAvailable = (name: string) => {
183+
const dir = path.join(cwd, formatProjectDirName(name));
184+
return !fs.existsSync(dir);
185+
};
186+
97187
main().catch((err) => {
98188
clearInterval(progressInterval);
99189
logUpdate(printConsoleError(err.message));

packages/create-spectacle/test/e2e.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
import type { Browser, Page } from 'puppeteer';
12
import puppeteer from 'puppeteer';
23
import { getLaunchOptions } from './util';
34

45
describe('App.js', () => {
5-
let browser;
6-
let page;
6+
let browser: Browser;
7+
let page: Page;
78

89
beforeAll(async () => {
910
const launchOpts = await getLaunchOptions();

0 commit comments

Comments
 (0)