Tagscript

Tagscript

TagScript


tagscript

A sandboxed template language for text your users write.

npm npm downloads codecov

What is TagScript?

TagScript is a template language for the case where the person writing the template is not the person who wrote the app. A Discord server admin building a custom command. A user customising their profile. A support team editing an auto-reply.

A template is plain text sprinkled with {tags}, and the interpreter knows nothing except the parsers you explicitly register. There is no host object to reach, no prototype to walk, no require to find. An unknown tag is not an error and not a crash. It stays in the output as literal text.

import { Interpreter, RandomParser } from 'tagscript';

const ts = new Interpreter(new RandomParser());

(await ts.run('{random:heads,tails}')).body; // -> 'tails'
(await ts.run('{if(1==1):yes|no}')).body; // -> '{if(1==1):yes|no}', no IfStatementParser registered

Ships ESM, CJS and an IIFE build (global TagScript). No runtime dependencies.

Installation

npm install tagscript

Anatomy of a tag

{declaration(parameter):payload}
{declaration.parameter:payload}
PartNotes
declarationThe tag name, e.g. if, random, upper. Matched case-insensitively; most have aliases.
parameter(...) or . form. The . form ends at the : or at the end of the tag. Often optional.
payloadEverything after the first un-nested :, up to the closing }. Often optional.

Tags nest, and inner tags resolve first, so {upper:{lower:ABC}} renders lower before upper. Anything outside braces is plain text. Prefix a {, }, (, ), : or | with a backslash to stop it being read as syntax.

ParenType decides which parameter forms are legal per render. See run() options.

Running a template

import { FiftyFiftyParser, IfStatementParser, Interpreter, RandomParser, SliceParser } from 'tagscript';

const ts = new Interpreter(new SliceParser(), new FiftyFiftyParser(), new RandomParser(), new IfStatementParser());

const response = await ts.run(
	'{random:Parbez,Rkn,Priyansh} attempts to pick the lock! I pick {if({5050:.}!=):heads|tails}',
);

response.body; // -> 'Parbez attempts to pick the lock! I pick heads'

run() resolves to a Response, not a string:

PropertyTypeDescription
bodystring | nullThe rendered, trimmed output.
rawstringThe template exactly as it was passed in.
actionsIActionsSide effects the template requested. Your code decides whether to honour any.
variablesRecord<string, ITransformer>Seeded variables plus anything a tag defined during the render.
keyValuesIKeyValuesWhatever you passed in for parsers to read. Untouched by the interpreter.

Parsers can also be swapped after construction with ts.addParsers(...) and ts.setParsers(...).

run() options

ts.run(message, seedVariables?, charLimit?, tagLimit?, parenType?, keyValues?);
ArgumentDefaultDescription
messagerequiredThe template to render.
seedVariables{}Variables available to StrictVarsParser / LooseVarsParser, as name → transformer.
charLimitnullMax characters a render may produce. Exceeding it throws out of run(). null disables it.
tagLimit2000Max characters read from inside a single {...}; the rest of that tag body is truncated.
parenTypeParenType.BothWhich parameter syntaxes are accepted: Both, Parenthesis or Dot.
keyValues{}Arbitrary data for your own parsers, reachable at ctx.response.keyValues.

charLimit is your defence against a template that expands cheaply into a huge string, so set it whenever the template author is untrusted:

await ts.run(template, vars, 2_000); // throws if the render exceeds 2000 characters

Built-in parsers

Nothing below is active until you pass it to the Interpreter.

Logic and control flow

ParserAliasesExampleResult
IfStatementParserif{if({args}==63):Correct!|Try again.}The branch before or after the |.
UnionStatementParserany, or, union{any({a}==hi|{a}==hey):Hello!|How rude.}First branch if any expression is true.
IntersectionStatementParserall, and, intersection{all({n}>=100|{n}<=999):Ok.|Out of range.}First branch if all expressions are true.
StopParserstop, halt, error{stop({args}==):You must provide input.}Halts the render; the payload becomes the body.
BreakParserbreak{break({args}==):No input.}Overrides the body but keeps parsing later tags.

Comparison operators are ==, !=, >, <, >= and <=. A bare true/false also works, and anything unrecognised evaluates as true.

stop and break differ in how far they go: stop ends the render there, break only replaces the final body while remaining tags still execute.

Variables

ParserAliasesExampleResult
StrictVarsParsernone{user}, {user(2)}Resolves seeded/defined variables. Prefer this one.
LooseVarsParsernone{user}Same, but the name is checked while parsing, not before.
DefineParser=, assign, let, var{=(prefix):!} then {prefix}Defines a variable for the rest of the render.
JSONVarParserjson{json(u):{"name":"Parbez"}} then {u(name)}Defines a variable from a JSON payload.

You need one of StrictVarsParser or LooseVarsParser registered for {variable} tags to resolve at all.

Text

ParserAliasesExampleResult
StringFormatParserlower, upper, capitalize, escape{upper:hi}HI
OrdinalFormatParserord, ordinal{ord:22}22nd
ReplaceParserreplace{replace(o,i):welcome to the server}welcime ti the server
SliceParserslice, substr, substring{slice(0-5):Hello World}Hello
IncludesParserin, includes, contain, index, lindex{in(there):Hi there!}true
UrlEncodeParserurlencode, encodeuri{urlencode:Hello World}Hello%20World
UrlDecodeParserurldecode{urldecode:Hello%20World}Hello World

IncludesParser covers four different questions depending on the alias:

{in(there):Hi there!}      # true, substring anywhere
{contain(there):Hi there!} # false, whole word only ("there!" is the word)
{index(there!):Hi there!}  # 1, word index
{lindex(t):Hi there!}      # 3, character index

Pass + as the parameter to urlencode/urldecode to use + for spaces instead of %20.

Randomness

ParserAliasesExampleResult
RandomParserrandom, rand{random:foo,bar,baz}One item, split on ~ or , (or |).
RangeParserrange, rangef{range:10-30}An integer; rangef gives one decimal place.
FiftyFiftyParser5050, 50, ?{5050:heads}The payload half the time, an empty string the rest.

Transformers

Transformers back the {variable} tags. They expose a fixed set of keys, so a template can never reach the object underneath.

TransformerPurpose
StringTransformerA string, with word/segment indexing through the parameter.
IntegerTransformerA counter. {n(++)} increments, {n(--)} decrements.
SafeObjectTransformerDotted access into a plain object. Refuses any key starting with _.
FunctionTransformerRuns your function at render time, so the value can be computed per tag.
import { Interpreter, StrictVarsParser, StringTransformer } from 'tagscript';

const ts = new Interpreter(new StrictVarsParser());

(await ts.run('Hi {user}, your surname is {user(2)}', { user: new StringTransformer('Parbez Barbhuiya') })).body;
// -> 'Hi Parbez Barbhuiya, your surname is Barbhuiya'

StringTransformer indexes from 1, splits on whitespace unless the payload gives another separator, and supports + for ranges. {args(2+)} is "the second word onwards", {args(+2)} is "up to and including the second word".

Writing your own

A parser is anything matching IParser. BaseParser gives you name matching and the parameter/payload requirement checks for free.

import { BaseParser, type Context, type IParser } from 'tagscript';

class ShoutParser extends BaseParser implements IParser {
	public constructor() {
		super(['shout'], false, true); // accepted names, requires parameter, requires payload
	}

	public parse(ctx: Context) {
		return `${ctx.tag.payload!.toUpperCase()}!!!`;
	}
}

(await new Interpreter(new ShoutParser()).run('{shout:hello}')).body; // -> 'HELLO!!!'

Return null from parse to decline the tag. The interpreter moves on to the next parser that accepted it, and if none produce a value the tag is left in the output verbatim. parse and willAccept may both be async.

To record a side effect instead of producing text, write to ctx.response.actions and return ''. Declaration-merge IActions so your field is typed:

declare module 'tagscript' {
	interface IActions {
		notify?: { channel: string };
	}
}

Transformers are simpler. Implement transform(tag) and return a string, or null to leave the tag alone:

import type { ITransformer, Lexer } from 'tagscript';

class UpperTransformer implements ITransformer {
	public constructor(private readonly value: string) {}

	public transform(tag: Lexer) {
		return tag.parameter === 'upper' ? this.value.toUpperCase() : this.value;
	}
}

Buy me some doughnuts

If you want to support me by donating, you can do so by using any of the following methods. Thank you very much in advance!

Contributors

Thanks goes to these wonderful people:

Special thanks

  • JonSnowbd for creating TagScript in Python, which this project is a TypeScript reimagining of.

Enumerations

Classes

Interfaces

Type Aliases

Variables

Functions

On this page