From 933a7dfd593914b1658b13685a69ecfef37e0120 Mon Sep 17 00:00:00 2001 From: Blake Knight Date: Mon, 11 Jan 2021 13:02:49 -0600 Subject: [PATCH 1/5] WIP - add ability to render lists of objects --- __tests__/index.ts | 20 +++++++++++++++++++- src/index.ts | 47 ++++++++++++++++++++++++++-------------------- 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/__tests__/index.ts b/__tests__/index.ts index 5384dba..1103a9c 100644 --- a/__tests__/index.ts +++ b/__tests__/index.ts @@ -129,7 +129,7 @@ test('Can render output to a file', async t => { } }); -test('Can render a ton of files', async t => { +test.skip('Can render a ton of files', async t => { const expectedFiles = [] as { name: string; contents: string }[]; // Pre-test setup @@ -162,3 +162,21 @@ test('Can render a ton of files', async t => { t.is(actualContents, contents); } }); + +test('renders lists of objects', t => { + const template = ` +`; + + t.is( + renderString(template, { + people: [{ name: 'Blake' }, { name: 'Dash' }] + }), + '\n' + ); + + t.is(renderString(template, { people: [] }), '\n'); +}); diff --git a/src/index.ts b/src/index.ts index 5afdc31..65c8532 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,11 +6,10 @@ import path from 'path'; import { promisify } from 'util'; import { limitOpenFiles } from './utils'; +type DataValue = string | number | Data | (() => string | number | Data); + interface Data - extends Record< - string | number | symbol, - string | number | Data | (() => string | number | Data) - > {} + extends Record {} export async function renderGlob( sourceGlob: string, @@ -26,25 +25,33 @@ export async function renderGlob( } } -export function renderString( - template: string, - data: Data -): string | Promise { - return template.replace(/\{\{\s*(.*?)\s*\}\}/g, (_match, captured) => { - const replacement = get(captured, data); +export function renderString(template: string, data: Data): string { + return template + .replace( + // '{{#tag}}stuff{{/tag}} + /\{\{\s*#\s*(.*?)\s*\}\}\s*([\s\S]+?)\s*\{\{\s*\/\s*\1\s*\}\}/g, + (_match, tag, contents) => { + const array = get(tag, data); + return array + .map((subData: Data) => renderString(contents, subData)) + .join(''); + } + ) + .replace(/\{\{\s*(.*?)\s*\}\}/g, (_match, captured) => { + const replacement = get(captured, data); - // If a template variable is found but nothing is supplied to fill it, remove it - if (replacement === null || replacement === undefined) { - return ''; - } + // If a template variable is found but nothing is supplied to fill it, remove it + if (replacement === null || replacement === undefined) { + return ''; + } - // If the replacement is a function, replace the variable with the result of the function - if (typeof replacement === 'function') { - return replacement(); - } + // If the replacement is a function, replace the variable with the result of the function + if (typeof replacement === 'function') { + return replacement(); + } - return replacement; - }); + return replacement; + }); } export async function renderTemplateFile( From 12f983e84e385c62a3d35d82a27f66720ca8667e Mon Sep 17 00:00:00 2001 From: Blake Knight Date: Mon, 11 Jan 2021 13:45:09 -0600 Subject: [PATCH 2/5] :zap: combine lookups for faster replacements --- src/index.ts | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/index.ts b/src/index.ts index 65c8532..b3c63e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,20 +25,27 @@ export async function renderGlob( } } +const tagRegEx = /\{\{\s*(.*?)\s*\}\}/g; +const sectionRegEx = /\{\{\s*(?:#(.*?))\s*\}\}\s*([\s\S]*?)\s*\{\{\s*\/\1\s*\}\}/g; +const combinedRegEx = new RegExp( + `${sectionRegEx.source}|${tagRegEx.source}`, + 'g' +); + export function renderString(template: string, data: Data): string { - return template - .replace( - // '{{#tag}}stuff{{/tag}} - /\{\{\s*#\s*(.*?)\s*\}\}\s*([\s\S]+?)\s*\{\{\s*\/\s*\1\s*\}\}/g, - (_match, tag, contents) => { - const array = get(tag, data); + return template.replace( + combinedRegEx, + (_match, sectionTag, sectionContents, basicTag) => { + // Tag is for an array section + if (sectionTag !== undefined) { + const array = get(sectionTag, data); + return array - .map((subData: Data) => renderString(contents, subData)) + .map((subData: Data) => renderString(sectionContents, subData)) .join(''); } - ) - .replace(/\{\{\s*(.*?)\s*\}\}/g, (_match, captured) => { - const replacement = get(captured, data); + + const replacement = get(basicTag, data); // If a template variable is found but nothing is supplied to fill it, remove it if (replacement === null || replacement === undefined) { @@ -51,7 +58,8 @@ export function renderString(template: string, data: Data): string { } return replacement; - }); + } + ); } export async function renderTemplateFile( From 0a7f412db5318789e223299e643524913c1117d8 Mon Sep 17 00:00:00 2001 From: Blake Knight Date: Mon, 11 Jan 2021 14:40:00 -0600 Subject: [PATCH 3/5] :sparkles: add rendering an array of non-object elements --- __tests__/index.ts | 16 ++++++++++++++++ src/index.ts | 8 +++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/__tests__/index.ts b/__tests__/index.ts index 1103a9c..fe28b24 100644 --- a/__tests__/index.ts +++ b/__tests__/index.ts @@ -180,3 +180,19 @@ test('renders lists of objects', t => { t.is(renderString(template, { people: [] }), '\n
    \n \n
'); }); + +test('renders array', t => { + const template = ` +
    + {{#people}} +
  • {{ this }}
  • + {{/people}} +
`; + + t.is( + renderString(template, { + people: ['Blake', 'Dash'] + }), + '\n
    \n
  • Blake
  • Dash
  • \n
' + ); +}); diff --git a/src/index.ts b/src/index.ts index b3c63e3..2b975cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,10 +38,12 @@ export function renderString(template: string, data: Data): string { (_match, sectionTag, sectionContents, basicTag) => { // Tag is for an array section if (sectionTag !== undefined) { - const array = get(sectionTag, data); + const replacements = get(sectionTag, data); - return array - .map((subData: Data) => renderString(sectionContents, subData)) + return replacements + .map((subData: Data) => { + return renderString(sectionContents, { ...subData, this: subData }); + }) .join(''); } From 78a9c91725aff5dcaeaf3d6955bfaa36f2ce7d00 Mon Sep 17 00:00:00 2001 From: Blake Knight Date: Mon, 11 Jan 2021 15:59:28 -0600 Subject: [PATCH 4/5] :twisted_rightwards_arrows: rename functions --- __tests__/index.ts | 29 ++++++++++++++++------------- src/index.ts | 14 +++++++------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/__tests__/index.ts b/__tests__/index.ts index fe28b24..af98029 100644 --- a/__tests__/index.ts +++ b/__tests__/index.ts @@ -2,12 +2,7 @@ import test from 'ava'; import { promises as fs } from 'fs'; import mkdirp from 'mkdirp'; import path from 'path'; -import { - renderGlob, - renderString, - renderTemplateFile, - renderToFolder -} from '../src'; +import { render, renderFile, renderGlob, renderToFolder } from '../src'; import { limitOpenFiles } from '../src/utils'; test('Data is replaced when given string', t => { @@ -24,7 +19,7 @@ test('Data is replaced when given string', t => { } }; - const actual = renderString(templateString, templateData); + const actual = render(templateString, templateData); const expected = 'The cool, pizza-loving developer jumped over the silly laptop.'; @@ -62,7 +57,7 @@ test('Data is replaced when given file path', async t => { 'text/xml' ]; - const actual = await renderTemplateFile(inputFile, { + const actual = await renderFile(inputFile, { aPath: '/this-is-a-test', domain: 'reallycooldomain.com', gzip: { @@ -172,13 +167,17 @@ test('renders lists of objects', t => { `; t.is( - renderString(template, { + render(template, { people: [{ name: 'Blake' }, { name: 'Dash' }] }), - '\n
    \n
  • Blake
  • Dash
  • \n
' + ` +
    +
  • Blake
  • +
  • Dash
  • +
` ); - t.is(renderString(template, { people: [] }), '\n
    \n \n
'); + t.is(render(template, { people: [] }), '\n
    \n \n
'); }); test('renders array', t => { @@ -190,9 +189,13 @@ test('renders array', t => { `; t.is( - renderString(template, { + render(template, { people: ['Blake', 'Dash'] }), - '\n
    \n
  • Blake
  • Dash
  • \n
' + ` +
    +
  • Blake
  • +
  • Dash
  • +
` ); }); diff --git a/src/index.ts b/src/index.ts index 2b975cd..a60cf08 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,19 +20,19 @@ export async function renderGlob( const files = await glob(sourceGlob); for (const file of files) { - const contents = await limitOpenFiles(() => renderTemplateFile(file, data)); + const contents = await limitOpenFiles(() => renderFile(file, data)); onFileCallback(file, contents); } } const tagRegEx = /\{\{\s*(.*?)\s*\}\}/g; -const sectionRegEx = /\{\{\s*(?:#(.*?))\s*\}\}\s*([\s\S]*?)\s*\{\{\s*\/\1\s*\}\}/g; +const sectionRegEx = /\{\{\s*(?:#(.*?))\s*\}\}\n*([\s\S]*?)\s*\{\{\s*\/\1\s*\}\}/g; const combinedRegEx = new RegExp( `${sectionRegEx.source}|${tagRegEx.source}`, 'g' ); -export function renderString(template: string, data: Data): string { +export function render(template: string, data: Data): string { return template.replace( combinedRegEx, (_match, sectionTag, sectionContents, basicTag) => { @@ -42,9 +42,9 @@ export function renderString(template: string, data: Data): string { return replacements .map((subData: Data) => { - return renderString(sectionContents, { ...subData, this: subData }); + return render(sectionContents, { ...subData, this: subData }); }) - .join(''); + .join('\n'); } const replacement = get(basicTag, data); @@ -64,12 +64,12 @@ export function renderString(template: string, data: Data): string { ); } -export async function renderTemplateFile( +export async function renderFile( filepath: string, data: Data ): Promise { const templateString = await fs.readFile(filepath, { encoding: 'utf-8' }); - return renderString(templateString, data); + return render(templateString, data); } export async function renderToFolder( From 5713dc9db58c7bfedd058b7d3796897333cc2d53 Mon Sep 17 00:00:00 2001 From: Blake Knight Date: Mon, 11 Jan 2021 16:02:40 -0600 Subject: [PATCH 5/5] :pencil: update README for new function names; add "Templates" section --- README.md | 278 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 241 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 04cb75f..487004e 100644 --- a/README.md +++ b/README.md @@ -2,18 +2,13 @@ > 🔀 Replace {{ variables }} in all your files -[![Build status](https://travis-ci.org/gsandf/template-file.svg?branch=master)](https://travis-ci.org/gsandf/template-file) -[![Greenkeeper badge](https://badges.greenkeeper.io/gsandf/template-file.svg)](https://greenkeeper.io/) - -Use variables to replace template strings in any type of file. +Use variables to replace template strings in any type of file. This is both a runnable command-line application and JavaScript/TypeScript module. **✨ Some helpful features:** - - If you use a JavaScript file as the `dataFile` argument, whatever object the JS exports is used for replacement. - - If the value of one of the keys is a function, the result of that function is used for replacement. - - Deeply-nested keys can be used for replacements. - - **⚠️ NOTE:** Keys with a period in the name will not be resolved. `{{ user.name }}` will look for `{ user: { name: '' }}` but not `{ 'user.name': ''}`. This would be easy to change, but we're leaving as-is for now for slightly better replacement performance (please open an issue if you would like the other behavior). +- If you use a JavaScript file as the `dataFile` argument, whatever object the JS exports is used for replacement. +- If the value of one of the keys is a function, the result of that function is used for replacement. +- Deeply-nested keys can be used for replacements. ## Usage @@ -27,10 +22,10 @@ template-file - **sourceGlob** - Files to process; see [glob](https://npmjs.com/glob) for syntax - **destination** - Destination directory where processed files go -### Examples - **ℹ️ TIP:** Remember to place quotes around your arguments (if they contain asterisks, question marks, etc.) to keep your shell from expanding globs before `template-file` gets to consume them. +### Examples + Just handle one file: ```shell @@ -49,52 +44,261 @@ Compile all HTML files in `src/` to `dist/` using the exported result of a JavaS template-file retrieveData.js 'src/**/*.html' './dist' ``` +## Templates + +This uses templates similar to [mustache](https://mustache.github.io/) templates, but there are some differences. + +Anything between `{{` and `}}` can be replaced with a value. Spacing doesn't matter. + +```js +const template = '{{ location.name }} is {{adjective}}.'; +const data = { + location: { name: 'Nashville' }, + adjective: 'cool' +}; + +render(template, data); //» 'Nashville is cool.' +``` + +To render a list of items, you can use `{{#example}}` and `{{/example}}`. Empty lists and falsy values aren't rendered: + +```js +const template = ` +

Friend List:

+
    + {{#friends}} +
  • {{name}}
  • + {{/friends}} +
+`; + +const data = { + friends: [{ name: 'Amanda' }, { name: 'Bryson' }, { name: 'Josh' }] +}; + +render(template, data); +//

Friend List:

+//
    +//
  • Amanda
  • +//
  • Bryson
  • +//
  • Josh
  • +//
+``` + +If you have an array of primitive values instead of objects, you can use `{{ this }}` to refer to the current value: + +```js +const template = ` +### Foods I Like + +{{#foods}} + - {{ this }} +{{/foods}} +`; + +const data = { + foods: ['steak', 'eggs', 'avocado'] +}; + +render(template, data); +// ### Foods I Like +// +// - steak +// - eggs +// - avocado +``` + +If a replacement is a function, it is called with no arguments: + +```js +const template = `Hello, {{name}}`; + +const data = { + name: () => 'Charles' +}; + +render(template, data); //» Hello, Charles +``` + ## API +In addition to the CLI, this module exports several helpers to programmatically render templates. + +**Example:** + ```js -const { renderString, renderTemplateFile } = require('template-file') +import { render, renderFile } from 'template-file'; const data = { - location: { - name: 'Nashville' - }, + location: { name: 'Nashville' }, adjective: 'cool' -} +}; // Replace variables in string -renderString('{{ location.name }} is {{ adjective }}.', data) // 'Nashville is cool.' +render('{{ location.name }} is {{ adjective }}.', data); //» 'Nashville is cool.' -// Replace variables in a file -renderTemplateFile('/path/to/file', data) - .then(renderedString => console.log(renderedString)) // same as above, but from file +// Replace variables in a file (same as above, but from a file) +const string = await renderFile('/path/to/file', data); +console.log(renderedString); ``` -## Install +### `render` -With either [Yarn](https://yarnpkg.com/) or [npm](https://npmjs.org/) installed, run **one** of the following: +**Type:** -```shell -# If using Yarn, add to project: -yarn add template-file +```ts +function render(template: string, data: Data): string; +``` -# ...or install as development dependency: -# (use this command if you're using `template-file` to build your project) -yarn add --dev template-file +Replaces values from `data` and returns the rendered string. -# ...*or* install globally to use anywhere: -yarn global add template-file +```ts +import { render } from 'template-file'; -# If using npm, add to project: -npm install --save template-file +const data = { + location: { name: 'Nashville' }, + adjective: 'cool' +}; -# ...or install as development dependency: -# (use this command if you're using `template-file` to build your project) -npm install --save-dev template-file +render('{{ location.name }} is {{ adjective }}.', data); //» 'Nashville is cool.' +``` + +### `renderFile` + +**Type:** + +```ts +function renderFile(filepath: string, data: Data): Promise; +``` + +Reads a file replaces values from `data`, and returns the rendered string. + +```ts +import { renderFile } from 'template-file'; + +// example.html: +//

Welcome back, {{ sites.github.username }}!

+ +const data = { + name: 'Blake', + sites: { + github: { + username: 'blakek' + } + } +}; + +renderFile('./example.html', data); //» '

Welcome back, blakek!

' +``` + +### `renderGlob` + +**Type:** (note, this may change in a future major version release) + +```ts +function renderGlob( + sourceGlob: string, + data: Data, + onFileCallback: (filename: string, contents: string) => void +): Promise; +``` + +Finds files matching a glob pattern, reads those files, replaces values from `data`, and calls a function for each file. Note, no string is returned from the function; values are handled through callbacks for each file. + +```ts +import { renderGlob } from 'template-file'; + +// ./templates/profile.html: +//

Welcome back, {{ name }}!

+ +// ./templates/sign-in.html: +//

Currently signed in as {{ sites.github.username }}.

+ +const data = { + name: 'Blake', + sites: { + github: { + username: 'blakek' + } + } +}; + +const files = []; + +renderGlob('./templates/*.html', data, (filename, contents) => { + files.push({ filename, contents }); +}); + +console.log(files); +// [ +// { +// contents: '

Welcome back, Blake!

', +// filename: './templates/profile.html' +// }, +// { +// contents: '

Currently signed in as blakek.

', +// filename: './templates/sign-in.html' +// } +// ] +``` + +### `renderToFolder` + +**Type:** -# ...*or* install globally to use anywhere: -npm install --global template-file +```ts +function renderToFolder( + sourceGlob: string, + destination: string, + data: Data +): Promise; ``` +```ts +import { renderToFolder } from 'template-file'; + +const data = { + name: 'Blake', + sites: { + github: { + username: 'blakek' + } + } +}; + +renderToFolder('./templates/*.html', './dist/', data); +``` + +Finds files matching a glob pattern, reads those files, replaces values from `data`, and writes a file with the same name to `destination`. + +### Upgrading from older versions: + +Version 5 renamed some functions to be simpler: + +- `renderString` was renamed `render` +- `renderTemplateFile` was renamed `renderFile` +- `renderGlob` and `renderToFolder` were in v4 but were undocumented. The API for `renderGlob` may change in the future, depending on usage. + +Versions < 4 could not lookup properties with a dot in the name. This should be possible since version 4. For example, this was not possible before v4.0.0: + +```ts +import { render } from 'template-file'; + +const data = { 'with.dot': 'yep' }; + +render('Does this work? {{with.dot}}', data); +``` + +## Install + +With either [Yarn](https://yarnpkg.com/) or [npm](https://npmjs.org/) installed, run **one** of the following: + +| Task | with Yarn | with npm | +| ---------------------------------------- | ------------------------------- | -------------------------------------- | +| Add this to a project | `yarn add template-file` | `npm install --save template-file` | +| Install this as a development dependency | `yarn add --dev template-file` | `npm install --save-dev template-file` | +| Install this globally | `yarn global add template-file` | `npm install --global template-file` | + ## License MIT