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
{
"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
{
"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
Install
@inlang/paraglide-js,@inlang/paraglide-js-svelteandsvelteare peer dependencies — a project already using Paraglide with Svelte has all three.npm install paraglide-markup-svelteHand over
localizeHrefParaglide generates
localizeHrefinto 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.// once, while your app starts up import { configureI18nMarkup } from 'paraglide-markup-svelte'; import { localizeHref } from '$lib/paraglide/runtime.js'; configureI18nMarkup({ localizeHref });Render
Pass the message reference —
m.title, neverm.title(). Everything else is inferred from it.<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.
import { I18nMarkupMessage } from 'paraglide-markup-svelte'; // scoped CSS
import { I18nMarkupMessage } from 'paraglide-markup-svelte/tailwindcss'; // Tailwind| Import | Styling | Needs Tailwind |
|---|---|---|
paraglide-markup-svelte | component-scoped Svelte CSS | no |
paraglide-markup-svelte/tailwindcss | Tailwind utility classes | yes |
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.
"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}"code small sup sub muted no wrapcode small sup sub muted no wrapThe 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.
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:
- the variant's own renderers,
- aliases, pointing at the renderer they reuse,
- your overrides, replacing a default or adding a new tag,
- aliases of an overridden tag — unless that alias was overridden itself.
In practice:
- overriding
strongalso changesb, - unless you override
btoo, which always wins, - overriding
balone leavesstrongon the default.
Link handling
The link renderer reads its target from href, to or url, in that order, and then decides what to do with it:
- app paths such as
/aboutgo through yourlocalizeHref; #hashand?querytargets are left untouched;- other origins and schemes are external — never localized, opened in a new tab with
rel="noopener noreferrer"; mailto:andtel:stay in the same tab, because sending them to a new one leaves an empty window behind;@external,@newtaband@nofolloware honoured explicitly, whatever the target looks like.
"docs_link": "{#link to=|/docs|}Read the docs{/link}"en Switch the language in the header and watch that link's href change. Nothing
in the component changed — only the configured localizeHref did the work.
"external_link": "{#link href=|https://example.com/pricing|}Pricing{/link}",
"mail_link": "{#link href=|mailto:hello@example.com|}Email us{/link}",
"tel_link": "{#link href=|tel:+15550100|}Call us{/link}",
"newtab_link": "{#link href=|/terms| @newtab @nofollow}Terms{/link}",
"forced": "{#link href=|/partner| @external}Partner{/link}"Inspect those anchors: the external one has target="_blank", the mailto: and tel: ones do not, and newtab_link carries rel="noopener noreferrer nofollow".
Lists
start and value are parsed as integers and type is
checked against the values HTML actually allows — 1, a, A, i, I.
"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}"- One
- Two
- Five
- 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.
"invalid_ol": "{#ol start=|abc| type=|z|}{#li value=|not-a-number|}Item{/li}{/ol}"start, type or value attribute - 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.
"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}"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.
<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} />"custom_badge": "Status: {#badge level=|new|}New{/badge}"badge is not a default tag What a renderer receives
| Prop | Type | Meaning |
|---|---|---|
children | Snippet | undefined | content between the tags; absent for a standalone tag |
options | MessageMarkupOptions | values written as name=|value| |
attributes | MessageMarkupAttributes | flags written as @name |
inputs | the message's inputs | for renderers that need message context |
messageOptions | the message's options | e.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
{#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} />code small sup sub muted no wrapstrong overridden code small sup sub muted no wrapBoth 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.
"greeting_rich": "Hello {name}, welcome to {#strong}our site{/strong}!",
"markup_with_input": "{#link to=|/users|}{name}'s profile{/link}"Hello Ada, welcome to our site!
options={{ locale: 'es' }} Hola Ada, bienvenido a nuestro sitio!
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.
<!-- 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:
<!-- 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} />inputsis required when the message declares variables and optional when it does not, with the exact shape the Paraglide compiler generated.optionsis 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.
@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/
├─ 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 configurationShared
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 →