-
Notifications
You must be signed in to change notification settings - Fork 174
markup rfc #4309
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
markup rfc #4309
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,244 @@ | ||
| # RFC: `parts()` API + `<Message>` 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 `<Message>` 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 `<Message>` 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 | ||
| <Message | ||
| message={m.contact} | ||
| inputs={{ email: "info@example.com" }} | ||
| markup={{ | ||
| link: ({ children, inputs }) => ( | ||
| <a href={`mailto:${inputs.email}`}>{children}</a> | ||
| ), | ||
| b: ({ children }) => <strong>{children}</strong>, | ||
| }} | ||
| /> | ||
| ``` | ||
|
|
||
| #### Vue | ||
|
|
||
| ```vue | ||
| <Message :message="m.contact" :inputs="{ email: 'info@example.com' }"> | ||
| <template #link="{ children, inputs }"> | ||
| <a :href="`mailto:${inputs.email}`"><component :is="children" /></a> | ||
| </template> | ||
| <template #b="{ children }"> | ||
| <strong><component :is="children" /></strong> | ||
| </template> | ||
| </Message> | ||
| ``` | ||
|
|
||
| #### Svelte | ||
|
|
||
| ```svelte | ||
| <Message message={m.contact} inputs={{ email: "info@example.com" }}> | ||
| {#snippet link({ children, inputs })} | ||
| <a href={"mailto:" + inputs.email}>{children}</a> | ||
| {/snippet} | ||
| {#snippet b({ children })} | ||
| <strong>{children}</strong> | ||
| {/snippet} | ||
| </Message> | ||
| ``` | ||
|
|
||
| **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) => <b>{chunks}</b> }); | ||
| ``` | ||
|
|
||
| **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 | ||
| <m.contact.Rich inputs={...} /> | ||
| ``` | ||
|
|
||
| **Pros** | ||
|
|
||
| - Excellent DX and type safety | ||
|
|
||
| **Cons** | ||
|
|
||
| - Many generated exports | ||
| - Autocomplete noise | ||
| - Adds extra abstraction per message | ||
|
|
||
| ### 4) Parsing the final string | ||
|
|
||
| ```tsx | ||
| <Message str={m.contact(inputs)} /> | ||
| ``` | ||
|
|
||
| **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()` + `<Message>`) | ||
|
|
||
| - 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 `<Message>` 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? | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Check out the implementation PR opral/paraglide-js#613
Here are the tests files https://github.com/opral/paraglide-js/pull/613/changes#diff-55d36a2983d72f441e2514c366c2101e35fd52c93b105dfd2554f19adee7fe7f
It uses @html and not @snippet
There was a problem hiding this comment.
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?
I would have wanted and expected something like
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.tsMessage.svelte+page.svelte(usage)Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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)Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM! 😄