Skip to content

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 | ...
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants