Skip to content

Commit a4f6277

Browse files
markup rfc (#4309)
<!-- CURSOR_SUMMARY --> > [!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 > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit eec13c3. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 35e1195 commit a4f6277

File tree

1 file changed

+244
-0
lines changed

1 file changed

+244
-0
lines changed

rfcs/markup/RFC.md

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
# RFC: `parts()` API + `<Message>` rendering for markup placeholders
2+
3+
**Status:** Draft
4+
**Goal:** Unblock safe, ergonomic markup / component interpolation in Paraglide JS
5+
6+
---
7+
8+
## Context
9+
10+
Many users want to use **markup placeholders** (bold, links, inline components) in translations without using raw HTML or `dangerouslySetInnerHTML`.
11+
12+
We already align on:
13+
14+
- **MessageFormat 2–style markup placeholders** (`{#b}…{/b}`, `{#icon/}`)
15+
- Markup lives in the **inlang SDK AST**
16+
- Translators control _where_ markup appears
17+
- Rendering should be **safe by default** (no HTML parsing)
18+
19+
The open question is:
20+
**What API should Paraglide JS expose?**
21+
22+
## Requirements
23+
24+
### Must have
25+
26+
- **Framework-agnostic compiler output**
27+
28+
- No `.tsx`, `.vue`, or `.svelte` files generated
29+
30+
- **MessageFormat 2–derived model**
31+
32+
- Start / end / standalone markup placeholders
33+
34+
- **Injection-safe**
35+
36+
- Markup comes only from message patterns, not from interpolated values
37+
38+
- **`m.key()` keeps working**
39+
40+
- Must still return a plain string for `title`, `aria-label`, logging, etc.
41+
42+
### Nice to have
43+
44+
- Simple mental model
45+
- Works well for **React, Vue, and Svelte**
46+
- Avoids per-message components or many imports
47+
- Allows **strong typing** for required markup tags
48+
49+
## Proposed API
50+
51+
### 1) `message.parts()` (only for messages with markup)
52+
53+
For messages that contain markup placeholders, the generated message function additionally exposes:
54+
55+
```ts
56+
m.contact();
57+
m.contact.parts();
58+
```
59+
60+
`parts()` returns a **framework-neutral array of parts**:
61+
62+
```ts
63+
type MessagePart =
64+
| { type: "text"; value: string }
65+
| { type: "markupStart"; name: string }
66+
| { type: "markupEnd"; name: string }
67+
| { type: "markupStandalone"; name: string };
68+
```
69+
70+
- Markup comes from the **message AST**
71+
- Interpolated values are emitted as **text**, never re-parsed
72+
- Messages **without markup do not get `parts()`**
73+
- This keeps non-markup messages tree-shakable and minimal
74+
75+
### Markup semantics (important clarification)
76+
77+
- Markup placeholders are **wrappers**, not values
78+
(`{#b}text{/b}`, `{#link}…{/link}`)
79+
- Therefore renderers receive **`children`** (the content inside the tag)
80+
- **Nested markup is allowed** (e.g. `{#link}{#b}Text{/b}{/link}`)
81+
82+
> MessageFormat 2 allows markup but does not require tags to be hierarchical.
83+
> **Paraglide will initially support only well-formed, properly nested markup**
84+
> and treat crossing / invalid markup as a lint or build error.
85+
86+
Standalone tags (`{#icon/}`) do **not** receive `children`.
87+
88+
### 2) Framework-specific `<Message>` components (outside the compiler)
89+
90+
Rendering is handled by **framework adapters**, not by generated files:
91+
92+
- `@inlang/paraglide-js/react`
93+
- `@inlang/paraglide-js/vue`
94+
- `@inlang/paraglide-js/svelte`
95+
96+
Each adapter exports a single `<Message>` component.
97+
98+
### Rendering API shape
99+
100+
The rendering API uses a **`markup` prop**, not `components`, to emphasize that:
101+
102+
- Keys correspond to **markup placeholders defined in the message**
103+
- Keys must **exactly match** the tag names used by the translator
104+
105+
Example message:
106+
107+
```json
108+
{
109+
"contact": "Send {#link}an email{/link} and read the {#b}docs{/b}."
110+
}
111+
```
112+
113+
#### React
114+
115+
```tsx
116+
<Message
117+
message={m.contact}
118+
inputs={{ email: "info@example.com" }}
119+
markup={{
120+
link: ({ children, inputs }) => (
121+
<a href={`mailto:${inputs.email}`}>{children}</a>
122+
),
123+
b: ({ children }) => <strong>{children}</strong>,
124+
}}
125+
/>
126+
```
127+
128+
#### Vue
129+
130+
```vue
131+
<Message :message="m.contact" :inputs="{ email: 'info@example.com' }">
132+
<template #link="{ children, inputs }">
133+
<a :href="`mailto:${inputs.email}`"><component :is="children" /></a>
134+
</template>
135+
<template #b="{ children }">
136+
<strong><component :is="children" /></strong>
137+
</template>
138+
</Message>
139+
```
140+
141+
#### Svelte
142+
143+
```svelte
144+
<Message message={m.contact} inputs={{ email: "info@example.com" }}>
145+
{#snippet link({ children, inputs })}
146+
<a href={"mailto:" + inputs.email}>{children}</a>
147+
{/snippet}
148+
{#snippet b({ children })}
149+
<strong>{children}</strong>
150+
{/snippet}
151+
</Message>
152+
```
153+
154+
**Important:**
155+
156+
- No framework-specific files are generated by the compiler
157+
- Adapters live in separate packages
158+
- `markup` keys must match the exact tag names used in the message (type-checked)
159+
- `children` represents the (possibly nested) content inside the markup tag
160+
161+
## Considered alternative APIs
162+
163+
### 1) `m.message.rich(...)`
164+
165+
```ts
166+
m.contact.rich(inputs, { b: (chunks) => <b>{chunks}</b> });
167+
```
168+
169+
**Pros**
170+
171+
- Very ergonomic in React
172+
173+
**Cons**
174+
175+
- Return type becomes framework-specific
176+
- Hard to support Svelte cleanly
177+
- Pushes compiler toward framework modes
178+
179+
### 2) Overloading the message function
180+
181+
```ts
182+
m.contact({ email }, { markup: { b: fn } });
183+
```
184+
185+
**Pros**
186+
187+
- Single entry point
188+
189+
**Cons**
190+
191+
- Ambiguous return type (string vs rich output)
192+
- Harder typing and worse DX at scale
193+
194+
### 3) Per-message components (`m.contact.Rich`)
195+
196+
```tsx
197+
<m.contact.Rich inputs={...} />
198+
```
199+
200+
**Pros**
201+
202+
- Excellent DX and type safety
203+
204+
**Cons**
205+
206+
- Many generated exports
207+
- Autocomplete noise
208+
- Adds extra abstraction per message
209+
210+
### 4) Parsing the final string
211+
212+
```tsx
213+
<Message str={m.contact(inputs)} />
214+
```
215+
216+
**Pros**
217+
218+
- Looks simple
219+
220+
**Cons**
221+
222+
- Injection risks unless inputs are escaped
223+
- Harder to lint and type-check
224+
- Markup is detected too late
225+
226+
## Why Option B (`parts()` + `<Message>`)
227+
228+
- Keeps the **compiler framework-agnostic**
229+
- Avoids bundle bloat for non-markup messages
230+
- Clean security boundary
231+
- Single, stable primitive (`parts()`)
232+
- Framework-native rendering via adapters
233+
- Strong typing tied to translator-defined markup
234+
- Naturally supports nested markup via `children`
235+
236+
## Open questions (feedback welcome)
237+
238+
1. Is `parts()` the right low-level primitive?
239+
2. Is `<Message>` the right primary API, or should we also expose a `renderMessage()` helper?
240+
3. For missing markup mappings, should the default behavior be:
241+
242+
- pass-through silently?
243+
- warn?
244+
- throw?

0 commit comments

Comments
 (0)