Svelte 5 · Paraglide JS

Let translators write whole sentences.

Paraglide messages can carry markup — links, emphasis, lists — instead of being chopped into fragments. I18nMarkupMessage renders that markup with a ready-made set of semantic renderers, so a message with a link inside is still one line of Svelte.

terminal
npm install paraglide-markup-svelte
  • No any, one type assertion
  • 21 default tags
  • CSS or Tailwind
  • 172 tests

The problem it solves

A sentence with a link in the middle is the classic i18n trap. Without markup support you end up splitting it into pieces, and every translator has to guess how the fragments get glued back together — a word order that works in English and nowhere else.

Fragmented

messages/en.json
{
  "terms_a": "Hi {name}, please read the ",
  "terms_b": "terms",
  "terms_c": " before continuing."
}

Three messages, no context, and the sentence structure is now hard-coded in your component.

One message

messages/en.json
{
  "terms_notice": "Hi {name}, please read the {#link to=|/terms|}terms{/link} before continuing."
}

One message, translatable as a sentence. The translator can move the link anywhere.

@inlang/paraglide-js-svelte already renders that markup, but it asks you to declare a snippet for every tag, on every usage. This library supplies those snippets once, for the markup that actually shows up in real messages.

Quick start

  1. Install

    @inlang/paraglide-js, @inlang/paraglide-js-svelte and svelte are peer dependencies — a project already using Paraglide with Svelte has all three.

    terminal
    npm install paraglide-markup-svelte
  2. Hand over localizeHref

    Paraglide generates localizeHref into your project, so a published library can never import it. Give it to the library once and every internal link a message renders becomes locale-aware. Skip this and hrefs are rendered exactly as the message wrote them.

    src/routes/+layout.svelte
    // once, while your app starts up
    import { configureI18nMarkup } from 'paraglide-markup-svelte';
    import { localizeHref } from '$lib/paraglide/runtime.js';
    
    configureI18nMarkup({ localizeHref });
  3. Render

    Pass the message reference — m.title, never m.title(). Everything else is inferred from it.

    Component.svelte
    <script lang="ts">
      import { I18nMarkupMessage } from 'paraglide-markup-svelte';
      import { m } from '$lib/paraglide/messages.js';
    </script>
    
    <I18nMarkupMessage message={m.terms_notice} inputs={{ name: 'Ada' }} />

Two presentations, one behaviour

The library ships one implementation with two presentation variants. They share the same base component, the same renderer resolution, the same link handling and the same types. Only the styling strategy differs, so switching is a one-line change.

pick one
import { I18nMarkupMessage } from 'paraglide-markup-svelte';             // scoped CSS
import { I18nMarkupMessage } from 'paraglide-markup-svelte/tailwindcss'; // Tailwind
ImportStylingNeeds Tailwind
paraglide-markup-sveltecomponent-scoped Svelte CSSno
paraglide-markup-svelte/tailwindcssTailwind utility classesyes

The default build styles the semantic elements it renders inside a Svelte <style> block, so there is no stylesheet to import, no global CSS and no class names to keep in sync. A message can never restyle the app around it.

messages/en.json
"inline_styles": "{#strong}bold{/strong} {#b}alias-bold{/b} {#em}emph{/em} {#i}alias-emph{/i} {#u}under{/u} {#s}struck{/s} {#mark}marked{/mark} {#code}code{/code} {#small}small{/small} {#sup}sup{/sup} {#sub}sub{/sub} {#muted}muted{/muted} {#nowrap}no wrap{/nowrap}"
CSS variant
bold alias-bold emph alias-emph under struck marked code small sup sub muted no wrap
Tailwind variant
bold alias-bold emph alias-emph under struck marked code small sup sub muted no wrap

The Tailwind pane above is unstyled here — this documentation site does not install Tailwind, which is precisely the point: the default build needs nothing. The utility classes are still on the elements, ready for a project that does have Tailwind.

Default markup tags

Every tag below works out of the box, renders a native HTML element, and accepts class=|…| which is appended to whatever the variant already applies.

TagAliasElementOptionsAttributes
{#link}{#a}<a>href / to / url, class@external @newtab @nofollow
{#strong}{#b}<strong>class—
{#em}{#i}<em>class—
{#u}—<u>class—
{#s}—<s>class—
{#mark}—<mark>class—
{#code}—<code>class—
{#small}—<small>class—
{#sup}—<sup>class—
{#sub}—<sub>class—
{#muted}—<span> dimmedclass—
{#nowrap}—<span> no wrapclass—
{#span}—<span>class—
{#p}—<p>class—
{#ul}—<ul>class—
{#ol}—<ol>start, type, class@reversed
{#li}—<li>value, class—
{#br/}—<br>—standalone

An unknown tag is not an error

If a message uses a tag nothing renders, its children are rendered without a wrapper. Adding markup to a translation can therefore never break a page that has not been updated yet.

messages/en.json
"unknown_tag": "Hello {#unregistered}stranger{/unregistered}!"
renders
Hello stranger!

Aliases and merge order

{#a}, {#b} and {#i} are aliases of {#link}, {#strong} and {#em}. They reuse the same renderer rather than a copy of it, which is why an override follows the alias.

Renderers resolve in a fixed order, so the result never depends on prop order:

  1. the variant's own renderers,
  2. aliases, pointing at the renderer they reuse,
  3. your overrides, replacing a default or adding a new tag,
  4. aliases of an overridden tag — unless that alias was overridden itself.

In practice:

  • overriding strong also changes b,
  • unless you override b too, which always wins,
  • overriding b alone leaves strong on the default.

Lists

start and value are parsed as integers and type is checked against the values HTML actually allows — 1, a, A, i, I.

messages/en.json
"bullet_list":   "{#ul}{#li}One{/li}{#li}Two{/li}{/ul}",
"numbered_list": "{#ol start=|3| type=|a| @reversed}{#li value=|5|}Five{/li}{#li}Six{/li}{/ol}"
renders
  • One
  • Two
  1. Five
  2. Six

Bad values are dropped, not forwarded

A typo in a translation must not reach the DOM as start="NaN". Anything that is not a valid integer or a valid numbering type is discarded, and the list still renders.

messages/en.json
"invalid_ol": "{#ol start=|abc| type=|z|}{#li value=|not-a-number|}Item{/li}{/ol}"
renders — no start, type or value attribute
  1. Item

Classes from a message

Any tag accepts class=|…|. The CSS variant combines it with its scoped styling; the Tailwind variant merges it with its utility classes. The three classes below are styled by this page, not by the library — proof they land on the elements.

messages/en.json
"span_with_class": "{#span class=|extra-class|}Styled span{/span}",
"url_option_link": "{#link url=|/pricing| class=|cta-link|}Pricing{/link}",
"paragraphs":      "{#p}First paragraph{/p}{#p class=|lead|}Second paragraph{/p}"
renders

Styled span

Pricing

First paragraph

Second paragraph

Custom renderers

Any snippet passed as a prop is a renderer. Name it after a default tag to replace that default, or after a new tag to add one. I18nMarkupRendererProps types it, so no cast is needed anywhere.

Component.svelte
<script lang="ts">
  import { I18nMarkupMessage, optionText } from 'paraglide-markup-svelte';
  import type { I18nMarkupRendererProps } from 'paraglide-markup-svelte';
  import { m } from '$lib/paraglide/messages.js';
</script>

{#snippet badge({ children, options }: I18nMarkupRendererProps)}
  <span class="badge" data-level={optionText(options.level)}>
    {@render children?.()}
  </span>
{/snippet}

<I18nMarkupMessage message={m.custom_badge} {badge} />
messages/en.json
"custom_badge": "Status: {#badge level=|new|}New{/badge}"
renders — badge is not a default tag
Status: New

What a renderer receives

PropTypeMeaning
childrenSnippet | undefinedcontent between the tags; absent for a standalone tag
optionsMessageMarkupOptionsvalues written as name=|value|
attributesMessageMarkupAttributesflags written as @name
inputsthe message's inputsfor renderers that need message context
messageOptionsthe message's optionse.g. the locale in use

options values are unknown, because a message may interpolate an input into them. The exported helpers turn them into something usable without casting: optionText, optionClass, integerOption, isFlagSet, orderedListType, orderedListAttributes, listItemValue, mergeClasses, plus anchorAttributes, hrefKind, isExternalHref and linkHref for links.

Overriding a default

Component.svelte
{#snippet strong({ children }: I18nMarkupRendererProps)}
  <b class="shouty">{@render children?.()}</b>
{/snippet}

<!-- replaces the default `strong`, and its `b` alias follows -->
<I18nMarkupMessage message={m.inline_styles} {strong} />
default
bold alias-bold emph alias-emph under struck marked code small sup sub muted no wrap
with strong overridden
bold alias-bold emph alias-emph under struck marked code small sup sub muted no wrap

Both bold and alias-bold changed in the right-hand pane: the override of strong carried over to its b alias.

Inputs and options

Interpolation, markup, and interpolation inside markup all go through the same component. inputs is required exactly when the message declares variables, and options is narrowed to your project's locales.

messages/en.json
"greeting_rich":     "Hello {name}, welcome to {#strong}our site{/strong}!",
"markup_with_input": "{#link to=|/users|}{name}'s profile{/link}"
default locale

Hello Ada, welcome to our site!

Ada's profile

options={{ locale: 'es' }}

Hola Ada, bienvenido a nuestro sitio!

perfil de Ada

TypeScript

The component is generic over the message you hand it, and everything else follows from that message. Generated messages are accepted as-is — normal usage needs no casts.

inferred from the message
<!-- inputs optional: the message declares none -->
<I18nMarkupMessage message={m.plain_text} />

<!-- inputs required, and typed as { name: … } -->
<I18nMarkupMessage message={m.hello_world} inputs={{ name: 'Ada' }} />

<!-- options narrowed to the project locales -->
<I18nMarkupMessage message={m.hello_world} inputs={{ name: 'Ada' }} options={{ locale: 'es' }} />

And the mistakes that used to be runtime bugs are now compile errors:

rejected at compile time
<!-- Error: 'de' is not assignable to 'en' | 'es' -->
<I18nMarkupMessage message={m.hello_world} inputs={{ name: 'Ada' }} options={{ locale: 'de' }} />

<!-- Error: Property 'inputs' is missing -->
<I18nMarkupMessage message={m.hello_world} />

<!-- Error: 'badge' does not exist on this message's props -->
<I18nMarkupMessage message={m.plain_text} {badge} />
  • inputs is required when the message declares variables and optional when it does not, with the exact shape the Paraglide compiler generated.
  • options is narrowed to your locales, so { locale: 'de' } is an error in a two-locale project.
  • Renderer prop names are checked: you may override any default tag and add any tag the message actually contains. A typo, or a renderer for a tag that message will never use, is an error.
  • The public API contains no any.

Tailwind setup

The Tailwind build uses only Tailwind's default theme, so it works in any Tailwind v4 project without extra tokens. What it does need is for Tailwind to see the classes — and both this package and your message files sit outside the default scan.

src/app.css
@import 'tailwindcss';

/* the utility classes this package's renderers emit */
@source '../node_modules/paraglide-markup-svelte/dist';

/* only needed if your messages use class=|…| */
@source '../messages';

Without the first @source, the renderers' own classes are purged. Without the second, a class a translator wrote inside a message is purged. Only the default CSS build needs neither.

Architecture

Behaviour lives in the shared layer; the two variants only declare renderer snippets. Paraglide integration exists in exactly one place, and so does the one type assertion.

src/lib
src/lib/
├─ index.ts                          → paraglide-markup-svelte
├─ tailwindcss/index.ts              → …/tailwindcss
├─ css/I18nMarkupMessage.svelte        presentation: scoped style block
├─ tailwindcss/I18nMarkupMessage.svelte presentation: utility classes
└─ internal/
   ├─ MarkupMessageBase.svelte         shared behaviour, no styling
   ├─ renderers.ts                     tags, aliases, override merging
   ├─ link.ts                          URL classification, <a> attributes
   ├─ markup.ts                        option / class / flag / int parsing
   ├─ types.ts                         the type system
   ├─ paraglide.ts                     the one boundary with an assertion
   └─ config.ts                        localizeHref configuration

Shared

Paraglide integration, message handling, renderer resolution and registration, overrides, link behaviour, attribute handling, semantic structure, the type system.

CSS

Component-scoped Svelte styles on semantic element selectors. No class names to coordinate, no stylesheet to import, no Tailwind.

Tailwind

The same elements and the same behaviour, with utility classes instead of a <style> block.

Source, issues and releases live on GitHub.

JLAcostaEC/paraglide-markup-svelte →