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

@sveltekit-i18n/extension-html

v3.0.2

Published

Renders HTML in translated messages as Svelte components, for @sveltekit-i18n/base.

Readme

@sveltekit-i18n/extension-html

Renders the markup a translation carries — Read <a href="/docs">the docs</a> — as elements and Svelte components, for @sveltekit-i18n/base v3. No {@html}: every element is created by Svelte, from an allowlist.

Install

npm i -D @sveltekit-i18n/base @sveltekit-i18n/extension-html

With sveltekit-i18n, install the extension alone — the core comes with it — and import I18n from sveltekit-i18n where a snippet here imports it from @sveltekit-i18n/base, so the instance gets its parser:

npm i -D @sveltekit-i18n/extension-html

Usage

Add the extension to config.extensions. It adds one member to the instance, the T component:

import I18n from '@sveltekit-i18n/base';
import html from '@sveltekit-i18n/extension-html';

export const i18n = new I18n({
  ...config,
  extensions: [html({ onReport: null })],
});
{ "intro": "Hi <b>{{name}}</b>, read <a href=\"/docs\">the docs</a>." }
<i18n.T key="intro" params={{ name }} />

renders

Hi <b>Ann</b>, read <a href="/docs">the docs</a>.

T is a component, so it is used as <i18n.T>, or taken off the instance first (const { T } = i18n). t() and l() do not change: they return the message with its markup as text, which is what an attribute, a <title> or an aria-label needs. Use <T> where the markup should render, and t() everywhere else.

The extension returns the instance it receives, so new I18n(...) stays the instance, T included. It can also be applied directly — html(options)(i18n) — and applying it twice is harmless.

Props

| Prop | Description | |------|-------------| | key | The key of the message. | | params | The payload, as the first param of t(). Required where the schema requires it. | | args | The parser's params after the payload (ICU's formats, for instance), as an array. | | locale | Renders in this locale rather than the active one, as l() does. | | components | This usage's layer of the component map (see below). |

<T> follows its props, the active locale and the loaded translations, like t() in a template. With a schema, key completes and narrows params exactly as t() does. A key held in a variable typed by a union of keys renders too when none of them needs params; a union of keys that do is typed only up to 25 of them, a limit of how TypeScript matches a union of props. TypeScript finds the props of a literal key by a lookup rather than comparing a usage with those of every key in turn. It cannot for a key whose payload is optional, for any key of a schema with an open namespace (a template-literal key such as [key: `post.${string}`]), or for a key held in a variable typed by a union of keys; with @sveltekit-i18n/typegen, that is every key of its keys-only output (payloads any) and every key whenever it leaves a namespace open under a preprocess other than 'none'.

Options

html({
  onReport: (report) => console.warn(report.message),
  components: { b: 'strong', a: { component: Link, props: { rel: 'noopener' } } },
});

| Option | Description | |--------|-------------| | onReport | Required: where the reports of what a message could not render go (see Reports); null discards them. | | components | The app's layer of the component map. |

The component map

A tag of a translation renders as whatever the map says for it. The map has three layers, and the highest that names a tag decides:

  1. the defaults — the inline elements a, b, strong, i, em, u, s, small, mark, code, sub, sup, br, span, bdi and bdo, each rendered as itself;
  2. the components option;
  3. the components prop of a usage.

An entry is

  • a tag name — b: 'strong' renders <b> as <strong>;
  • a component — a: Link renders <a> as <Link>, with the attributes of the tag as props and the content as children;
  • either of them with props of the app's own, laid over the tag's attributes — a: { component: 'a', props: { class: 'link' } };
  • null, which unmaps a tag a lower layer maps.

A layer's entry replaces a lower layer's entry whole, props included: a usage's a: Link drops the props the option gave a.

A tag no layer maps is unwrapped — its content renders, the tag does not — and reported. Tag names are matched in lower case, as HTML parses them. SVG and MathML are not HTML: their content renders as text, and each of their tags is reported as unmapped.

Block elements

Block and structural elements are off by default. A <T> is often inside a <p>, a <button> or an <a>, which cannot hold a <p> or a <div>, and the translator who writes the tag cannot know where the message goes: the browser would move the element out of its parent and the server-rendered page would not hydrate. Enable them where the context allows:

import html, { BLOCK_ELEMENTS } from '@sveltekit-i18n/extension-html';

html({ onReport: null, components: BLOCK_ELEMENTS });
<i18n.T key="terms" components={BLOCK_ELEMENTS} />

BLOCK_ELEMENTS maps p, div, h1–h6, ul, ol, li, dl, dt, dd, blockquote, pre, hr, table, thead, tbody, tfoot, tr, th, td, caption, details and summary.

A message is parsed with parse5, the HTML parser of the standard, as the content of a <body>: whatever it repairs — a link inside a link, a table row without its <tbody>, a paragraph a <div> closes, text inside a table — it repairs as a browser does, so the markup the server renders parses back into the same tree and hydrates.

The map can still break that tree: an element rendered as another, or one unmapped that kept its children apart — a <table> whose <tbody> the map leaves out, an <h2> inside an unmapped tag inside an <h1>. Before rendering, the markup the map makes of a message is parsed again, and a message that would not come back as the same tree renders as its text, and is reported. A component is checked as the tag it takes the place of — a td: Cell as a <td> — since its own markup is not known: a component that renders a block element where the message's tag was inline, or a link inside a link, is the app's to place. A message that nests elements more than 32 deep renders as its text too, since a render recurses per level; a component adds levels of its own, which the limit cannot count.

What the message cannot know is where it goes: a <T> inside an <a> renders the message's <a> inside it, which the browser splits apart. Pass components={{ a: null }} there.

What never renders

No element that loads, runs or submits anything is in either set — no img, script, style, iframe, form, input, object, base or meta — and no layer can map a tag onto one unless the app names it. The content of script, style, title, textarea, xmp, iframe, noembed, noframes, noscript, plaintext and template is dropped with the element and reported, since it is not text of the message. Comments are dropped.

Attributes

A translation may set only the attributes its tag allows, each with a quoted value (href="/docs", not href=/docs: whitespace from the payload would end an unquoted value and start an attribute of its own); everything else — event handlers, class, style, id — is dropped and reported. The app sets those through an entry's props.

| Tag | Attributes | |-----|------------| | every tag | title, lang, dir, translate | | a | href, hreflang, target | | ol | start, reversed, type | | li | value | | td, th | colspan, rowspan, headers, scope | | details | open |

start, value, colspan and rowspan take only an integer of at most nine digits, written as the DOM writes it back — no leading zeros, no -0 — since Svelte sets value as a number. An href renders only with no scheme (/docs, docs, #top, //example.com) or with http:, https:, mailto: or tel:; any other — javascript:, data: — drops the attribute and is reported. A URL without a scheme is not necessarily on the same site: //example.com is not.

Every attribute but title renders, and is checked, without the bidi controls in its value (U+061C, U+200E, U+200F, U+202A–U+202E and U+2066–U+2069): parser-mf2 isolates a placeholder with them, and Intl marks a number or a date with them in a right-to-left locale. They order text, and in a URL, a number or a keyword they would make another value, so href="/docs/{$slug}" with slug: 'intro' links to /docs/intro, and a javascript: behind them is blocked. t() and l() keep them: an attribute the app fills with t() gets MF2's unless its parser is built with bidiIsolation: 'none', and Intl's either way.

Payloads

Interpolation brings no markup: every string in params is escaped before the parser sees it, and renders as the text it is. The values a message names — name in { name } — have their &, <, >, " and ' escaped, so a value the parser selects by (a select or .match key) with one of those characters no longer matches it. The keys, and the strings nested deeper, which a parser can only serialise, are also escaped for =, /, ` and whitespace, so a key with one of those no longer matches a placeholder that names it. Any other object — a Date, a class instance — reaches the parser as it is, for it to format, so an object whose toString() returns markup renders that markup: it is the app's own code. args are the parser's and are not escaped.

What the parser makes of an object is its own text: a parser that serialises an object into a quoted attribute can break the quoting. An element whose start tag the HTML parser reports an error in, as it does then, is rendered without any of its attributes, and each is reported. A copy the parser makes of an element — a link it reopens past a paragraph — has the attributes the element kept, and reports nothing again.

Parsers

The parser runs first and <T> reads what it returns, so the parser has to pass markup through.

  • parser-curly, parser-mf2 and parser-i18next do. With i18next, keep its default escapeValue: false: the extension escapes the payload itself, and an escaped one would render its entities as text. Its unescape marker ({{- name}}) creates no markup in <T> either, since the payload is escaped before the parser sees it.

    • With curly, a ; inside an option value still ends the option, an entity's included: write &amp\; there, &amp\\; in JSON (escaping).
    • With MF2, write the tags as text: its own markup ({#link}) renders no element. The marks MF2 isolates a placeholder with are removed from every attribute but title (Attributes); an attribute the app fills with t() keeps them, so build the parser with bidiIsolation: 'none' where one interpolates.
  • parser-icu reads tags as its own rich-text syntax and fails the message. Pass ignoreTag: true:

    import icu from '@sveltekit-i18n/parser-icu';
    
    const parser = icu({ onReport: null, ignoreTag: true });

Reports

A report says what a message could not render, as the parsers' reports do:

type Report = {
  code: 'tag-unmapped' | 'tag-dropped' | 'attribute-dropped' | 'url-blocked' | 'nesting-invalid';
  key: string;
  locale: string;
  tag?: string;
  attribute?: string;
  message: string;
};

| Code | What happened | |------|---------------| | tag-unmapped | A tag no layer maps, or one of SVG or MathML: its content rendered without it. A tag the parser implied, such as a <tbody>, is not reported. | | tag-dropped | A tag whose content is no text of the message, dropped with it. | | attribute-dropped | An attribute its tag does not allow, a value it does not take or that is unquoted, or one of a start tag the parser reports an error in. | | url-blocked | An href with a scheme that is not allowed. | | nesting-invalid | The markup the map makes of the message would not parse back into the same tree, or it nests elements more than 32 deep: the message rendered as its text. It names no tag. |

A report is made each time the message renders — on the server and again in the browser, and on every change of a prop, the locale or the translations — so a channel that counts should deduplicate. A channel that throws does not stop the render.

Other extensions

An extension whose output is no instance — such as extension-stores — goes after this one, and T is then at instance.T:

export const { t, locale, instance: { T } } = new I18n({ ...config, extensions: [html({ onReport: null }), stores] });

Put the other way round, html throws at construction, and its output type carries no T. extension-typed-access returns an output that is still an instance, so html may go after it and adds T beside the tree: extensions: [typedAccess, html({ onReport: null })]. html is a factory of the options: extensions: [html] and html() throw too.

With SvelteKit

Put the extension in the config @sveltekit-i18n/base/kit wires, and data.i18n, use() and get() carry T. With sveltekit-i18n 3.1 or newer, import defineI18n from sveltekit-i18n/kit instead. The server renders the elements, and the browser hydrates them.

Size

The markup is parsed with parse5, about 42 kB minified and gzipped with its entity tables — this package's one runtime dependency, and the price of a tree that matches the browser's. With the extension's own code, an app's client bundle grows by about 48 kB gzipped.

Documentation

Issues

This package is a part of the sveltekit-i18n ecosystem. Please report issues here.

License

MIT