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