Skip to content

Rich text

A link inside a translated sentence is the classic i18n headache: split the string into three keys and the translator loses word order. MF2 has markup for exactly this, and the Rich component renders it through snippets — the markup name in the message decides which snippet wraps the text.

Try it #

The link and its target both live in the translation, not in the component:

Read the docs before you ship

{ "cta": "Read {#link to=|/docs|}the docs{/link} before you ship" }
<script lang="ts">
	import { Rich } from 'svelte-dialect';
	import { formatParts } from '#lib/i18n.js';

	// options come from the translation: let only a path on this site through
	const internal = (to: unknown) => (/^\/(?![/\\])/.test(String(to)) ? String(to) : undefined);
</script>

<Rich parts={formatParts('home:cta')}>
	{#snippet link(children, options)}<a href={internal(options.to)}>{@render children()}</a>{/snippet}
</Rich>

Options and nesting #

A snippet receives a snippet for the markup's contents and the markup's MF2 options — {#link to=|/x|} becomes { to: '/x' } — so a translation can carry per-locale attributes. They are input like any other, so check them before they reach an attribute: the snippets on this page only let a path on this site through. Nesting works to any depth; each level goes through its own snippet:

bold with a link inside it

Missing snippets don't break the page #

Markup with no matching snippet still renders its contents — so an outdated translation, or one written before a snippet existed, degrades gracefully instead of vanishing:

This sentence has unwired markup in the middle

Below Rich #

formatParts(key, params) is the primitive underneath — it returns text / open / close / standalone parts. Reach for it directly if you'd rather render markup with machinery of your own.

Naming markup #

Markup names become snippet names, so keep them valid identifiers — {#my-link} has no writable snippet.