From 65b6f146b622a95c28e02bd42a430a106a9b63e3 Mon Sep 17 00:00:00 2001 From: Tony Brix Date: Sat, 2 Sep 2023 21:39:45 -0600 Subject: [PATCH] add config option to bin/marked (#2937) * add config option to bin/marked * add tests * add docs * remove focused tests --- bin/main.js | 270 +++++++++++++++++++++++++++++++ bin/marked.js | 206 +---------------------- docs/INDEX.md | 24 +++ man/marked.1 | 6 +- test/unit/bin-spec.js | 96 +++++++++++ test/unit/fixtures/bin-config.js | 3 + 6 files changed, 400 insertions(+), 205 deletions(-) create mode 100644 bin/main.js create mode 100644 test/unit/bin-spec.js create mode 100644 test/unit/fixtures/bin-config.js diff --git a/bin/main.js b/bin/main.js new file mode 100644 index 0000000000..2ffa88137c --- /dev/null +++ b/bin/main.js @@ -0,0 +1,270 @@ +#!/usr/bin/env node + +/** + * Marked CLI + * Copyright (c) 2011-2013, Christopher Jeffrey (MIT License) + */ + +import { promises } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { homedir } from 'node:os'; +import { createRequire } from 'node:module'; +import { marked } from '../lib/marked.esm.js'; + +const { access, readFile, writeFile } = promises; +const require = createRequire(import.meta.url); + +/** + * @param {Process} nodeProcess inject process so it can be mocked in tests. + */ +export async function main(nodeProcess) { + /** + * Man Page + */ + async function help() { + const { spawn } = await import('child_process'); + const { fileURLToPath } = await import('url'); + + const options = { + cwd: nodeProcess.cwd(), + env: nodeProcess.env, + stdio: 'inherit' + }; + + const __dirname = dirname(fileURLToPath(import.meta.url)); + const helpText = await readFile(resolve(__dirname, '../man/marked.1.txt'), 'utf8'); + + // eslint-disable-next-line promise/param-names + await new Promise(res => { + spawn('man', [resolve(__dirname, '../man/marked.1')], options) + .on('error', () => { + console.log(helpText); + }) + .on('close', res); + }); + } + + async function version() { + const pkg = require('../package.json'); + console.log(pkg.version); + } + + /** + * Main + */ + async function start(argv) { + const files = []; + const options = {}; + let input; + let output; + let string; + let arg; + let tokens; + let config; + let opt; + + function getArg() { + let arg = argv.shift(); + + if (arg.indexOf('--') === 0) { + // e.g. --opt + arg = arg.split('='); + if (arg.length > 1) { + // e.g. --opt=val + argv.unshift(arg.slice(1).join('=')); + } + arg = arg[0]; + } else if (arg[0] === '-') { + if (arg.length > 2) { + // e.g. -abc + argv = arg.substring(1).split('').map(function(ch) { + return '-' + ch; + }).concat(argv); + arg = argv.shift(); + } else { + // e.g. -a + } + } else { + // e.g. foo + } + + return arg; + } + + while (argv.length) { + arg = getArg(); + switch (arg) { + case '-o': + case '--output': + output = argv.shift(); + break; + case '-i': + case '--input': + input = argv.shift(); + break; + case '-s': + case '--string': + string = argv.shift(); + break; + case '-t': + case '--tokens': + tokens = true; + break; + case '-c': + case '--config': + config = argv.shift(); + break; + case '-h': + case '--help': + return await help(); + case '-v': + case '--version': + return await version(); + default: + if (arg.indexOf('--') === 0) { + opt = camelize(arg.replace(/^--(no-)?/, '')); + if (!marked.defaults.hasOwnProperty(opt)) { + continue; + } + if (arg.indexOf('--no-') === 0) { + options[opt] = typeof marked.defaults[opt] !== 'boolean' + ? null + : false; + } else { + options[opt] = typeof marked.defaults[opt] !== 'boolean' + ? argv.shift() + : true; + } + } else { + files.push(arg); + } + break; + } + } + + async function getData() { + if (!input) { + if (files.length <= 2) { + if (string) { + return string; + } + return await getStdin(); + } + input = files.pop(); + } + return await readFile(input, 'utf8'); + } + + function resolveFile(file) { + return resolve(file.replace(/^~/, homedir)); + } + + function fileExists(file) { + return access(resolveFile(file)).then(() => true, () => false); + } + + async function runConfig(file) { + const configFile = resolveFile(file); + let markedConfig; + try { + // try require for json + markedConfig = require(configFile); + } catch (err) { + if (err.code !== 'ERR_REQUIRE_ESM') { + throw err; + } + // must import esm + markedConfig = await import('file:///' + configFile); + } + + if (markedConfig.default) { + markedConfig = markedConfig.default; + } + + if (typeof markedConfig === 'function') { + markedConfig(marked); + } else { + marked.use(markedConfig); + } + } + + const data = await getData(); + + if (config) { + if (!await fileExists(config)) { + throw Error(`Cannot load config file '${config}'`); + } + + await runConfig(config); + } else { + const defaultConfig = [ + '~/.marked.json', + '~/.marked.js', + '~/.marked/index.js' + ]; + + for (const configFile of defaultConfig) { + if (await fileExists(configFile)) { + await runConfig(configFile); + break; + } + } + } + + const html = tokens + ? JSON.stringify(marked.lexer(data, options), null, 2) + : await marked.parse(data, options); + + if (output) { + return await writeFile(output, html); + } + + nodeProcess.stdout.write(html + '\n'); + } + + /** + * Helpers + */ + function getStdin() { + return new Promise((resolve, reject) => { + const stdin = nodeProcess.stdin; + let buff = ''; + + stdin.setEncoding('utf8'); + + stdin.on('data', function(data) { + buff += data; + }); + + stdin.on('error', function(err) { + reject(err); + }); + + stdin.on('end', function() { + resolve(buff); + }); + + stdin.resume(); + }); + } + + /** + * @param {string} text + */ + function camelize(text) { + return text.replace(/(\w)-(\w)/g, function(_, a, b) { + return a + b.toUpperCase(); + }); + } + + try { + await start(nodeProcess.argv.slice()); + nodeProcess.exit(0); + } catch (err) { + if (err.code === 'ENOENT') { + nodeProcess.stderr.write('marked: output to ' + err.path + ': No such directory'); + } + nodeProcess.stderr.write(err); + return nodeProcess.exit(1); + } +} diff --git a/bin/marked.js b/bin/marked.js index 5031246258..e2dd816f6b 100755 --- a/bin/marked.js +++ b/bin/marked.js @@ -5,213 +5,11 @@ * Copyright (c) 2011-2013, Christopher Jeffrey (MIT License) */ -import { promises } from 'fs'; -import { marked } from '../lib/marked.esm.js'; - -const { readFile, writeFile } = promises; - -/** - * Man Page - */ - -async function help() { - const { spawn } = await import('child_process'); - - const options = { - cwd: process.cwd(), - env: process.env, - setsid: false, - stdio: 'inherit' - }; - - const { dirname, resolve } = await import('path'); - const { fileURLToPath } = await import('url'); - const __dirname = dirname(fileURLToPath(import.meta.url)); - const helpText = await readFile(resolve(__dirname, '../man/marked.1.txt'), 'utf8'); - - // eslint-disable-next-line promise/param-names - await new Promise(res => { - spawn('man', [resolve(__dirname, '../man/marked.1')], options) - .on('error', () => { - console.log(helpText); - }) - .on('close', res); - }); -} - -async function version() { - const { createRequire } = await import('module'); - const require = createRequire(import.meta.url); - const pkg = require('../package.json'); - console.log(pkg.version); -} - -/** - * Main - */ - -async function main(argv) { - const files = []; - const options = {}; - let input; - let output; - let string; - let arg; - let tokens; - let opt; - - function getarg() { - let arg = argv.shift(); - - if (arg.indexOf('--') === 0) { - // e.g. --opt - arg = arg.split('='); - if (arg.length > 1) { - // e.g. --opt=val - argv.unshift(arg.slice(1).join('=')); - } - arg = arg[0]; - } else if (arg[0] === '-') { - if (arg.length > 2) { - // e.g. -abc - argv = arg.substring(1).split('').map(function(ch) { - return '-' + ch; - }).concat(argv); - arg = argv.shift(); - } else { - // e.g. -a - } - } else { - // e.g. foo - } - - return arg; - } - - while (argv.length) { - arg = getarg(); - switch (arg) { - case '-o': - case '--output': - output = argv.shift(); - break; - case '-i': - case '--input': - input = argv.shift(); - break; - case '-s': - case '--string': - string = argv.shift(); - break; - case '-t': - case '--tokens': - tokens = true; - break; - case '-h': - case '--help': - return await help(); - case '-v': - case '--version': - return await version(); - default: - if (arg.indexOf('--') === 0) { - opt = camelize(arg.replace(/^--(no-)?/, '')); - if (!marked.defaults.hasOwnProperty(opt)) { - continue; - } - if (arg.indexOf('--no-') === 0) { - options[opt] = typeof marked.defaults[opt] !== 'boolean' - ? null - : false; - } else { - options[opt] = typeof marked.defaults[opt] !== 'boolean' - ? argv.shift() - : true; - } - } else { - files.push(arg); - } - break; - } - } - - async function getData() { - if (!input) { - if (files.length <= 2) { - if (string) { - return string; - } - return await getStdin(); - } - input = files.pop(); - } - return await readFile(input, 'utf8'); - } - - const data = await getData(); - - const html = tokens - ? JSON.stringify(marked.lexer(data, options), null, 2) - : marked(data, options); - - if (output) { - return await writeFile(output, html); - } - - process.stdout.write(html + '\n'); -} - -/** - * Helpers - */ - -function getStdin() { - return new Promise((resolve, reject) => { - const stdin = process.stdin; - let buff = ''; - - stdin.setEncoding('utf8'); - - stdin.on('data', function(data) { - buff += data; - }); - - stdin.on('error', function(err) { - reject(err); - }); - - stdin.on('end', function() { - resolve(buff); - }); - - stdin.resume(); - }); -} - -/** - * @param {string} text - */ -function camelize(text) { - return text.replace(/(\w)-(\w)/g, function(_, a, b) { - return a + b.toUpperCase(); - }); -} - -function handleError(err) { - if (err.code === 'ENOENT') { - console.error('marked: output to ' + err.path + ': No such directory'); - return process.exit(1); - } - throw err; -} +import { main } from './main.js'; /** * Expose / Entry Point */ process.title = 'marked'; -main(process.argv.slice()).then(code => { - process.exit(code || 0); -}).catch(err => { - handleError(err); -}); +main(process); diff --git a/docs/INDEX.md b/docs/INDEX.md index 2d07d75d3f..64497a5698 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -75,6 +75,30 @@ $ cat readme.html $ marked --help ``` +*CLI Config* + +A config file can be used to configure the marked cli. + +If it is a `.json` file it should be a JSON object that will be passed to marked as options. + +If `.js` is used it should have a default export of a marked options object or a function that takes `marked` as a parameter. +It can use the `marked` parameter to install extensions using `marked.use`. + +By default the marked cli will look for a config file in your home directory in the following order. + +- `~/.marked.json` +- `~/.marked.js` +- `~/.marked/index.js` + +```bash +# Example with custom config + +echo '{ "breaks": true }' > config.json + +$ marked -s 'line1\nline2' -c config.json +

line1
line2

+``` + **Browser** ```html diff --git a/man/marked.1 b/man/marked.1 index 4dd24fd1d8..04697a4c71 100644 --- a/man/marked.1 +++ b/man/marked.1 @@ -7,7 +7,7 @@ marked \- a javascript markdown parser .SH SYNOPSIS .B marked [\-o \fI\fP] [\-i \fI\fP] [\-s \fI\fP] [\-\-help] -[\-\-tokens] [\-\-pedantic] [\-\-gfm] + [\-c \fI\fP] [\-\-tokens] [\-\-pedantic] [\-\-gfm] [\-\-breaks] [\-\-sanitize] [\-\-smart\-lists] [\-\-lang\-prefix \fI\fP] [\-\-no\-etc...] [\-\-silent] [\fIfilename\fP] @@ -39,6 +39,10 @@ If no input file is specified, read from stdin. .BI \-s,\ \-\-string\ [\fIstring\fP] Specify string input instead of a file. .TP +.BI \-c,\ \-\-config\ [\fIconfig\fP] +Specify a javascript file to be used as the config file +instead of the default file in your home directory. +.TP .BI \-t,\ \-\-tokens Output a token stream instead of html. .TP diff --git a/test/unit/bin-spec.js b/test/unit/bin-spec.js new file mode 100644 index 0000000000..e2bcb88662 --- /dev/null +++ b/test/unit/bin-spec.js @@ -0,0 +1,96 @@ +import { main } from '../../bin/main.js'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'url'; +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function createMocks() { + const mocks = { + stdout: '', + stderr: '', + code: null, + stdin: { + data: null, + error: null, + end: null + }, + process: { + cwd: jasmine.createSpy('process.cwd').and.returnValue('/cwd'), + env: [], + argv: [], + stdout: { + write: jasmine.createSpy('process.stdout.write').and.callFake((str) => { mocks.stdout += str; }) + }, + stderr: { + write: jasmine.createSpy('process.stderr.write').and.callFake((str) => { mocks.stderr += str; }) + }, + stdin: { + setEncoding: jasmine.createSpy('process.stdin.setEncoding'), + on: jasmine.createSpy('process.stdin.on').and.callFake((method, func) => { + mocks.stdin[method] = func; + }), + resume: jasmine.createSpy('process.stdin.resume') + }, + exit: jasmine.createSpy('process.exit').and.callFake((code) => { mocks.code = code; }) + } + }; + + return mocks; +} + +function testInput({ args = [], stdin = '', stdinError = '', stdout = '', stderr = '', code = 0 } = {}) { + return async() => { + const mocks = createMocks(); + mocks.process.argv = args; + const mainPromise = main(mocks.process); + if (typeof mocks.stdin.end === 'function') { + if (stdin) { + mocks.stdin.data(stdin); + } + if (stdinError) { + mocks.stdin.error(stdinError); + } + mocks.stdin.end(); + } + await mainPromise; + + await expectAsync(mocks.stdout).toEqualHtml(stdout); + expect(mocks.stderr).toEqual(stderr); + expect(mocks.code).toBe(code); + }; +} + +function fixturePath(filePath) { + return resolve(__dirname, './fixtures', filePath); +} + +describe('bin/marked', () => { + describe('string', () => { + it('-s', testInput({ + args: ['-s', '# test'], + stdout: '

test

' + })); + + it('--string', testInput({ + args: ['--string', '# test'], + stdout: '

test

' + })); + }); + + describe('config', () => { + it('-c', testInput({ + args: ['-c', fixturePath('bin-config.js'), '-s', 'line1\nline2'], + stdout: '

line1
line2

' + })); + + it('--config', testInput({ + args: ['--config', fixturePath('bin-config.js'), '-s', 'line1\nline2'], + stdout: '

line1
line2

' + })); + + it('not found', testInput({ + args: ['--config', fixturePath('does-not-exist.js'), '-s', 'line1\nline2'], + stderr: jasmine.stringContaining(`Cannot load config file '${fixturePath('does-not-exist.js')}'`), + code: 1 + })); + }); +}); diff --git a/test/unit/fixtures/bin-config.js b/test/unit/fixtures/bin-config.js new file mode 100644 index 0000000000..b0b356c510 --- /dev/null +++ b/test/unit/fixtures/bin-config.js @@ -0,0 +1,3 @@ +export default { + breaks: true +};