diff --git a/rfcs/markup/RFC.md b/rfcs/markup/RFC.md new file mode 100644 index 0000000000..6d3cc869ea --- /dev/null +++ b/rfcs/markup/RFC.md @@ -0,0 +1,244 @@ +# RFC: `parts()` API + `` 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 `` 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 `` 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 + ( + {children} + ), + b: ({ children }) => {children}, + }} +/> +``` + +#### Vue + +```vue + + + + +``` + +#### Svelte + +```svelte + + {#snippet link({ children, inputs })} + {children} + {/snippet} + {#snippet b({ children })} + {children} + {/snippet} + +``` + +**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) => {chunks} }); +``` + +**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 + +``` + +**Pros** + +- Excellent DX and type safety + +**Cons** + +- Many generated exports +- Autocomplete noise +- Adds extra abstraction per message + +### 4) Parsing the final string + +```tsx + +``` + +**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()` + ``) + +- 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 `` 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?