diff --git a/README.md b/README.md index a0898b966..3ea202508 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,13 @@ Or use the UMD bundle from jsDelivr: ``` -More details, refer to [The Setup Guide][setup]. +Or render directly from CLI using npx: + +```bash +npx liquidjs --template 'Hello, {{ name }}!' --context '{"name": "Snake"}' +``` + +For more details, refer to the [Setup Guide][setup]. ## Related Projects diff --git a/bin/liquid.js b/bin/liquid.js index a331b4fd6..9d54b865d 100755 --- a/bin/liquid.js +++ b/bin/liquid.js @@ -1,24 +1,139 @@ #!/usr/bin/env node +const fs = require('fs/promises') const Liquid = require('..').Liquid -const contextArg = process.argv.slice(2)[0] -let context = {} -if (contextArg) { - if (contextArg.endsWith('.json')) { - const fs = require('fs') - context = JSON.parse(fs.readFileSync(contextArg, 'utf8')) +// Preserve compatibility by falling back to legacy CLI behavior if: +// - stdin is redirected (i.e. not connected to a terminal) AND +// - there are either no arguments, or only a single argument which does not start with a dash +// TODO: Remove this fallback for 11.0 + +let renderPromise = null +if (!process.stdin.isTTY && (process.argv.length === 2 || (process.argv.length === 3 && !process.argv[2].startsWith('-')))) { + renderPromise = renderLegacy() +} else { + renderPromise = render() +} + +renderPromise.catch(err => { + process.stderr.write(`${err.message}\n`) + process.exitCode = 1 +}) + +async function render () { + const { program } = require('commander') + + program + .name('liquidjs') + .description('Render a Liquid template') + .requiredOption('-t, --template ', 'liquid template to render (@- to read from stdin)') // TODO: Change to argument in 11.0 + .option('-c, --context ', 'input context in JSON format (@- to read from stdin)') + .option('-o, --output ', 'write rendered output to file (omit to write to stdout)') + .option('--cache [size]', 'cache previously parsed template structures (default cache size: 1024)') + .option('--extname ', 'use a default filename extension when resolving partials and layouts') + .option('--jekyll-include', 'use jekyll-style include (pass parameters to include variable of current scope)') + .option('--js-truthy', 'use JavaScript-style truthiness') + .option('--layouts ', 'directories from where to resolve layouts (defaults to --root)') + .option('--lenient-if', 'do not throw on undefined variables in conditional expressions (when using --strict-variables)') + .option('--no-dynamic-partials', 'always treat file paths for partials and layouts as a literal value') + .option('--no-greedy', 'disable greedy matching for --trim* options') + .option('--no-relative-reference', 'require absolute file paths for partials and layouts') + .option('--ordered-filter-parameters', 'respect parameter order when using filters') + .option('--output-delimiter-left ', 'left delimiter to use for liquid outputs') + .option('--output-delimiter-right ', 'right delimiter to use for liquid outputs') + .option('--partials ', 'directories from where to resolve partials (defaults to --root)') + .option('--preserve-timezones', 'preserve input timezone in date filter') + .option('--root ', 'directories from where to resolve partials and layouts (defaults to ".")') + .option('--strict-filters', 'throw on undefined filters instead of skipping them') + .option('--strict-variables', 'throw on undefined variables instead of rendering them as empty string') + .option('--tag-delimiter-left', 'left delimiter to use for liquid tags') + .option('--tag-delimiter-right', 'right delimiter to use for liquid tags') + .option('--timezone-offset ', 'JavaScript timezone name or timezoneOffset value to use in date filter (defaults to local timezone)') + .option('--trim-output-left', 'trim whitespace from left of liquid outputs') + .option('--trim-output-right', 'trim whitespace from right of liquid outputs') + .option('--trim-tag-left', 'trim whitespace from left of liquid tags') + .option('--trim-tag-right', 'trim whitespace from right of liquid tags') + .showHelpAfterError('Use -h or --help for additional information.') + .parse() + + const options = program.opts() + + if (Object.values(options).filter((value) => value === '@-').length > 1) { + throw new Error(`The stdin input specifier '@-' must only be used once.`) + } + + const template = await resolveInputOption(options.template) + const context = await resolveContext(options.context) + const liquid = new Liquid(options) + const output = liquid.parseAndRenderSync(template, context) + if (options.output) { + await fs.writeFile(options.output, output) } else { - context = JSON.parse(contextArg) + process.stdout.write(output) } } -let tpl = '' -process.stdin.on('data', chunk => (tpl += chunk)) -process.stdin.on('end', () => render(tpl)) - -async function render (tpl) { - const liquid = new Liquid() - const html = await liquid.parseAndRender(tpl, context) - process.stdout.write(html) +async function resolveContext (contextOption) { + let contextJson = '{}' + if (contextOption) { + contextJson = await resolveInputOption(contextOption) + } + const context = JSON.parse(contextJson) + return context +} + +async function resolveInputOption (option) { + let content = null + if (option) { + if (option === '@-') { + content = await readStream(process.stdin) + } else if (option.startsWith('@')) { + const filePath = option.slice(1) + const stat = await fs.stat(filePath, { throwIfNoEntry: false }) + if (!stat || !stat.isFile) { + throw new Error(`'${filePath}' does not exist or is not a file`) + } + content = await fs.readFile(filePath, 'utf8') + } else { + content = option + } + } + return content +} + +async function readStream (stream) { + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + return Buffer.concat(chunks).toString('utf8') +} + +// TODO: Remove for 11.0 +async function renderLegacy () { + process.stderr.write('Reading template from stdin. This mode will be removed in next major version, use --template option instead.\n') + const contextArg = process.argv.slice(2)[0] + let context = {} + if (contextArg) { + const contextJson = await resolveInputOptionLegacy(contextArg) + context = JSON.parse(contextJson) + } + const template = await readStream(process.stdin) + const liquid = new Liquid() + const output = liquid.parseAndRenderSync(template, context) + process.stdout.write(output) +} + +// TODO: Remove for 11.0 +async function resolveInputOptionLegacy (option) { + let content = null + if (option) { + const stat = await fs.stat(option).catch(e => null) + if (stat && stat.isFile) { + content = await fs.readFile(option, 'utf8') + } else { + content = option + } + } + return content } diff --git a/docs/source/tutorials/setup.md b/docs/source/tutorials/setup.md index 4568d1a94..8969628a2 100644 --- a/docs/source/tutorials/setup.md +++ b/docs/source/tutorials/setup.md @@ -53,20 +53,51 @@ Pre-built UMD bundles are also available: ## LiquidJS in CLI -LiquidJS is also available from CLI: +LiquidJS can also be used to render a template directly from CLI using `npx`: ```bash -echo '{{"hello" | capitalize}}' | npx liquidjs +npx liquidjs --template '{{"hello" | capitalize}}' ``` -If you pass a path to a JSON file or a JSON string as the first argument, it will be used as the context for your template. +You can either pass the template inline (as shown above) or you can read it from a file by using the `@` character followed by a path, like so: ```bash -echo 'Hello, {{ name }}.' | npx liquidjs '{"name": "Snake"}' +npx liquidjs --template @./some-template.liquid ``` +You can also use the `@-` syntax to read the template from `stdin`: + +```bash +echo '{{"hello" | capitalize}}' | npx liquidjs --template @- +``` + +A context can be passed in the same ways (i.e. inline, from a path or piped through `stdin`). The following three are equivalent: + +```bash +npx liquidjs --template 'Hello, {{ name }}!' --context '{"name": "Snake"}' +npx liquidjs --template 'Hello, {{ name }}!' --context @./some-context.json +echo '{"name": "Snake"}' | npx liquidjs --template 'Hello, {{ name }}!' --context @- +``` + +Note that you can only use the `stdin` specifier `@-` for a single argument. If you try to use it for both `--template` and `--context` you will get an error. + +The rendered output is written to `stdout` by default, but you can also specify an output file (if the file exists, it will be overwritten): + +```bash +npx liquidjs --template '{{"hello" | capitalize}}' --output ./hello.txt +``` + +You can also pass a number of options to customize template rendering behavior. For example, the `--js-truthy` option can be used to enable JavaScript truthiness: + +```bash +npx liquidjs --template @./some-template.liquid --js-truthy +``` + +Most of the [options available through the JavaScript API][options] are also available from the CLI. For help on available options, use `npx liquidjs --help`. + ## Miscellaneous A ReactJS demo is also added by [@stevenanthonyrevo](https://github.com/stevenanthonyrevo), see [liquidjs/demo/reactjs/](https://github.com/harttle/liquidjs/blob/master/demo/reactjs/). [intro]: ./intro-to-liquid.html +[options]: ./options.md diff --git a/package-lock.json b/package-lock.json index ea6f9a808..e5f7f4706 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2802,10 +2802,9 @@ } }, "commander": { - "version": "2.20.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.0.tgz", - "integrity": "sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ==", - "dev": true + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.0.tgz", + "integrity": "sha512-zS5PnTI22FIRM6ylNW8G4Ap0IEOyk62fhLSD0+uHRT9McRCLGpkVNvao4bjimpK/GShynyQkFFxHhwMcETmduA==" }, "commondir": { "version": "1.0.1", diff --git a/package.json b/package.json index e3dcf051b..4f7848e19 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,9 @@ "typedoc-plugin-markdown": "^2.2.17", "typescript": "^4.5.3" }, + "dependencies": { + "commander": "^10.0.0" + }, "release": { "branch": "master", "plugins": [ @@ -160,6 +163,5 @@ "pre-commit": "npm run check", "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" } - }, - "dependencies": {} + } }