Simplifies authoring JS string template tags. Tagged string templates
allow embedding a mini-language with JavaScript, and the example below
is syntactic sugar for a call to myMiniLang
.
myMiniLang`...`
This library makes it easier to write your own. See "Tagged template literals" for details about how template tag functions are called.
The example code below defines a CSV (Comma-separated value file) formatter that takes into account whether an interpolation happens inside quotes.
// Import this library.
const {
memoizedTagFunction,
trimCommonWhitespaceFromLines,
TypedString
} = require('template-tag-common')
const { Mintable } = require('node-sec-patterns')
/**
* A fragment of CSV.
* Unlike simple strings, numbers, or Dates,
* fragments may span multiple cells.
*/
class CsvFragment extends TypedString {}
Object.defineProperty(
CsvFragment, 'contractKey', { value: 'CsvFragment' })
const isCsvFragment = Mintable.verifierFor(CsvFragment)
// Assumes module-keys/babel plugin
const mintCsvFragment = require.moduleKeys.unbox(
Mintable.minterFor(CsvFragment), null,
(x) => String(x))
/**
* A template tag function that composes a CSV fragment
* by ensuring that simple values are properly quoted.
*/
const csv = memoizedTagFunction(
computeCsvContexts, interpolateValuesIntoCsv)
// memoizeTagFunction caches the results of this
// if csv`...` happens inside a loop, this only
// happens once.
function computeCsvContexts (strings) {
const { raw } = trimCommonWhitespaceFromLines(
strings, { trimEolAtStart: true, trimEolAtEnd: true })
const contexts = []
let betweenQuotes = false
raw.forEach((chunk) => {
(/""?|\\./g.exec(chunk) || []).forEach((token) => {
if (token === '"') {
// "" and \" are escape sequences
betweenQuotes = !betweenQuotes
}
})
contexts.push(betweenQuotes)
})
if (betweenQuotes) {
const placeholder = '${...}'
throw new Error(
`Missing quote in CSV: \`${raw.join(placeholder)}\``)
}
return { raw, contexts }
}
// Called with the contexts computed above, the static chunks of text,
// then the dynamic values to compute the actual result.
function interpolateValuesIntoCsv(options, { raw, contexts }, strings, values) {
const len = values.length
let result = ''
for (let i = 0; i < len; ++i) {
const alreadyQuoted = contexts[i]
const value = values[i]
let escaped = null
if (isCsvFragment(value)) {
// Allow a CSV fragment to specify multiple cells
escaped = alreadyQuoted
? `"${value.content}"`
: value.content
// TODO: maybe convert date to 2018-01-01T12:00:00Z format
} else {
escaped = JSON.stringify(String(values[i]))
if (alreadyQuoted) {
escaped = escaped.replace(/^"|"$/g, '')
}
}
result += raw[i]
result += escaped
}
result += raw[len]
return mintCsvFragment(result)
}
console.log(
'%s',
csv`
foo,${ 1 },${ mintCsvFragment('bar,bar') }
${ 'ab"c' },baz,"boo${ '\n' }",far`)
// Logs something like
// foo,1,bar,bar
// "ab\"c",baz,"boo\n",far
module.exports = {
csv,
CsvFragment
}
If defining a function that may be used as a template tag or called normally, then pass the first argument and the argument count and this will return true if the call was via a string template.
const { calledAsTemplateTag } = require('template-tag-common')
function myFunction (...args) {
if (calledAsTemplateTag(args[0], args.length)) {
// Assume template tag calling convention
const [ staticStrings, ...dynamicValues ] = args
...
} else {
// Assume regular function calling convention
...
}
}
This is true iff firstArgument
could be a result of
GetTemplateObject
and the number of dynamic arguments is consistent with a
template call.
It is possible, but unlikely, for this function to return true when
the caller is not a template literal. It is not likely that an
attacker could cause an untrusted input to specify static strings; no
firstArgument
deserialized via JSON.parse
will pass this function.
Like calledAsTemplateTag
but doesn't check that the
strings array contains only strings.
Memoizes operations on the static portions so the per-use cost of a tagged template literal is related to the complexity of handling the dynamic values.
computeStaticHelper
:{!function (Array.<string>): T}
called when there is no entry for the frozen static strings object, and cached weakly thereafter. Receives a string of arrays with a.raw
property that is a string array of the same length.computeResultHelper
:{!function (O, T, !Array.<string>, !Array.<*>): R}
a function that takes four parameters:- An options object. By default, an empty object.
- The result of computeStaticHelper above.
- The static chunks of text that surround the
${...}
- The dynamic values that result from evaluating the contents of
${...}
Returns {!function (!Array.<string>, ...*): R}
a template tag
function that calls computeStaticHelper
as needed on the static
portion and returns the result of applying computeResultHelper
.
By splitting tagged template processing into separate static analysis and dynamic value handling phases, we encourage granting privilege to the static portions which the developer specifies and treating with suspicion the dynamic values which may be controlled by an attacker.
A computeResultHelper
's options
parameter bundles optional
configuration data together.
Configurations can be passed to a tag as a single argument before the template literal:
myTag(options)`Foo ${ bar } baz`
Configurations can be associated with a tag and then later used:
const myConfiguredTag = myTag({ property: value })
const tagResult = myConfiguredTag`foo ${ bar } baz`
Arrays cannot be valid options
objects because of the way we
distinguish a call to specify options from a use of the tag.
Execution of
const { memoizedTagFunction } = require('template-tag-common')
const myTag = memoizedTagFunction(computeStaticHelper, computeResultHelper)
const result = myTag(options)`string0 ${ value0 } string1 ${ value1 } string2\n`
is equivalent to
// The JavaScript engine does this under the hood.
// It is hoisted to the top of the module.
const staticStrings = [ 'string0 ', ' string1 ', ' string2\n' ]
staticStrings.raw = [ 'string0 ', ' string1 ', ' string2\\n' ]
Object.freeze(staticStrings.raw)
Object.freeze(staticString)
// This is the part that memoizedTagFunction does.
const result = computeResultHelper(
options,
computeStaticHelper(staticStrings),
staticStrings,
[ value0, value1 ])
but if this happened in a loop, the call to computeStaticHelper
would
probably only happen once.
Simplifies tripping common leading whitespace from a multiline template tag so that a template tag can be re-indented as a block.
This function takes the first argument to a tag handler.
A memoized tag handler's computeStaticHandler
function (see above)
can call this so that the cost is not incurred every time a particular
template is reached.
Using this in template tag handlers ensures that code blocks like the two below are treated the same even though the string templates' contents have been indented differently so as to flow nicely with the surrounding code.
function f () {
if (x) {
return null
}
return aTagHandler`
{
...
}`
// Indent level 2
}
function f () {
if (x) {
return null
} else {
return aTagHandler`
{
...
}`
// Indent level 3
}
}
The options
parameter is optional as are all its properties. Options include
option property name | meaning | default |
---|---|---|
trimEolAtStart |
trim starting line terminator from first chunk | false |
trimEolAtEnd |
trim ending line terminator from last chunk | false |
A TypedString
is an object that represents a string that matches a known
contract. Each subclass
of TypedString
encapsulates such a contract.
Create a subclass of TypedString
when you want to treat some kinds of
strings specially.
This can make it very easy to write composable tag handlers -- tag handlers that can easily be split up or refactored into multiple steps.
The CSV example does not re-escape CSVFragment
s.
class CSVFragment extends TypedString {}
Object.defineProperty(
CSVFragment, 'contractKey', { value: 'CSVFragment' })
Note that each concrete sub-class of TypedString
must have
a static property contractKey
. This allows using a minter and
verifier instead of error-prone instanceof
checks. That
module fetches them thus
const isCsvFragment = Mintable.verifierFor(CsvFragment)
const mintCsvFragment = require.moduleKeys.unbox(
Mintable.minterFor(CsvFragment), null,
(x) => String(x))
Mintable.minterFor
returns a box that is openable when
there's a grant for the current module. The (x) => String(x))
allows it to degrade gracefully to returning a simple string.
Later that example checks whether a value has a particular content type before re-escaping
if (isCsvFragment(value))
The output of csv`...`
is also a CSVFragment
return mintCsvFragment(result)
which makes it easy to compose multiple uses of csv`...`
or split and refactor a single use.
const row0 = csv`...`
const row1 = csv`...`
// Combine two rows into one
csv`
${row0}
${row1}
`