npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

tagscript

v3.0.0

Published

A sandboxed template language for text your users write.

Readme

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}

| Part | Notes | | --------------- | ------------------------------------------------------------------------------------------- | | declaration | The 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. | | payload | Everything 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:

| Property | Type | Description | | ----------- | ------------------------------ | ------------------------------------------------------------------------------- | | body | string \| null | The rendered, trimmed output. | | raw | string | The template exactly as it was passed in. | | actions | IActions | Side effects the template requested. Your code decides whether to honour any. | | variables | Record<string, ITransformer> | Seeded variables plus anything a tag defined during the render. | | keyValues | IKeyValues | Whatever 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, options?);

| Option | Default | Description | | --------------- | ---------------- | ------------------------------------------------------------------------------------------------- | | message | required | The template to render. Passed positionally. | | seedVariables | {} | Variables available to StrictVarsParser / LooseVarsParser, as name → transformer. | | charLimit | null | Max characters a render may produce. Exceeding it rejects out of run(). null disables it. | | tagLimit | 2000 | Max characters read from inside a single {...}; the rest of that tag body is truncated. | | parenType | ParenType.Both | Which 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:

// rejects with a WorkloadExceededError if the render exceeds 2000 characters
await ts.run(template, { seedVariables: vars, charLimit: 2_000 });

The positional form, run(message, seedVariables, charLimit, tagLimit, parenType, keyValues), still works and is deprecated.

Errors

A parser failing does not reject and does not end the render. The interpreter replaces that one tag and carries on, recording what happened on response.errors.

| The parser raises | The body gets | response.errors gets | | ----------------- | ----------------------------------- | --------------------------------------------------- | | TemplateError | the error's message, as written | the TemplateError | | anything else | a generic message | a ParserError, with the real error on cause | | StopSignal | the render so far, then its message | nothing, this is control flow rather than a failure |

The person who wrote the template usually has no console, so raise a TemplateError for a mistake they can fix and its message is shown to them. Anything else is a bug in your parser, so the body gets a generic line and the real error is kept on response.errors for you.

Built-in parsers

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

Logic and control flow

| Parser | Aliases | Example | Result | | ----------------------------- | ---------------------------- | ---------------------------------------------- | ------------------------------------------------ | | IfStatementParser | if | {if({args}==63):Correct!\|Try again.} | The branch before or after the \|. | | UnionStatementParser | any, or, union | {any({a}==hi\|{a}==hey):Hello!\|How rude.} | First branch if any expression is true. | | IntersectionStatementParser | all, and, intersection | {all({n}>=100\|{n}<=999):Ok.\|Out of range.} | First branch if all expressions are true. | | StopParser | stop, halt, error | {stop({args}==):You must provide input.} | Halts the render; the payload becomes the body. | | BreakParser | break | {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

| Parser | Aliases | Example | Result | | ------------------ | --------------------------- | ---------------------------------------------- | -------------------------------------------------------- | | StrictVarsParser | none | {user}, {user(2)} | Resolves seeded/defined variables. Prefer this one. | | LooseVarsParser | none | {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. | | JSONVarParser | json | {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

| Parser | Aliases | Example | Result | | --------------------- | ---------------------------------------------- | -------------------------------------- | ----------------------- | | StringFormatParser | lower, upper, capitalize, escape | {upper:hi} | HI | | OrdinalFormatParser | ord, ordinal | {ord:22} | 22nd | | ReplaceParser | replace | {replace(o,i):welcome to the server} | welcime ti the server | | SliceParser | slice, substr, substring | {slice(0-5):Hello World} | Hello | | IncludesParser | in, includes, contain, index, lindex | {in(there):Hi there!} | true | | UrlEncodeParser | urlencode, encodeuri | {urlencode:Hello World} | Hello%20World | | UrlDecodeParser | urldecode | {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

| Parser | Aliases | Example | Result | | ------------------ | ----------------- | ---------------------- | ---------------------------------------------------- | | RandomParser | random, rand | {random:foo,bar,baz} | One item, split on ~ or , (or \|). | | RangeParser | range, rangef | {range:10-30} | An integer; rangef gives one decimal place. | | FiftyFiftyParser | 5050, 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.

| Transformer | Purpose | | ----------------------- | ------------------------------------------------------------------------ | | StringTransformer | A string, with word/segment indexing through the parameter. | | IntegerTransformer | A counter. {n(++)} increments, {n(--)} decrements. | | SafeObjectTransformer | Dotted access into a plain object. Refuses any key starting with _. | | FunctionTransformer | Runs 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;
	}
}

Effect

tagscript/effect is a second entry point where a parser declares what it can fail with and what services it needs. effect is an optional peer dependency, so nothing changes for the classic entry point.

npm install effect@rc

A parser typed Parser<OnCooldown, CooldownStore> cannot run until the application provides that service, and its error reaches the caller:

const body = await Effect.runPromise(
	ts.run(template).pipe(
		Effect.map((response) => response.body),
		Effect.catchTag('OnCooldown', (error) => Effect.succeed(`Try again in ${error.retryAfter}s.`)),
		Effect.provide(CooldownStore.redis(client)),
	),
);

{random}, {5050} and {range} draw from Effect's Random there, so a seeded test can assert on them. fromClassic, toClassic and toPromise let the two entry points mix.

Needs Node ^20.19.0 || >=22.12.0. Full details: tagscript.js.org/tagscript/effect

Related

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.