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

@marianmeres/safe-html

v0.4.0

Published

[![NPM](https://img.shields.io/npm/v/@marianmeres/safe-html)](https://www.npmjs.com/package/@marianmeres/safe-html) [![JSR](https://jsr.io/badges/@marianmeres/safe-html)](https://jsr.io/@marianmeres/safe-html) [![License](https://img.shields.io/npm/l/@mar

Readme

@marianmeres/safe-html

NPM JSR License

HTML tagged templates with contextual escaping. Every interpolated value is escaped for the place it appears — element text, attribute value, URL attribute, <script>, <style> — as worked out once per call site from the template's static markup. Slot positions that can never be made safe (unquoted attributes, event handlers, tag names, comments) are rejected the first time the template renders, whatever the data.

Zero dependencies. Runtime-agnostic ESM (Deno, Node, Bun, browsers). For servers that render HTML strings without a framework.

Installation

deno add jsr:@marianmeres/safe-html
npm install @marianmeres/safe-html

deno fmt reformats the contents of html-tagged templates, which changes your output. Start template files with // deno-fmt-ignore-file (see Notes).

Usage

import { html, jsonScript } from "@marianmeres/safe-html";

interface Link {
	label: string;
	href: string;
}

const LinkItem = (link: Link) => html`<li><a href="${link.href}">${link.label}</a></li>`;

export const page = (title: string, links: Link[], ld: object) =>
	html`<!doctype html>
<html lang="en">
<head>
	<meta charset="utf-8">
	<title>${title}</title>
	<script type="application/ld+json">${jsonScript(ld)}</script>
</head>
<body>
	<h1>${title}</h1>
	${links.length > 0 ? html`<ul>${links.map(LinkItem)}</ul>` : html`<p>No links.</p>`}
</body>
</html>`;

// String(page(…)) is the document.
// label "<img src=x onerror=alert(1)>"  → rendered as text
// href  "javascript:alert(1)"           → rendered as href="about:invalid#blocked"

Components are plain functions returning html. Conditionals, lists and nesting are plain expressions:

import { attrs, html, join, scriptText, srcset, styleText, url } from "@marianmeres/safe-html";

html`<p>${user.name}</p>`;                                   // text: escaped
html`<p title="${note}" class="card ${kind}">…</p>`;         // quoted attribute: escaped
html`<a href="${link}">…</a>`;                               // URL: scheme allowlist, then escaped
html`<img src="${cdn}/img/${file}.png">`;                    // URL slot followed by "/", "?" or "#"
html`<a href="/items/${id}?tab=${tab}">…</a>`;               // static prefix settles the scheme
html`<button ${attrs({ type: "submit", disabled: busy })}>`; // attribute list
html`<img srcset="${srcset([{ url: a }, { url: b, descriptor: "2x" }])}">`;
html`<script>const data = ${jsonScript(data)};</script>`;    // data island
html`<script>${scriptText(source)}</script>`;                // inline code
html`<style>${styleText(css)}</style>`;                      // inline CSS
html`<p>${join(tags, ", ")}</p>`;                            // separated list
html`<ul>${items.map((i) => html`<li>${i}</li>`)}</ul>`;     // arrays render item by item
html`${loggedIn && html`<a href="/logout">Log out</a>`}`;    // false/null/undefined render nothing
html`<img src="${url(dataUri, { schemes: ["data"] })}">`;    // allow a scheme for one value

Examples

Four runnable servers under example/, smallest first — a single value in four slots, a small blog, a URL policy, and a server-rendered app enhanced in the browser by @marianmeres/vanilla that still works with JavaScript disabled. Each renders hostile data on purpose, and each takes --print to dump its HTML to stdout.

deno run --allow-net example/01-hello.ts            # http://localhost:8801/
deno run             example/02-blog.ts --print     # just the bytes

See example/README.md for the index.

What is rejected

html`<a href=${u}>`;                       // HtmlTemplateError: unquoted attribute value
html`<button onclick="${js}">`;            // HtmlTemplateError: slot in event-handler attribute
html`<${tag}>`;                            // HtmlTemplateError: slot in tag name
html`<a href="${base}${path}">`;           // HtmlTemplateError: URL slot must be alone (or continue with / ? #)
html`<a href="http${s}://x">`;             // HtmlTemplateError: ambiguous scheme
html`<a href="javascript:go('${id}')">`;   // HtmlTemplateError: static scheme not allowed
html`<!-- ${note} -->`;                    // HtmlTemplateError: slot in comment
html`<div class="x">${a}<b title="`;       // HtmlTemplateError: template ends inside a tag
html`<svg><style>${styleText(css)}</style></svg>`; // HtmlTemplateError: not raw text inside SVG
html`<noscript><script>${jsonScript(d)}</script></noscript>`; // HtmlTemplateError: script slot in <noscript>
html`<select><style>…</style></select>`;   // HtmlTemplateError: older parsers ignore <style> there
html`<script>let n = ${n};</script>`;      // HtmlValueError: script takes jsonScript()/scriptText()
html`<p title="${html`<b>x</b>`}">`;       // HtmlValueError: html fragment in an attribute value
html`<p>${new Date()}</p>`;                // HtmlValueError (and a type error)

Template errors depend only on the template, never on data, so rendering each template once in a test surfaces them all. Value errors depend on a value's type, never on string content: a crafted string in your database can't turn a page into a 500. Error messages never include values.

Safety

Guaranteed. Given templates that pass analysis and no unsafeRaw(), no string value can:

  • create an element, attribute, comment or declaration;
  • close an element or attribute value that the template opened;
  • inject script through an event-handler attribute (those slots are rejected);
  • put a URL with a scheme outside the allowlist into a URL attribute (default: http, https, mailto, tel), including through srcset or attrs();
  • break out of <script>, <style>, <title>, <textarea> or <noscript> content;
  • change how SVG/MathML content parses.

This is tested against parse5, a spec-compliant HTML parser: templates rendered with adversarial strings (XSS filter-evasion vectors plus seeded fuzzing) must parse to the same tree as with benign values.

Not guaranteed:

  • CSS injection inside style="…" or styleText(). A value can't break out, but it can restyle the page or load a url(…). Build style values from validated tokens.
  • Semantic misuse: <meta http-equiv="refresh" content="${x}">, <base href> pointing at a hostile https: host, open redirects, and //host protocol-relative URLs (relative, so allowed).
  • Attributes that other code later treats as URLs or code: data-src, data-href, and client-side frameworks that evaluate attributes or text (Alpine x-*, htmx hx-on*, Vue/Angular template syntax in server-rendered markup). Don't interpolate data there.
  • DOM clobbering through data-controlled id or name values.
  • A slot inside a JavaScript string literal in a <script>. jsonScript() output must be used as a whole JS expression.
  • URL component encoding: href="/search?q=${q}" is HTML-escaped, not encodeURIComponent-ed. That is your job.
  • Anything passed through unsafeRaw() — it means trusted, never cleaned. This is not an HTML sanitizer: to accept HTML from users, sanitize it elsewhere first.
  • Character encoding. Serve content-type: text/html; charset=utf-8 and emit <meta charset="utf-8">.

The full rules are in docs/design.md.

URL policy

The default exports use a default kit. Create your own for a different policy:

import { createHtml, DEFAULT_URL_SCHEMES } from "@marianmeres/safe-html";

export const { html, attrs, url, srcset } = createHtml({
	urlSchemes: [...DEFAULT_URL_SCHEMES, "sms"],
	blockedUrl: "#blocked",
	onBlockedUrl: ({ url, tag, attribute }) =>
		log.warn("blocked URL", { tag, attribute }),
});

Porting from Svelte templates

| Svelte | Here | | ------------------------------ | -------------------------------------------------- | | {expr} | ${expr} | | {#if c}A{:else}B{/if} | ${c ? html`A` : html`B`} | | {#if c}A{/if} | ${c && html`A`} (careful when c is a number) | | {#each xs as x}…{/each} | ${xs.map((x) => html`…`)} | | {#snippet row(x)}…{/snippet} | const row = (x: T) => html`…` / ${row(x)} | | <Card {...props} /> | ${Card(props)} | | <div {...rest}> | <div ${attrs(rest)}> | | class:active={on} | class="${on ? "active" : ""}" | | {@html jsonLd} in <script> | ${jsonScript(data)} | | {@html css} in <style> | ${styleText(css)} | | {@html anythingElse} | ${unsafeRaw(s)} (review every one) | | {#await} | not supported; await the data before rendering |

Differences in the output:

  • HTML comments are emitted. Svelte strips <!-- … --> from SSR output; html keeps them, so a comment in a template ships to every visitor. Move rationale into a JS comment (e.g. the docblock of the function that returns the fragment).
  • Boolean attributes: Svelte writes selected="", attrs({ selected: true }) writes selected. Same DOM, different bytes.
  • Quotes in text: Svelte writes ' and " in element text as is; here they are &#39; and &quot; in every context (see docs/design.md §5.1). Same DOM.

Tests that pin markup bytes need adjusting for these; tests that compare parsed text don't.

Notes

  • Booleans render nothing, true included (as in JSX). Numbers render, so ${count && html`…`} prints 0 for zero: write count > 0 && ….
  • An untagged template literal around a fragment (`<p>${frag}</p>`) makes a plain string, which html escapes: visibly double-escaped, never an injection.
  • Every attribute value that contains a slot must be quoted.
  • <noscript> content works like any other markup, except that it can't hold <script> or <style> slots, or html fragments that have them (with scripting enabled, the element ends at the first </noscript, even one inside a script string).
  • <select> can't contain <title>, <style>, <svg>, <math>, <template> or the other tags that older parsers ignore there (their content would be parsed as markup), nor html fragments that have them. Options, optgroups, text and <script> are fine.
  • attrs() output starts with a space. When an attribute appears both statically and in attrs(), the first one in the tag wins (HTML parsing rules).
  • Whitespace is preserved exactly; nothing is minified.
  • deno fmt formats the contents of html-tagged templates (whitespace, />, line breaks), which changes your output. Add // deno-fmt-ignore above a statement (or // deno-fmt-ignore-file) where exact output matters.
  • unsafeRaw is long and loud on purpose, so every use is greppable. A codebase can assert in a test how many uses it has.

Performance

Analysis runs once per call site and is cached. A render is one pass of concatenation plus escaping. Steady state on an Apple M2 (Deno 2.9, deno task bench):

| Benchmark | hand-written concatenation + manual escaping | safe-html | ratio | | ------------------------- | -------------------------------------------- | --------- | ----- | | 1 000-row table | 141 µs | 264 µs | ~1.9× | | small page (31 templates) | 2.7 µs | 5.5 µs | ~2.0× |

The safe-html side does more work: every URL is scheme-checked and every fragment is a branded value.

API

See API.md for the complete API.

License

MIT