Tagscript

Markdown

Escape what a tag produced, so a value someone else supplied cannot plant formatting in a template you wrote.

An admin writes a message. Your application fills in a name.

Thanks **{name}**, see you soon.

Someone submits # Ada, a horizontal rule and a link as their name. The rendered page gets all three, and the admin never wrote any of it.

Sanitising HTML afterwards does not help. By the time the body reaches a markdown renderer this is all legitimate markdown, and no renderer can tell which characters the author typed and which came from a form field. Only the render knows that, which is why this lives here rather than in your rendering step.

Usage

npm install @tagscript/markdown

Render with spans: true, then escape:

import { Flavour, markdownSafe } from '@tagscript/markdown';

const response = await ts.run('Thanks **{name}**, see you soon.', { seedVariables, spans: true });
const body = markdownSafe(response, Flavour.GFM);

The author's ** still works. Everything the tag produced is now literal text.

Leave spans: true off and this throws. A response without ranges looks exactly like a response where nothing was generated, and returning the body unescaped would be the wrong way to be wrong.

It gives you markdown, not HTML

Whatever you already render with keeps rendering, whether that is react-markdown, an email template, or a Discord message. Pick the flavour your reader uses.

FlavourAdds
Flavour.CommonMarkthe base
Flavour.GFMtables and strikethrough, so | and ~
Flavour.Discordspoilers and strikethrough, and no raw HTML

This is not a substitute for sanitising HTML. If your renderer passes raw HTML through, keep doing whatever you already do about that.

What counts as the author's text

Every built-in parser passes its own payload through, and the author typed that payload, so {if(x):**yes**} keeps its emphasis. A variable is not a built-in, so whatever your application seeded is escaped.

markdownSafe(response, Flavour.GFM, { trust: [...builtinTags, ...myTemplateTags] });

Add your own tags to trust when they return template text. A tag that returns anything fetched, submitted or configured elsewhere belongs nowhere near that list. untrust goes the other way, for a variable an author defined with {=(name):value} that you nevertheless do not want to be able to format.

Nesting follows where the text came from. A tag nested in a payload is carried, and one nested in a parameter is not, because a parameter is read rather than written:

TemplateResult
{upper:{user}}escaped, since the user value is in the output
{if({user}==yes):**sure**}not escaped, since the branch is the author's own text

That is a rule about provenance, not proof of what a parser did. A parser that writes its own parameter into its output is the case it gets wrong, so name such a parser in untrust.

Escaping a value on its own

When you have a value rather than a render:

import { escapeMarkdown } from '@tagscript/markdown';

escapeMarkdown(displayName, Flavour.CommonMark);

Characters that only mean something at the start of a line are escaped only there, so a phone number keeps its hyphens and a sentence keeps its full stops.

Discord mentions

Escaping stops formatting, not mentions. <@123> still renders as a mention, because that is not markdown. Use allowed_mentions on the message payload, which is what decides whether it pings.

Last updated on

On this page

Edit on Github