@skriptfabrik/elements-cli 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ version: 2
2
+
3
+ registries:
4
+ dockerhub:
5
+ type: 'docker-registry'
6
+ url: 'https://registry.hub.docker.com'
7
+ username: '${{ secrets.DOCKERHUB_USERNAME }}'
8
+ password: '${{ secrets.DOCKERHUB_TOKEN }}'
9
+ replaces-base: true
10
+
11
+ updates:
12
+ - package-ecosystem: 'github-actions'
13
+ directory: '/'
14
+ reviewers:
15
+ - 'skriptfabrik/developers'
16
+ schedule:
17
+ interval: 'weekly'
18
+ time: '08:00'
19
+ timezone: 'Europe/Berlin'
20
+
21
+ - package-ecosystem: 'npm'
22
+ directory: '/'
23
+ reviewers:
24
+ - 'skriptfabrik/developers'
25
+ schedule:
26
+ interval: 'weekly'
27
+ time: '08:00'
28
+ timezone: 'Europe/Berlin'
29
+
30
+ - package-ecosystem: 'docker'
31
+ directory: '/'
32
+ reviewers:
33
+ - 'skriptfabrik/developers'
34
+ registries:
35
+ - 'dockerhub'
36
+ schedule:
37
+ interval: 'weekly'
38
+ time: '08:00'
39
+ timezone: 'Europe/Berlin'
@@ -0,0 +1,12 @@
1
+ ## Describe your changes
2
+
3
+ - [ ] Bug fix (non-breaking change which fixes an issue)
4
+ - [ ] New feature (non-breaking change which adds functionality)
5
+ - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
6
+
7
+ ## Checklist before requesting a review
8
+
9
+ - [ ] I have included the ticket number of the issue at the beginning of the title of this pull request.
10
+ - [ ] I have updated the docs and/or specs to reflect this change.
11
+ - [ ] I have performed a self-review of my code.
12
+ - [ ] I have added thorough tests.
@@ -68,7 +68,7 @@ jobs:
68
68
 
69
69
  - name: Build and Push latest Docker image
70
70
  if: github.ref_name == 'main'
71
- uses: docker/build-push-action@v2
71
+ uses: docker/build-push-action@v4
72
72
  with:
73
73
  build-args: |-
74
74
  BUILDKIT_INLINE_CACHE=${{ env.DOCKER_BUILDKIT_INLINE_CACHE }}
@@ -90,7 +90,7 @@ jobs:
90
90
 
91
91
  - name: Build and Push Docker image release version
92
92
  if: github.ref_type == 'tag'
93
- uses: docker/build-push-action@v2
93
+ uses: docker/build-push-action@v4
94
94
  with:
95
95
  build-args: |-
96
96
  BUILDKIT_INLINE_CACHE=${{ env.DOCKER_BUILDKIT_INLINE_CACHE }}
package/Dockerfile CHANGED
@@ -1,4 +1,4 @@
1
- FROM node:18.2.0-alpine
1
+ FROM node:18.14.0-alpine
2
2
 
3
3
  LABEL maintainer="Daniel Schröder <daniel.schroeder@skriptfabrik.com>"
4
4
 
@@ -12,7 +12,7 @@ COPY . /opt/elements-cli-${ELEMENTS_CLI_VERSION}
12
12
  RUN set -eux; \
13
13
  npm --prefix /opt/elements-cli-${ELEMENTS_CLI_VERSION} install; \
14
14
  rm -Rf ~/.npm; \
15
- ln -s /opt/elements-cli-${ELEMENTS_CLI_VERSION}/elements-cli.js /usr/local/bin/elements
15
+ ln -s /opt/elements-cli-${ELEMENTS_CLI_VERSION}/elements-cli.mjs /usr/local/bin/elements
16
16
 
17
17
  WORKDIR /data
18
18
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  [![NPM Version](https://img.shields.io/npm/v/@skriptfabrik/elements-cli)](https://www.npmjs.com/package/@skriptfabrik/elements-cli)
2
2
  [![NPM Downloads](https://img.shields.io/npm/dt/@skriptfabrik/elements-cli)](https://www.npmjs.com/package/@skriptfabrik/elements-cli)
3
- [![Continuous Integration](https://img.shields.io/github/workflow/status/skriptfabrik/elements-cli/Continuous%20Integration)](https://github.com/skriptfabrik/elements-cli/actions/workflows/ci.yml)
3
+ [![Continuous Integration](https://img.shields.io/github/actions/workflow/status/skriptfabrik/elements-cli/ci.yml)](https://github.com/skriptfabrik/elements-cli/actions/workflows/ci.yml)
4
4
 
5
5
  # Elements CLI
6
6
 
@@ -0,0 +1,414 @@
1
+ #!/usr/bin/env node
2
+
3
+ import chalk from 'chalk';
4
+ import chokidar from 'chokidar';
5
+ import corsAnywhere from 'cors-anywhere';
6
+ import express from 'express';
7
+ import { engine } from 'express-handlebars';
8
+ import { readFile } from 'fs/promises';
9
+ import handlebars from 'handlebars';
10
+ import gracefulShutdown from 'http-graceful-shutdown';
11
+ import minimist from 'minimist';
12
+ import { createRequire } from 'module';
13
+ import path from 'path';
14
+ import send from 'send';
15
+ import { WebSocketServer } from 'ws';
16
+ import { fileURLToPath, URL } from 'url';
17
+
18
+ // Compat
19
+
20
+ const __filename = fileURLToPath(import.meta.url);
21
+ const __dirname = path.dirname(__filename);
22
+ const require = createRequire(import.meta.url);
23
+
24
+ // Package info
25
+
26
+ const pkg = JSON.parse(await readFile(path.join(__dirname, 'package.json')));
27
+
28
+ // Argument defaults
29
+
30
+ const argd = {
31
+ 'base-path': process.env.ELEMENTS_BASE_PATH || process.env.BASE_PATH || '/',
32
+ 'credentials-policy': process.env.ELEMENTS_CREDENTIALS_POLICY || process.env.CREDENTIALS_POLICY || 'omit',
33
+ hostname: process.env.ELEMENTS_HOSTNAME || 'localhost',
34
+ layout: process.env.ELEMENTS_LAYOUT || process.env.LAYOUT || 'sidebar',
35
+ logo: process.env.ELEMENTS_LOGO || process.env.LOGO,
36
+ port: parseInt(process.env.ELEMENTS_PORT || '8000'),
37
+ router: process.env.ELEMENTS_ROUTER || process.env.ROUTER || 'history',
38
+ style: process.env.ELEMENTS_STYLE || process.env.STYLE || 'flex: 1 0 0; overflow: hidden;',
39
+ title: process.env.ELEMENTS_TITLE || process.env.TITLE || 'My API Docs',
40
+ variable: (process.env.ELEMENTS_VARIABLE || process.env.VARIABLE || '').split('\n').map(variable => variable.trim()),
41
+ 'virtual-host': process.env.ELEMENTS_VIRTUAL_HOST || 'localhost',
42
+ 'virtual-port': process.env.ELEMENTS_VIRTUAL_PORT || '8000',
43
+ 'working-dir': process.cwd(),
44
+ };
45
+
46
+ // Parse arguments
47
+
48
+ const argv = minimist(process.argv.slice(2), {
49
+ boolean: ['c', 'f', 'h', 'n', 'p', 'v', 'w'],
50
+ alias: {
51
+ c: 'with-cors-proxy',
52
+ f: 'filter-internal',
53
+ h: 'help',
54
+ n: 'no-try-it',
55
+ p: 'poll',
56
+ v: 'version',
57
+ w: 'watch',
58
+ },
59
+ default: argd,
60
+ });
61
+
62
+ // Print version number
63
+
64
+ if (argv.version) {
65
+ console.log(pkg.version);
66
+ process.exit(0);
67
+ }
68
+
69
+ // Display help message
70
+
71
+ if (argv.help || argv._.length < 2 || !['export', 'preview'].includes(argv._[0])) {
72
+ if (argv._[0] === 'export') {
73
+ console.error(
74
+ `Elements CLI\n\n${chalk.yellow('Usage:')}\n%s\n\n${chalk.yellow('Arguments:')}\n%s\n\n${chalk.yellow('Options:')}\n%s\n\n${chalk.yellow('Examples:')}\n%s`,
75
+ ` ${path.basename(process.argv[1])} export [options] <openapi_json>`,
76
+ ` ${chalk.green('openapi_json')} The path or URL of the OpenAPI JSON file`,
77
+ [
78
+ ` ${chalk.green(' --base-path=BASE_PATH')} Use the given base path ${chalk.yellow('[default: "' + argd['base-path'] + '"]')}`,
79
+ ` ${chalk.green(' --credentials-policy=CREDENTIALS_POLICY')} Credentials policy for "Try It" feature: omit, include, same-origin ${chalk.yellow('[default: "' + argd['credentials-policy'] + '"]')}`,
80
+ ` ${chalk.green(' --cors-proxy=CORS_PROXY')} Provide CORS proxy`,
81
+ ` ${chalk.green('-f, --filter-internal')} Filter out any content which has been marked as internal with x-internal`,
82
+ ` ${chalk.green('-h, --help')} Display this help message`,
83
+ ` ${chalk.green(' --layout=LAYOUT')} Layout for Elements: sidebar, stacked ${chalk.yellow('[default: "' + argd.layout + '"]')}`,
84
+ ` ${chalk.green(' --logo=LOGO')} URL of an image that will show as a small square logo next to the title`,
85
+ ` ${chalk.green('-n --no-try-it')} Hide the "Try It" panel (the interactive API console)`,
86
+ ` ${chalk.green(' --router=ROUTER')} Determines how navigation should work: history, hash, memory, static ${chalk.yellow('[default: "' + argd.router + '"]')}`,
87
+ ` ${chalk.green(' --style=STYLE')} Additional style for Elements ${chalk.yellow('[default: "' + argd.style + '"]')}`,
88
+ ` ${chalk.green(' --title=TITLE')} API docs title ${chalk.yellow('[default: "' + argd.title + '"]')}`,
89
+ ` ${chalk.green(' --variable=VARIABLE')} Variable to be replaced in the OpenAPI document`,
90
+ ` ${chalk.green('-v, --version')} Print version number`,
91
+ ].join('\n'),
92
+ [
93
+ ` Export rendered API docs based on local ${chalk.magenta('openapi.json')} path as ${chalk.magenta('index.html')}:`,
94
+ ``,
95
+ ` ${chalk.green(path.basename(process.argv[1]) + ' export openapi.json > index.html')}`,
96
+ ``,
97
+ ` Export rendered Swagger Petstore docs based on remote ${chalk.magenta('https://petstore.swagger.io/v2/swagger.json')} URL as ${chalk.magenta('index.html')}:`,
98
+ ``,
99
+ ` ${chalk.green(path.basename(process.argv[1]) + ' export --title="Swagger Petstore" https://petstore.swagger.io/v2/swagger.json > index.html')}`,
100
+ ].join('\n'),
101
+ );
102
+ } else if (argv._[0] === 'preview') {
103
+ console.error(
104
+ `Elements CLI\n\n${chalk.yellow('Usage:')}\n%s\n\n${chalk.yellow('Arguments:')}\n%s\n\n${chalk.yellow('Options:')}\n%s\n\n${chalk.yellow('Examples:')}\n%s`,
105
+ ` ${path.basename(process.argv[1])} preview [options] <openapi_json>`,
106
+ ` ${chalk.green('openapi_json')} The path or URL of the OpenAPI JSON file`,
107
+ [
108
+ ` ${chalk.green(' --base-path=BASE_PATH')} Use the given base path ${chalk.yellow('[default: "' + argd['base-path'] + '"]')}`,
109
+ ` ${chalk.green(' --credentials-policy=CREDENTIALS_POLICY')} Credentials policy for "Try It" feature: omit, include, same-origin ${chalk.yellow('[default: "' + argd['credentials-policy'] + '"]')}`,
110
+ ` ${chalk.green('-c --with-cors-proxy')} Enable CORS proxy capabilities`,
111
+ ` ${chalk.green('-f, --filter-internal')} Filter out any content which has been marked as internal with x-internal`,
112
+ ` ${chalk.green('-h, --help')} Display this help message`,
113
+ ` ${chalk.green(' --hostname=HOSTNAME')} Server hostname ${chalk.yellow('[default: "' + argd.hostname + '"]')}`,
114
+ ` ${chalk.green(' --layout=LAYOUT')} Layout for Elements: sidebar, stacked ${chalk.yellow('[default: "' + argd.layout + '"]')}`,
115
+ ` ${chalk.green(' --logo=LOGO')} URL of an image that will show as a small square logo next to the title`,
116
+ ` ${chalk.green('-n --no-try-it')} Hide the "Try It" panel (the interactive API console)`,
117
+ ` ${chalk.green('-p, --poll')} Use polling instead of file system events`,
118
+ ` ${chalk.green(' --port=PORT')} Server port ${chalk.yellow('[default: ' + argd.port + ']')}`,
119
+ ` ${chalk.green(' --router=ROUTER')} Determines how navigation should work: history, hash, memory, static ${chalk.yellow('[default: "' + argd.router + '"]')}`,
120
+ ` ${chalk.green(' --style=STYLE')} Additional style for Elements ${chalk.yellow('[default: "' + argd.style + '"]')}`,
121
+ ` ${chalk.green(' --title=TITLE')} API docs title ${chalk.yellow('[default: "' + argd.title + '"]')}`,
122
+ ` ${chalk.green(' --variable=VARIABLE')} Variable to be replaced in the OpenAPI document`,
123
+ ` ${chalk.green('-v, --version')} Print version number`,
124
+ ` ${chalk.green('-w --watch')} Watch for changes and reload (only for local files)`,
125
+ ` ${chalk.green(' --virtual-host=VIRTUAL_HOST')} Reported hostname ${chalk.yellow('[default: ' + argd['virtual-host'] + ']')}`,
126
+ ` ${chalk.green(' --virtual-port=VIRTUAL_PORT')} Reported port ${chalk.yellow('[default: ' + argd['virtual-port'] + ']')}`,
127
+ ` ${chalk.green(' --working-dir=PWD')} Use the given directory as working directory`,
128
+ ].join('\n'),
129
+ [
130
+ ` Preview rendered API docs based on local ${chalk.magenta('openapi.json')} path:`,
131
+ ``,
132
+ ` ${chalk.green(path.basename(process.argv[1]) + ' preview openapi.json')}`,
133
+ ``,
134
+ ` Preview rendered Swagger Petstore docs based on remote ${chalk.magenta('https://petstore.swagger.io/v2/swagger.json')} URL:`,
135
+ ``,
136
+ ` ${chalk.green(path.basename(process.argv[1]) + ' preview --title="Swagger Petstore" https://petstore.swagger.io/v2/swagger.json')}`,
137
+ '',
138
+ ` Preview local API docs, enable CORS proxy and watch/reload on data changes:`,
139
+ ``,
140
+ ` ${chalk.green(path.basename(process.argv[1]) + ' preview -cw openapi.json')}`,
141
+ ].join('\n'),
142
+ );
143
+ } else {
144
+ console.error(
145
+ `Elements CLI\n\n${chalk.yellow('Usage:')}\n%s\n\n${chalk.yellow('Options:')}\n%s\n\n${chalk.yellow('Commands:')}\n%s`,
146
+ ` ${path.basename(process.argv[1])} command [options] [arguments]`,
147
+ [
148
+ ` ${chalk.green('-h, --help')} Display this help message`,
149
+ ` ${chalk.green('-v, --version')} Print version number`,
150
+ ].join('\n'),
151
+ [
152
+ ` ${chalk.green('export')} Export rendered API docs`,
153
+ ` ${chalk.green('preview')} Preview rendered API docs`,
154
+ ].join('\n'),
155
+ );
156
+ }
157
+
158
+ process.exit(argv.help ? 0 : 1);
159
+ }
160
+
161
+ // Watching remote files is not supported
162
+
163
+ if (/^http(s)?:\/\//i.test(argv._[1])) {
164
+ argv.watch = false;
165
+ }
166
+
167
+ /**
168
+ * Replace double forward slashes, removes trailing slashes and optionally appends suffix
169
+ *
170
+ * @param {string} str The input string
171
+ * @param {string} suffix The optional suffix
172
+ *
173
+ * @returns {string}
174
+ */
175
+ function sanitize(str, suffix = '') {
176
+ return str.replace(/\/+/g, '/').replace(/\/$/, '') + suffix;
177
+ }
178
+
179
+ /**
180
+ * Upgrade HTTP server with web socket server capabilities
181
+ *
182
+ * @param {http.Server} server The HTTP server instance
183
+ *
184
+ * @returns {ws.WebSocketServer}
185
+ */
186
+ function upgrade(server) {
187
+ const wss = new WebSocketServer({ server });
188
+
189
+ return wss.on('connection', (socket) => {
190
+ socket.on('message', (message) => {
191
+ const request = JSON.parse(message);
192
+
193
+ if (request.command === 'hello') {
194
+ const data = JSON.stringify({
195
+ command: 'hello',
196
+ protocols: [
197
+ 'http://livereload.com/protocols/official-7',
198
+ 'http://livereload.com/protocols/official-8',
199
+ 'http://livereload.com/protocols/official-9',
200
+ 'http://livereload.com/protocols/2.x-origin-version-negotiation',
201
+ 'http://livereload.com/protocols/2.x-remote-control',
202
+ ],
203
+ serverName: 'elements-server',
204
+ });
205
+
206
+ socket.send(data);
207
+ }
208
+ });
209
+ });
210
+ }
211
+
212
+ /**
213
+ * Create file system watcher and broatcast all file changes to every client
214
+ *
215
+ * @param {string} filePath The file path to watch
216
+ * @param {ws.WebSocketServer} server The web socket server instance
217
+ *
218
+ * @returns {chokidar.FSWatcher}
219
+ */
220
+ function watch(filePath, server) {
221
+ const watcher = chokidar.watch(filePath, {
222
+ ignoreInitial: true,
223
+ usePolling: argv.poll,
224
+ });
225
+
226
+ return watcher.on('all', (filePath) => {
227
+ const data = JSON.stringify({
228
+ command: 'reload',
229
+ path: filePath,
230
+ });
231
+
232
+ server.clients.forEach((socket) => socket.send(data));
233
+ });
234
+ }
235
+
236
+ // Define base href
237
+
238
+ const baseHref = sanitize(`/${argv['base-path']}`, '/');
239
+
240
+ // Define delimiters and variables
241
+
242
+ const delimiters = { open: '{{', close: '}}' },
243
+ variables = [argv.variable]
244
+ .flat()
245
+ .filter((variable) => !!variable)
246
+ .reduce((variables, variable) => {
247
+ const [name, value] = variable.split('=');
248
+ variables[name] = value;
249
+ return variables;
250
+ }, {});
251
+
252
+ // Export rendered API docs
253
+
254
+ if (argv._[0] === 'export') {
255
+ const input = await readFile(path.resolve(__dirname, 'views', 'index.handlebars'));
256
+ const template = handlebars.compile(input.toString('utf8'));
257
+ const version = pkg.dependencies['@stoplight/elements'];
258
+
259
+ let tryItCorsProxy;
260
+
261
+ if (argv['cors-proxy'] && !argv['no-try-it']) {
262
+ tryItCorsProxy = argv['cors-proxy'];
263
+ }
264
+
265
+ console.log(
266
+ template({
267
+ baseHref,
268
+ delimiters,
269
+ elements: {
270
+ apiDescriptionUrl: argv._[1],
271
+ basePath: baseHref,
272
+ hideInternal: argv['filter-internal'] ? 'true' : undefined,
273
+ hideTryIt: argv['no-try-it'] ? 'true' : undefined,
274
+ tryItCorsProxy,
275
+ tryItCredentialsPolicy: argv['credentials-policy'],
276
+ layout: argv.layout,
277
+ logo: argv.logo,
278
+ router: argv.router,
279
+ style: argv.style,
280
+ },
281
+ 'elements-css': `https://unpkg.com/@stoplight/elements@${version}/styles.min.css`,
282
+ 'elements-js': `https://unpkg.com/@stoplight/elements@${version}/web-components.min.js`,
283
+ layout: false,
284
+ livereload: false,
285
+ title: argv.title,
286
+ variables,
287
+ })
288
+ );
289
+
290
+ process.exit(0);
291
+ }
292
+
293
+ // Create express app
294
+
295
+ const app = express();
296
+
297
+ // Enable Handlebars view engine
298
+
299
+ app.engine('handlebars', engine());
300
+ app.set('view engine', 'handlebars');
301
+ app.set('views', path.join(__dirname, 'views'));
302
+
303
+ // Serve assets from node_modules
304
+
305
+ const assets = {
306
+ 'livereload.js': require.resolve('livereload-js/dist/livereload.min.js'),
307
+ 'styles.min.css': require.resolve('@stoplight/elements/styles.min.css'),
308
+ 'web-components.min.js': require.resolve('@stoplight/elements/web-components.min.js'),
309
+ };
310
+
311
+ app.get(
312
+ Object.keys(assets).map((asset) =>
313
+ sanitize(`/${argv['base-path']}/${asset}`)
314
+ ),
315
+ (req, res) => {
316
+ const url = new URL(req.url, `http://${req.headers.host}`);
317
+
318
+ send(req, assets[path.basename(url.pathname)]).pipe(res);
319
+ }
320
+ );
321
+
322
+ // Serve static files from working directory
323
+
324
+ app.use(
325
+ sanitize(`/${argv['base-path']}`),
326
+ express.static(argv['working-dir'], { index: false })
327
+ );
328
+
329
+ // Handle CORS proxy requests
330
+
331
+ if (argv['with-cors-proxy'] && !argv['no-try-it']) {
332
+ const proxy = corsAnywhere.createServer({
333
+ originWhitelist: [], // Allow all origins
334
+ requireHeaders: [], // Do not require any headers
335
+ removeHeaders: [], // Do not remove any headers
336
+ });
337
+
338
+ app.all(sanitize(`/${argv['base-path']}/_/*`), (req, res) => {
339
+ const pos = req.originalUrl.indexOf('?');
340
+ const queryString = pos === -1 ? '' : req.originalUrl.substring(pos);
341
+
342
+ req.url = `/${req.params['0']}${queryString}`;
343
+
344
+ proxy.emit('request', req, res);
345
+ });
346
+ }
347
+
348
+ // Render and serve index template
349
+
350
+ app.get(
351
+ [sanitize(`/${argv['base-path']}`, '*'), sanitize(`/${argv['base-path']}`)],
352
+ (req, res) => {
353
+ let tryItCorsProxy;
354
+
355
+ if (argv['with-cors-proxy'] && !argv['no-try-it']) {
356
+ tryItCorsProxy = `http://${req.headers.host}${baseHref}_/`;
357
+ }
358
+
359
+ res.render('index', {
360
+ baseHref,
361
+ delimiters,
362
+ elements: {
363
+ apiDescriptionUrl: argv._[1],
364
+ basePath: baseHref,
365
+ hideInternal: argv['filter-internal'] ? 'true' : undefined,
366
+ hideTryIt: argv['no-try-it'] ? 'true' : undefined,
367
+ tryItCorsProxy,
368
+ tryItCredentialsPolicy: argv['credentials-policy'],
369
+ layout: argv.layout,
370
+ logo: argv.logo,
371
+ router: argv.router,
372
+ style: argv.style,
373
+ },
374
+ 'elements-css': 'styles.min.css',
375
+ 'elements-js': 'web-components.min.js',
376
+ layout: false,
377
+ 'livereload-js': argv.watch ? 'livereload.js' : undefined,
378
+ title: argv.title,
379
+ variables,
380
+ });
381
+ }
382
+ );
383
+
384
+ // Listen for HTTP connections
385
+
386
+ const server = app.listen(argv.port, argv.hostname, () => {
387
+ console.error(`Elements server listening on ${argv.hostname}:${argv.port}`);
388
+ console.error(`Visit http://${argv['virtual-host']}:${argv['virtual-port']}${baseHref}`);
389
+ });
390
+
391
+ // Watch files in working directory and launch web socket server
392
+
393
+ const watcher = argv.watch
394
+ ? watch(
395
+ argv['working-dir'],
396
+ upgrade(server).on('error', (err) => console.error(err))
397
+ )
398
+ .once('ready', () =>
399
+ console.error(`Watching ${path.resolve(argv['working-dir'])}`)
400
+ )
401
+ .on('error', (err) => console.error(err))
402
+ : undefined;
403
+
404
+ // Enable the graceful shutdown
405
+
406
+ gracefulShutdown(server, {
407
+ onShutdown: () =>
408
+ new Promise((resolve) => {
409
+ if (watcher) {
410
+ watcher.close();
411
+ }
412
+ resolve();
413
+ }),
414
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skriptfabrik/elements-cli",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "The missing CLI for beautiful, interactive API docs powered by with Stoplight Elements",
5
5
  "keywords": [
6
6
  "stoplight",
@@ -21,20 +21,20 @@
21
21
  },
22
22
  "license": "MIT",
23
23
  "dependencies": {
24
- "@stoplight/elements": "7.7.3",
25
- "chalk": "^4.0.0",
24
+ "@stoplight/elements": "7.7.13",
25
+ "chalk": "^5.2.0",
26
26
  "chokidar": "^3.5.3",
27
27
  "cors-anywhere": "~0.4.4",
28
28
  "express": "^4.18.2",
29
- "express-handlebars": "^6.0.6",
29
+ "express-handlebars": "^7.0.4",
30
30
  "handlebars": "^4.7.7",
31
- "http-graceful-shutdown": "^3.1.9",
32
- "livereload-js": "3.4.1",
31
+ "http-graceful-shutdown": "^3.1.12",
32
+ "livereload-js": "^4.0.1",
33
33
  "minimist": "^1.2.7",
34
34
  "send": "~0.18.0",
35
- "ws": "^8.10.0"
35
+ "ws": "^8.13.0"
36
36
  },
37
37
  "bin": {
38
- "elements": "./elements-cli.js"
38
+ "elements": "./elements-cli.mjs"
39
39
  }
40
40
  }
package/elements-cli.js DELETED
@@ -1,392 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- const chalk = require('chalk');
4
- const chokidar = require('chokidar');
5
- const { compile } = require('handlebars');
6
- const corsAnywhere = require('cors-anywhere');
7
- const express = require('express');
8
- const fs = require('fs');
9
- const gracefulShutdown = require('http-graceful-shutdown');
10
- const handlebars = require('express-handlebars');
11
- const minimist = require('minimist');
12
- const path = require('path');
13
- const pkg = require('./package.json');
14
- const send = require('send');
15
- const { Server } = require('ws');
16
- const { URL } = require('url');
17
-
18
- // Argument defaults
19
-
20
- const argd = {
21
- 'base-path': process.env.ELEMENTS_BASE_PATH || process.env.BASE_PATH || '/',
22
- 'credentials-policy': process.env.ELEMENTS_CREDENTIALS_POLICY || process.env.CREDENTIALS_POLICY || 'omit',
23
- hostname: process.env.ELEMENTS_HOSTNAME || 'localhost',
24
- layout: process.env.ELEMENTS_LAYOUT || process.env.LAYOUT || 'sidebar',
25
- logo: process.env.ELEMENTS_LOGO || process.env.LOGO,
26
- port: parseInt(process.env.ELEMENTS_PORT || '8000'),
27
- router: process.env.ELEMENTS_ROUTER || process.env.ROUTER || 'history',
28
- style: process.env.ELEMENTS_STYLE || process.env.STYLE || 'flex: 1 0 0; overflow: hidden;',
29
- title: process.env.ELEMENTS_TITLE || process.env.TITLE || 'My API Docs',
30
- variable: (process.env.ELEMENTS_VARIABLE || process.env.VARIABLE || '').split('\n').map(variable => variable.trim()),
31
- 'virtual-host': process.env.ELEMENTS_VIRTUAL_HOST || 'localhost',
32
- 'virtual-port': process.env.ELEMENTS_VIRTUAL_PORT || '8000',
33
- 'working-dir': process.cwd(),
34
- };
35
-
36
- // Parse arguments
37
-
38
- const argv = minimist(process.argv.slice(2), {
39
- boolean: ['c', 'f', 'h', 'n', 'p', 'v', 'w'],
40
- alias: {
41
- c: 'with-cors-proxy',
42
- f: 'filter-internal',
43
- h: 'help',
44
- n: 'no-try-it',
45
- p: 'poll',
46
- v: 'version',
47
- w: 'watch',
48
- },
49
- default: argd,
50
- });
51
-
52
- // Print version number
53
-
54
- if (argv.version) {
55
- console.log(pkg.version);
56
- process.exit(0);
57
- }
58
-
59
- // Display help message
60
-
61
- if (argv.help || argv._.length < 2 || !['export', 'preview'].includes(argv._[0])) {
62
- if (argv._[0] === 'export') {
63
- console.error(
64
- `Elements CLI\n\n${chalk.yellow('Usage:')}\n%s\n\n${chalk.yellow('Arguments:')}\n%s\n\n${chalk.yellow('Options:')}\n%s\n\n${chalk.yellow('Examples:')}\n%s`,
65
- ` ${path.basename(process.argv[1])} export [options] <openapi_json>`,
66
- ` ${chalk.green('openapi_json')} The path or URL of the OpenAPI JSON file`,
67
- [
68
- ` ${chalk.green(' --base-path=BASE_PATH')} Use the given base path ${chalk.yellow('[default: "' + argd['base-path'] + '"]')}`,
69
- ` ${chalk.green(' --credentials-policy=CREDENTIALS_POLICY')} Credentials policy for "Try It" feature: omit, include, same-origin ${chalk.yellow('[default: "' + argd['credentials-policy'] + '"]')}`,
70
- ` ${chalk.green(' --cors-proxy=CORS_PROXY')} Provide CORS proxy`,
71
- ` ${chalk.green('-f, --filter-internal')} Filter out any content which has been marked as internal with x-internal`,
72
- ` ${chalk.green('-h, --help')} Display this help message`,
73
- ` ${chalk.green(' --layout=LAYOUT')} Layout for Elements: sidebar, stacked ${chalk.yellow('[default: "' + argd.layout + '"]')}`,
74
- ` ${chalk.green(' --logo=LOGO')} URL of an image that will show as a small square logo next to the title`,
75
- ` ${chalk.green('-n --no-try-it')} Hide the "Try It" panel (the interactive API console)`,
76
- ` ${chalk.green(' --router=ROUTER')} Determines how navigation should work: history, hash, memory, static ${chalk.yellow('[default: "' + argd.router + '"]')}`,
77
- ` ${chalk.green(' --style=STYLE')} Additional style for Elements ${chalk.yellow('[default: "' + argd.style + '"]')}`,
78
- ` ${chalk.green(' --title=TITLE')} API docs title ${chalk.yellow('[default: "' + argd.title + '"]')}`,
79
- ` ${chalk.green(' --variable=VARIABLE')} Variable to be replaced in the OpenAPI document`,
80
- ` ${chalk.green('-v, --version')} Print version number`,
81
- ].join('\n'),
82
- [
83
- ` Export rendered API docs based on local ${chalk.magenta('openapi.json')} path as ${chalk.magenta('index.html')}:`,
84
- ``,
85
- ` ${chalk.green(path.basename(process.argv[1]) + ' export openapi.json > index.html')}`,
86
- ``,
87
- ` Export rendered Swagger Petstore docs based on remote ${chalk.magenta('https://petstore.swagger.io/v2/swagger.json')} URL as ${chalk.magenta('index.html')}:`,
88
- ``,
89
- ` ${chalk.green(path.basename(process.argv[1]) + ' export --title="Swagger Petstore" https://petstore.swagger.io/v2/swagger.json > index.html')}`,
90
- ].join('\n'),
91
- );
92
- } else if (argv._[0] === 'preview') {
93
- console.error(
94
- `Elements CLI\n\n${chalk.yellow('Usage:')}\n%s\n\n${chalk.yellow('Arguments:')}\n%s\n\n${chalk.yellow('Options:')}\n%s\n\n${chalk.yellow('Examples:')}\n%s`,
95
- ` ${path.basename(process.argv[1])} preview [options] <openapi_json>`,
96
- ` ${chalk.green('openapi_json')} The path or URL of the OpenAPI JSON file`,
97
- [
98
- ` ${chalk.green(' --base-path=BASE_PATH')} Use the given base path ${chalk.yellow('[default: "' + argd['base-path'] + '"]')}`,
99
- ` ${chalk.green(' --credentials-policy=CREDENTIALS_POLICY')} Credentials policy for "Try It" feature: omit, include, same-origin ${chalk.yellow('[default: "' + argd['credentials-policy'] + '"]')}`,
100
- ` ${chalk.green('-c --with-cors-proxy')} Enable CORS proxy capabilities`,
101
- ` ${chalk.green('-f, --filter-internal')} Filter out any content which has been marked as internal with x-internal`,
102
- ` ${chalk.green('-h, --help')} Display this help message`,
103
- ` ${chalk.green(' --hostname=HOSTNAME')} Server hostname ${chalk.yellow('[default: "' + argd.hostname + '"]')}`,
104
- ` ${chalk.green(' --layout=LAYOUT')} Layout for Elements: sidebar, stacked ${chalk.yellow('[default: "' + argd.layout + '"]')}`,
105
- ` ${chalk.green(' --logo=LOGO')} URL of an image that will show as a small square logo next to the title`,
106
- ` ${chalk.green('-n --no-try-it')} Hide the "Try It" panel (the interactive API console)`,
107
- ` ${chalk.green('-p, --poll')} Use polling instead of file system events`,
108
- ` ${chalk.green(' --port=PORT')} Server port ${chalk.yellow('[default: ' + argd.port + ']')}`,
109
- ` ${chalk.green(' --router=ROUTER')} Determines how navigation should work: history, hash, memory, static ${chalk.yellow('[default: "' + argd.router + '"]')}`,
110
- ` ${chalk.green(' --style=STYLE')} Additional style for Elements ${chalk.yellow('[default: "' + argd.style + '"]')}`,
111
- ` ${chalk.green(' --title=TITLE')} API docs title ${chalk.yellow('[default: "' + argd.title + '"]')}`,
112
- ` ${chalk.green(' --variable=VARIABLE')} Variable to be replaced in the OpenAPI document`,
113
- ` ${chalk.green('-v, --version')} Print version number`,
114
- ` ${chalk.green('-w --watch')} Watch for changes and reload (only for local files)`,
115
- ` ${chalk.green(' --virtual-host=VIRTUAL_HOST')} Reported hostname ${chalk.yellow('[default: ' + argd['virtual-host'] + ']')}`,
116
- ` ${chalk.green(' --virtual-port=VIRTUAL_PORT')} Reported port ${chalk.yellow('[default: ' + argd['virtual-port'] + ']')}`,
117
- ` ${chalk.green(' --working-dir=PWD')} Use the given directory as working directory`,
118
- ].join('\n'),
119
- [
120
- ` Preview rendered API docs based on local ${chalk.magenta('openapi.json')} path:`,
121
- ``,
122
- ` ${chalk.green(path.basename(process.argv[1]) + ' preview openapi.json')}`,
123
- ``,
124
- ` Preview rendered Swagger Petstore docs based on remote ${chalk.magenta('https://petstore.swagger.io/v2/swagger.json')} URL:`,
125
- ``,
126
- ` ${chalk.green(path.basename(process.argv[1]) + ' preview --title="Swagger Petstore" https://petstore.swagger.io/v2/swagger.json')}`,
127
- '',
128
- ` Preview local API docs, enable CORS proxy and watch/reload on data changes:`,
129
- ``,
130
- ` ${chalk.green(path.basename(process.argv[1]) + ' preview -cw openapi.json')}`,
131
- ].join('\n'),
132
- );
133
- } else {
134
- console.error(
135
- `Elements CLI\n\n${chalk.yellow('Usage:')}\n%s\n\n${chalk.yellow('Options:')}\n%s\n\n${chalk.yellow('Commands:')}\n%s`,
136
- ` ${path.basename(process.argv[1])} command [options] [arguments]`,
137
- [
138
- ` ${chalk.green('-h, --help')} Display this help message`,
139
- ` ${chalk.green('-v, --version')} Print version number`,
140
- ].join('\n'),
141
- [
142
- ` ${chalk.green('export')} Export rendered API docs`,
143
- ` ${chalk.green('preview')} Preview rendered API docs`,
144
- ].join('\n'),
145
- );
146
- }
147
-
148
- process.exit(argv.help ? 0 : 1);
149
- }
150
-
151
- // Watching remote files is not supported
152
-
153
- if (/^http(s)?:\/\//i.test(argv._[1])) {
154
- argv.watch = false;
155
- }
156
-
157
- /**
158
- * Replace double forward slashes, removes trailing slashes and optionally appends suffix
159
- *
160
- * @param {string} str The input string
161
- * @param {string} suffix The optional suffix
162
- *
163
- * @returns {string}
164
- */
165
- function sanitize(str, suffix = '') {
166
- return str.replace(/\/+/g, '/').replace(/\/$/, '') + suffix;
167
- }
168
-
169
- /**
170
- * Upgrade HTTP server with web socket server capabilities
171
- *
172
- * @param {http.Server} server The HTTP server instance
173
- *
174
- * @returns {ws.Server}
175
- */
176
- function upgrade(server) {
177
- const wss = new Server({ server });
178
-
179
- return wss.on('connection', (socket) => {
180
- socket.on('message', (message) => {
181
- const request = JSON.parse(message);
182
-
183
- if (request.command === 'hello') {
184
- const data = JSON.stringify({
185
- command: 'hello',
186
- protocols: [
187
- 'http://livereload.com/protocols/official-7',
188
- 'http://livereload.com/protocols/official-8',
189
- 'http://livereload.com/protocols/official-9',
190
- 'http://livereload.com/protocols/2.x-origin-version-negotiation',
191
- 'http://livereload.com/protocols/2.x-remote-control',
192
- ],
193
- serverName: 'elements-server',
194
- });
195
-
196
- socket.send(data);
197
- }
198
- });
199
- });
200
- }
201
-
202
- /**
203
- * Create file system watcher and broatcast all file changes to every client
204
- *
205
- * @param {sting} filePath The file path to watch
206
- * @param {ws.Server} server The web socket server instance
207
- *
208
- * @returns {chokidar.FSWatcher}
209
- */
210
- function watch(filePath, server) {
211
- const watcher = chokidar.watch(filePath, {
212
- ignoreInitial: true,
213
- usePolling: argv.poll,
214
- });
215
-
216
- return watcher.on('all', (filePath) => {
217
- const data = JSON.stringify({
218
- command: 'reload',
219
- path: filePath,
220
- });
221
-
222
- server.clients.forEach((socket) => socket.send(data));
223
- });
224
- }
225
-
226
- // Define base href
227
-
228
- const baseHref = sanitize(`/${argv['base-path']}`, '/');
229
-
230
- // Define delimiters and variables
231
-
232
- const delimiters = { open: '{{', close: '}}' },
233
- variables = [argv.variable].flat().filter((variable) => !!variable).reduce((variables, variable) => {
234
- const [name, value] = variable.split('=');
235
- variables[name] = value;
236
- return variables;
237
- }, {});
238
-
239
- // Export rendered API docs
240
-
241
- if (argv._[0] === 'export') {
242
- const input = fs.readFileSync(path.resolve(__dirname, 'views', 'index.handlebars')).toString('utf8');
243
- const template = compile(input);
244
- const version = pkg.dependencies['@stoplight/elements'];
245
-
246
- let tryItCorsProxy;
247
-
248
- if (argv['cors-proxy'] && !argv['no-try-it']) {
249
- tryItCorsProxy = argv['cors-proxy'];
250
- }
251
-
252
- console.log(template({
253
- baseHref,
254
- delimiters,
255
- elements: {
256
- apiDescriptionUrl: argv._[1],
257
- basePath: baseHref,
258
- hideInternal: argv['filter-internal'] ? 'true' : undefined,
259
- hideTryIt: argv['no-try-it'] ? 'true' : undefined,
260
- tryItCorsProxy,
261
- tryItCredentialsPolicy: argv['credentials-policy'],
262
- layout: argv.layout,
263
- logo: argv.logo,
264
- router: argv.router,
265
- style: argv.style,
266
- },
267
- 'elements-css': `https://unpkg.com/@stoplight/elements@${version}/styles.min.css`,
268
- 'elements-js': `https://unpkg.com/@stoplight/elements@${version}/web-components.min.js`,
269
- layout: false,
270
- livereload: false,
271
- title: argv.title,
272
- variables,
273
- }));
274
-
275
- process.exit(0);
276
- }
277
-
278
- // Create express app
279
-
280
- const app = express();
281
-
282
- // Enable Handlebars view engine
283
-
284
- app.engine('handlebars', handlebars.engine());
285
- app.set('view engine', 'handlebars');
286
- app.set('views', path.join(__dirname, 'views'));
287
-
288
- // Serve assets from node_modules
289
-
290
- const assets = {
291
- 'livereload.js': require.resolve('livereload-js/dist/livereload.min.js'),
292
- 'styles.min.css': require.resolve('@stoplight/elements/styles.min.css'),
293
- 'web-components.min.js': require.resolve('@stoplight/elements/web-components.min.js'),
294
- };
295
-
296
- app.get(
297
- Object.keys(assets).map((asset) =>
298
- sanitize(`/${argv['base-path']}/${asset}`)
299
- ),
300
- (req, res) => {
301
- const url = new URL(req.url, `http://${req.headers.host}`);
302
-
303
- send(req, assets[path.basename(url.pathname)]).pipe(
304
- res
305
- );
306
- }
307
- );
308
-
309
- // Serve static files from working directory
310
-
311
- app.use(sanitize(`/${argv['base-path']}`), express.static(argv['working-dir'], { index: false }));
312
-
313
- // Handle CORS proxy requests
314
-
315
- if (argv['with-cors-proxy'] && !argv['no-try-it']) {
316
- const proxy = corsAnywhere.createServer({
317
- originWhitelist: [], // Allow all origins
318
- requireHeaders: [], // Do not require any headers
319
- removeHeaders: [] // Do not remove any headers
320
- });
321
-
322
- app.all(sanitize(`/${argv['base-path']}/_/*`), (req, res) => {
323
- const pos = req.originalUrl.indexOf('?');
324
- const queryString = pos === -1 ? '' : req.originalUrl.substring(pos);
325
-
326
- req.url = `/${req.params['0']}${queryString}`;
327
-
328
- proxy.emit('request', req, res);
329
- });
330
- }
331
-
332
- // Render and serve index template
333
-
334
- app.get(
335
- [sanitize(`/${argv['base-path']}`, '*'), sanitize(`/${argv['base-path']}`)],
336
- (req, res) => {
337
- let tryItCorsProxy;
338
-
339
- if (argv['with-cors-proxy'] && !argv['no-try-it']) {
340
- tryItCorsProxy = `http://${req.headers.host}${baseHref}_/`;
341
- }
342
-
343
- res.render('index', {
344
- baseHref,
345
- delimiters,
346
- elements: {
347
- apiDescriptionUrl: argv._[1],
348
- basePath: baseHref,
349
- hideInternal: argv['filter-internal'] ? 'true' : undefined,
350
- hideTryIt: argv['no-try-it'] ? 'true' : undefined,
351
- tryItCorsProxy,
352
- tryItCredentialsPolicy: argv['credentials-policy'],
353
- layout: argv.layout,
354
- logo: argv.logo,
355
- router: argv.router,
356
- style: argv.style,
357
- },
358
- 'elements-css': 'styles.min.css',
359
- 'elements-js': 'web-components.min.js',
360
- layout: false,
361
- 'livereload-js': argv.watch ? 'livereload.js' : undefined,
362
- title: argv.title,
363
- variables,
364
- });
365
- }
366
- );
367
-
368
- // Listen for HTTP connections
369
-
370
- const server = app.listen(argv.port, argv.hostname, () => {
371
- console.error(
372
- `Elements server listening on ${argv.hostname}:${argv.port}`
373
- );
374
- console.error(
375
- `Visit http://${argv['virtual-host']}:${argv['virtual-port']}${baseHref}`
376
- );
377
- });
378
-
379
- // Watch files in working directory and launch web socket server
380
-
381
- if (argv.watch) {
382
- watch(
383
- argv['working-dir'],
384
- upgrade(server).on('error', (err) => console.error(err))
385
- )
386
- .once('ready', () => console.error(`Watching ${path.resolve(argv['working-dir'])}`))
387
- .on('error', (err) => console.error(err));
388
- }
389
-
390
- // Enable the graceful shutdown
391
-
392
- gracefulShutdown(server);