Skip to content

markup rfc#4309

Merged
samuelstroschein merged 1 commit intomainfrom
markup-rfc
Feb 10, 2026
Merged

markup rfc#4309
samuelstroschein merged 1 commit intomainfrom
markup-rfc

Conversation

@samuelstroschein
Copy link
Member

@samuelstroschein samuelstroschein commented Jan 16, 2026

Note

Adds a new RFC detailing a safe, framework-agnostic approach for markup placeholders in translations.

  • Proposes message.parts() returning structured MessagePart[] only for messages with markup (text, markupStart/end/standalone); interpolations emitted as text for injection safety
  • Introduces framework adapters exporting a <Message> component (react/vue/svelte) that render via a markup prop keyed by translator-defined tags, supporting nesting via children
  • Outlines considered alternatives (rich(), overloaded message fn, per-message components, post-parse) and rationale for choosing parts() + <Message>
  • Notes typing, tree-shaking, and security boundaries; lists open questions on API shape and fallback behavior

Written by Cursor Bugbot for commit eec13c3. This will update automatically on new commits. Configure here.

@changeset-bot

This comment was marked as off-topic.

@samuelstroschein
Copy link
Member Author

From kmsomebody #3801 (comment):

Purely from a DX standpoint as a React dev, I think option 2 in your list of "Considered alternative APIs" comes closest to what I'd expect. Though what exactly would be the reason for overloading instead of adding the markup parameters to the first argument?

Let's take this message as an example:

"balance": "You have {#tooltip}{amount} coins{/tooltip}."

This message needs the amount parameter and a tooltip function.

Options such as per-message components (<m.balance.Rich/>), a new function (m.balance.rich() or m.balance.parts()) do not make sense to me, because the regular m.balance() function couldn't be used regardless. So that leads me to the question: Why can't I just use the m.balance() function for this too? There is always only one correct way to pass parameters to the message. I think having to use a different function or a component for different types of messages unnecessarily bloats the API. I'd always prefer a single entry point for this.

Suggestion

This suggestion is framework-specific, so the framework adapters would need to extend the compiler. I can only provide feedback for React. I do not know if it's possible to implement the same way for other frameworks.

The idea is to not overload the message function, but to change the type of its inputs and the return type depending on the message.
The return type for messages that do not contain markup placeholders is string. Otherwise, it's a ReactNode.
The input type for regular parameters stays unchanged, but the type for markup parameters is a React component.

Why ReactNode as return type is okay

The message function would only be used with placeholders in places where a ReactNode is expected. If the developer needs a string, they would not be able to use a component anyway, regardless of paraglide's support for it. If a string version and a rich text version are required, they can create two different messages.

Examples

I omitted the optional options parameter here, since it's not relevant.

Markup with children

"balance": "You have {#tooltip}{amount} coins{/tooltip}."
m.balance: (
  inputs: {
    amount: NonNullable<unknown>;
    tooltip: React.ComponentType<{ children: React.ReactNode }>;
  }
) => React.ReactNode;

Markup without children

"balance": "You have {amount} coins{#tooltip/}."
m.balance: (
  inputs: {
    amount: NonNullable<unknown>;
    tooltip: React.ComponentType<Record<never, never>>;
  }
) => React.ReactNode;

No markup

"balance": "You have {amount} coins."
m.balance: (
  inputs: {
    amount: NonNullable<unknown>;
  }
) => string;

This would provide the best DX for me.
No breaking changes and there is still only one message function generated per message.
Does this make sense?

@samuelstroschein samuelstroschein changed the title add rfc markup rfc Jan 16, 2026
@samuelstroschein
Copy link
Member Author

@kmsomebody thanks for the in-depth feedback.

I see the appeal of the API "just have one message function and let the compiler change return types as needed".

<p>{m.hello({ amount: 5 })}</p>

Two downsides:

1. Mixing inputs and markup risks namespace collisions

"balance": "You have {#amount}{amount} coins{/amount}."
// 💥 amount is not a react node
m.hello({ amount: 5 })

Question is: How often will that happen? Do we need to optimize for namespace collisions? After all, it can be linted via opral/lix#239.

Adding an overload for markup as 3rd argument kinda seems out of question. The API would get ugly.

m.hello({ amount: 5 }, {}, {amount: <Component />}

2. A compiler flag would be needed for the compiler

Seems OK tbh. Question is just if the message returning something like a Svelte|Solid|... etc. node works in all frameworks

options: {
+  framework: "react" | svelte | ...
}

@kmsomebody
Copy link

1. Mixing inputs and markup risks namespace collisions

"balance": "You have {#amount}{amount} coins{/amount}."
// 💥 amount is not a react node
m.hello({ amount: 5 })

Question is: How often will that happen? Do we need to optimize for namespace collisions? After all, it can be linted via opral/lix#239.

Can the compiler detect that and throw? I think this should just be considered invalid.

@samuelstroschein
Copy link
Member Author

Can the compiler detect that and throw? I think this should just be considered invalid.

Yes, it could throw.

@TiagoPortfolio
Copy link

I think this is the main feature that prevents people from shifting from other locales solutions to inlang or praglidejs.

Here is a simple example:

en.json

{
  "welcome_user": "Welcome {username}!",
}

index.tsx

{m.welcome_user({
  username: () => <strong>{user.username}</strong>
})}

```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! 😄

@YUCLing
Copy link

YUCLing commented Feb 9, 2026

Hi! I've made an implementation for Svelte Runes. However, I'm struggling with TypeScript's typing. But I'd wish this can help you with building a better adapter for Svelte.

opral/paraglide-js@markup...YUCLing:paraglide-js:markup

@samuelstroschein samuelstroschein merged commit a4f6277 into main Feb 10, 2026
3 checks passed
@samuelstroschein samuelstroschein deleted the markup-rfc branch February 10, 2026 18:54
@github-actions github-actions bot locked and limited conversation to collaborators Feb 10, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants