Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
244 changes: 244 additions & 0 deletions rfcs/markup/RFC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
# RFC: `parts()` API + `<Message>` rendering for markup placeholders

**Status:** Draft
**Goal:** Unblock safe, ergonomic markup / component interpolation in Paraglide JS

---

## Context

Many users want to use **markup placeholders** (bold, links, inline components) in translations without using raw HTML or `dangerouslySetInnerHTML`.

We already align on:

- **MessageFormat 2–style markup placeholders** (`{#b}…{/b}`, `{#icon/}`)
- Markup lives in the **inlang SDK AST**
- Translators control _where_ markup appears
- Rendering should be **safe by default** (no HTML parsing)

The open question is:
**What API should Paraglide JS expose?**

## Requirements

### Must have

- **Framework-agnostic compiler output**

- No `.tsx`, `.vue`, or `.svelte` files generated

- **MessageFormat 2–derived model**

- Start / end / standalone markup placeholders

- **Injection-safe**

- Markup comes only from message patterns, not from interpolated values

- **`m.key()` keeps working**

- Must still return a plain string for `title`, `aria-label`, logging, etc.

### Nice to have

- Simple mental model
- Works well for **React, Vue, and Svelte**
- Avoids per-message components or many imports
- Allows **strong typing** for required markup tags

## Proposed API

### 1) `message.parts()` (only for messages with markup)

For messages that contain markup placeholders, the generated message function additionally exposes:

```ts
m.contact();
m.contact.parts();
```

`parts()` returns a **framework-neutral array of parts**:

```ts
type MessagePart =
| { type: "text"; value: string }
| { type: "markupStart"; name: string }
| { type: "markupEnd"; name: string }
| { type: "markupStandalone"; name: string };
```

- Markup comes from the **message AST**
- Interpolated values are emitted as **text**, never re-parsed
- Messages **without markup do not get `parts()`**
- This keeps non-markup messages tree-shakable and minimal

### Markup semantics (important clarification)

- Markup placeholders are **wrappers**, not values
(`{#b}text{/b}`, `{#link}…{/link}`)
- Therefore renderers receive **`children`** (the content inside the tag)
- **Nested markup is allowed** (e.g. `{#link}{#b}Text{/b}{/link}`)

> MessageFormat 2 allows markup but does not require tags to be hierarchical.
> **Paraglide will initially support only well-formed, properly nested markup**
> and treat crossing / invalid markup as a lint or build error.

Standalone tags (`{#icon/}`) do **not** receive `children`.

### 2) Framework-specific `<Message>` components (outside the compiler)

Rendering is handled by **framework adapters**, not by generated files:

- `@inlang/paraglide-js/react`
- `@inlang/paraglide-js/vue`
- `@inlang/paraglide-js/svelte`

Each adapter exports a single `<Message>` component.

### Rendering API shape

The rendering API uses a **`markup` prop**, not `components`, to emphasize that:

- Keys correspond to **markup placeholders defined in the message**
- Keys must **exactly match** the tag names used by the translator

Example message:

```json
{
"contact": "Send {#link}an email{/link} and read the {#b}docs{/b}."
}
```

#### React

```tsx
<Message
message={m.contact}
inputs={{ email: "info@example.com" }}
markup={{
link: ({ children, inputs }) => (
<a href={`mailto:${inputs.email}`}>{children}</a>
),
b: ({ children }) => <strong>{children}</strong>,
}}
/>
```

#### Vue

```vue
<Message :message="m.contact" :inputs="{ email: 'info@example.com' }">
<template #link="{ children, inputs }">
<a :href="`mailto:${inputs.email}`"><component :is="children" /></a>
</template>
<template #b="{ children }">
<strong><component :is="children" /></strong>
</template>
</Message>
```

#### Svelte

```svelte
<Message message={m.contact} inputs={{ email: "info@example.com" }}>
{#snippet link({ children, inputs })}
<a href={"mailto:" + inputs.email}>{children}</a>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If children is used like this it will just become a string, children should either be a component and rendered using <Children /> or a snippet and rendered using {@render children()} in order to allow nesting, or am I missing something here?

The example Vue code looks fine to me, but I think the React code has the same issue; I haven't used either in quite some time and am not really knowledgeable on them.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait so I can't use components?

So it would be used like this?

<Message {...} markup={{
  a: ({children, options})=>`<a href="${options.href}">${children}</a>`
}} />

I would have wanted and expected something like

<Message {...}>
  {#snippet a({children, options})}
    <MyLink href={options.href}>{@render children()}</MyLink>
  {/snippet}
</Message>

Without having looked at the internals I threw together some small example which does what I would expect. It is not properly typed, sometimes I hate TypeScript with a passion. This allows using components, button as example, it keeps its onclick property.

messages.ts

export type Placeholder = {
	type: "variable";
	name: string;
};
export type Markup = {
	type: "markup";
	markup: string;
	content: Content;
};
export type Content = (string | Placeholder | Markup)[];
export type Variables = Record<string, string|number>;
export const myContent: Content = [
	"Hello, ",
	{ type: "markup", markup: "strong", content: [{ type: "variable", name: "name" }, "!"] },
	{ type: "markup", markup: "p", content: ["Welcome.", { type: "markup", markup: "button", content: ["Click me (", { type: "variable", name: "count" }, ")"] }] }
];
export const myVariables: Variables = {
	name: "Alice",
	count: 5,
};

Message.svelte

<script lang="ts">
	import type { Snippet } from "svelte";
	import Message from "./Message.svelte";
	import type { Content, Variables } from "./messages";
	type Props = {
		content: Content;
		variables: Variables;
	} & Record<string, Snippet<[{ variables: Variables; children: () => Snippet }]>>;
	let { content, variables, ...snippets }: Props = $props();
</script>
{#each content as part}
	{#if typeof part === "string"}
		{part}
	{:else}
		{#if part.type === "variable"}
			{variables[part.name]}
		{:else if part.type === "markup"}
			{#snippet children()}
			<Message content={part.content} variables={variables} {...snippets} />
			{/snippet}
			{@render snippets[part.markup]({ variables, children })}
		{/if}
	{/if}
{/each}

+page.svelte (usage)

<script lang="ts">
	import Message from "./Message.svelte";
	import { myContent, myVariables } from "./messages";
</script>
<Message content={myContent} variables={myVariables}>
	{#snippet strong({children, variables})}
		<strong>{@render children()}</strong>
	{/snippet}
	{#snippet p({children, variables})}
		<p>{@render children()}</p>
	{/snippet}
	{#snippet button({children, variables})}
		<button onclick={() => myVariables.count += 1}>
			{@render children()}
		</button>
	{/snippet}
</Message>

Copy link

@YUCLing YUCLing Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would have wanted and expected something like

https://github.com/opral/paraglide-js/blob/3729826d8b2f573f9f514f0125123495ebdd48aa/framework/svelte/package.json#L30

It's also supporting Svelte 4, which has no runes support -> no snippet. I don't know how Svelte 4 should be supported without using html either.

The only thing I can think of is slots. But it require static slot names, <slot name={frame.name}></slot> is not supported. (tho it has the potential)

Copy link
Member Author

@samuelstroschein samuelstroschein Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aarondoet @YUCLing the api should support components in Svelte. I'll look into it. We can drop support for Svelte 4 if a better API requires it.

@YUCLing thanks for the example!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aarondoet @YUCLing i updated the svelte component. it uses snippets now. is the API ok?

opral/paraglide-js@95ada3d

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I understand this looks good.

Snippets definitely simplify things a lot here because they are way more versatile than slots, so I definitely support dropping Svelte 4. Definitely looking forward to migrating to inlang and finally dropping my own cursed piece of i18n.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! 😄

{/snippet}
{#snippet b({ children })}
<strong>{children}</strong>
{/snippet}
</Message>
```

**Important:**

- No framework-specific files are generated by the compiler
- Adapters live in separate packages
- `markup` keys must match the exact tag names used in the message (type-checked)
- `children` represents the (possibly nested) content inside the markup tag

## Considered alternative APIs

### 1) `m.message.rich(...)`

```ts
m.contact.rich(inputs, { b: (chunks) => <b>{chunks}</b> });
```

**Pros**

- Very ergonomic in React

**Cons**

- Return type becomes framework-specific
- Hard to support Svelte cleanly
- Pushes compiler toward framework modes

### 2) Overloading the message function

```ts
m.contact({ email }, { markup: { b: fn } });
```

**Pros**

- Single entry point

**Cons**

- Ambiguous return type (string vs rich output)
- Harder typing and worse DX at scale

### 3) Per-message components (`m.contact.Rich`)

```tsx
<m.contact.Rich inputs={...} />
```

**Pros**

- Excellent DX and type safety

**Cons**

- Many generated exports
- Autocomplete noise
- Adds extra abstraction per message

### 4) Parsing the final string

```tsx
<Message str={m.contact(inputs)} />
```

**Pros**

- Looks simple

**Cons**

- Injection risks unless inputs are escaped
- Harder to lint and type-check
- Markup is detected too late

## Why Option B (`parts()` + `<Message>`)

- Keeps the **compiler framework-agnostic**
- Avoids bundle bloat for non-markup messages
- Clean security boundary
- Single, stable primitive (`parts()`)
- Framework-native rendering via adapters
- Strong typing tied to translator-defined markup
- Naturally supports nested markup via `children`

## Open questions (feedback welcome)

1. Is `parts()` the right low-level primitive?
2. Is `<Message>` the right primary API, or should we also expose a `renderMessage()` helper?
3. For missing markup mappings, should the default behavior be:

- pass-through silently?
- warn?
- throw?