refactor: use paraglide markup instead of custom implementation

This commit is contained in:
Elias Schneider
2026-08-01 15:49:45 +02:00
parent d24a68d24f
commit b08ac7bf7d
46 changed files with 477 additions and 456 deletions
@@ -4,7 +4,6 @@
import { LucideChevronDown, type Icon as IconType } from '@lucide/svelte';
import { onMount, type Snippet } from 'svelte';
import { slide } from 'svelte/transition';
import FormattedMessage from './formatted-message.svelte';
import { Button } from './ui/button';
import * as Card from './ui/card';
@@ -71,7 +70,7 @@
{title}
</Card.Title>
{#if description}
<Card.Description><FormattedMessage m={description} /></Card.Description>
<Card.Description>{description}</Card.Description>
{/if}
</div>
{#if button}
@@ -10,7 +10,11 @@
<AlertDialog.Header>
<AlertDialog.Title>{$confirmDialogStore.title}</AlertDialog.Title>
<AlertDialog.Description>
<FormattedMessage m={$confirmDialogStore.message} />
{#if typeof $confirmDialogStore.message === 'string'}
{$confirmDialogStore.message}
{:else}
<FormattedMessage {...$confirmDialogStore.message} />
{/if}
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
@@ -1,8 +1,20 @@
import { m } from '$lib/paraglide/messages';
import { writable } from 'svelte/store';
import type { AnyFormattedMessage, AnyMessage, FormattedMessageProps } from '../formatted-message';
import ConfirmDialog from './confirm-dialog.svelte';
export const confirmDialogStore = writable({
interface ConfirmDialogState {
open: boolean;
title: string;
message: string | AnyFormattedMessage;
confirm: {
label: string;
destructive: boolean;
action: () => void;
};
}
export const confirmDialogStore = writable<ConfirmDialogState>({
open: false,
title: '',
message: '',
@@ -13,13 +25,13 @@ export const confirmDialogStore = writable({
}
});
function openConfirmDialog({
function openConfirmDialog<TMessage extends AnyMessage = AnyMessage>({
title,
message,
confirm
}: {
title: string;
message: string;
message: string | FormattedMessageProps<TMessage>;
confirm: {
label?: string;
destructive?: boolean;
@@ -7,7 +7,6 @@
import { LucideExternalLink } from '@lucide/svelte';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import FormattedMessage from '../formatted-message.svelte';
import { cn } from '$lib/utils/style';
type WithoutChildren = {
@@ -38,7 +37,7 @@
}: HTMLAttributes<HTMLDivElement> &
(WithChildren | WithoutChildren) & {
label?: string;
description?: string;
description?: string | Snippet;
docsLink?: string;
placeholder?: string;
disabled?: boolean;
@@ -63,7 +62,11 @@
{/if}
{#if description}
<Field.Description>
<FormattedMessage m={description} />
{#if typeof description === 'string'}
{description}
{:else}
{@render description()}
{/if}
{#if docsLink}
<a
class="relative text-black after:absolute after:bottom-0 after:left-0 after:h-px after:w-full after:-translate-y-px after:bg-white dark:text-white"
@@ -81,7 +81,7 @@
{/if}
<p class="text-muted-foreground mt-2 text-xs">
<FormattedMessage m={m.logo_from_url_description()} />
<FormattedMessage message={m.logo_from_url_description} />
</p>
</Popover.Content>
</Popover.Root>
@@ -1,100 +1,49 @@
<!-- Component to display messages from Paraglide with support for links in the format <link href="url">text</link>. -->
<!-- This gets redundant in the future, because the library will support this natively. https://github.com/opral/inlang-sdk/issues/240 -->
<script lang="ts">
let {
m
}: {
m: string;
} = $props();
<script lang="ts" generics="TMessage extends AnyMessage">
import {
ParaglideMessage,
type MarkupRendererProps,
type MessageLike,
type MessageOptions
} from '@inlang/paraglide-js-svelte';
import type { Component, Snippet } from 'svelte';
import type { AnyMessage, FormattedMessageProps } from './formatted-message';
interface MessagePart {
type: 'text' | 'link' | 'bold';
content: string;
href?: string;
}
// Extracts attribute value from a tag's attribute string
function getAttr(attrs: string, name: string): string | undefined {
const re = new RegExp(`\\b${name}\\s*=\\s*(["'])(.*?)\\1`, 'i');
const m = re.exec(attrs ?? '');
return m?.[2];
}
const handlers: Record<string, (attrs: string, inner: string) => MessagePart | null> = {
link: (attrs, inner) => {
const href = getAttr(attrs, 'href');
if (!href) return { type: 'text', content: inner };
return { type: 'link', content: inner, href };
},
b: (_attrs, inner) => ({ type: 'bold', content: inner })
type BoldTag = {
options: Record<string, never>;
attributes: Record<string, never>;
children: true;
};
function buildTokenRegex(): RegExp {
const keys = Object.keys(handlers).join('|');
// Matches: <tag attrs>inner</tag> for allowed tags only
return new RegExp(`<(${keys})\\b([^>]*)>(.*?)<\\/\\1>`, 'g');
}
type LinkTag = {
options: { href: string };
attributes: Record<string, never>;
children: true;
};
function parseMessage(content: string): MessagePart[] | string {
const tokenRegex = buildTokenRegex();
if (!tokenRegex.test(content)) return content;
// Reset lastIndex for reuse
tokenRegex.lastIndex = 0;
type NativeMessageProps = {
message: MessageLike<any, any, any>;
inputs?: unknown;
b: Snippet<[MarkupRendererProps<unknown, MessageOptions, BoldTag>]>;
link: Snippet<[MarkupRendererProps<unknown, MessageOptions, LinkTag>]>;
};
const parts: MessagePart[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
const NativeParaglideMessage = ParaglideMessage as unknown as Component<NativeMessageProps>;
while ((match = tokenRegex.exec(content)) !== null) {
// Add text before the matched token
if (match.index > lastIndex) {
const textContent = content.slice(lastIndex, match.index);
if (textContent) parts.push({ type: 'text', content: textContent });
}
const tag = match[1];
const attrs = match[2] ?? '';
const inner = match[3] ?? '';
const handler = handlers[tag];
const part: MessagePart | null = handler
? handler(attrs, inner)
: { type: 'text', content: inner };
if (part) parts.push(part);
lastIndex = match.index + match[0].length;
}
// Add remaining text after the last token
if (lastIndex < content.length) {
const remainingText = content.slice(lastIndex);
if (remainingText) parts.push({ type: 'text', content: remainingText });
}
return parts;
}
const parsedContent = parseMessage(m);
let { message, inputs }: FormattedMessageProps<TMessage> = $props();
</script>
{#if typeof parsedContent === 'string'}
{parsedContent}
{:else}
{#each parsedContent as part, i (i)}
{#if part.type === 'text'}
{part.content}
{:else if part.type === 'bold'}
<b>
{part.content}
</b>
{:else if part.type === 'link'}
<a
class="text-black underline dark:text-white"
href={part.href}
target="_blank"
rel="noopener noreferrer"
>
{part.content}
</a>
{/if}
{/each}
{/if}
<NativeParaglideMessage {message} {inputs}>
{#snippet b({ children })}
<b>{@render children?.()}</b>
{/snippet}
{#snippet link({ children, options })}
<a
class="text-black underline dark:text-white"
href={options.href}
target="_blank"
rel="noopener noreferrer"
>
{@render children?.()}
</a>
{/snippet}
</NativeParaglideMessage>
@@ -0,0 +1,18 @@
import type { MessageLike } from '@inlang/paraglide-js-svelte';
export type AnyMessage = MessageLike<any, any, any>;
type MessageInputs<TMessage extends AnyMessage> = Parameters<TMessage>[0];
type MessageInputsProp<TInputs> = undefined extends TInputs
? { inputs?: Exclude<NoInfer<TInputs>, undefined> }
: { inputs: NoInfer<TInputs> };
export type FormattedMessageProps<TMessage extends AnyMessage> = {
message: TMessage;
} & MessageInputsProp<MessageInputs<TMessage>>;
export type AnyFormattedMessage = {
message: AnyMessage;
inputs?: unknown;
};
+6 -4
View File
@@ -207,10 +207,11 @@
{:else if reauthenticationRequired && deviceInfo?.client}
<p class="text-muted-foreground mt-2">
<FormattedMessage
m={m.do_you_want_to_sign_in_to_client_with_your_app_name_account({
message={m.do_you_want_to_sign_in_to_client_with_your_app_name_account}
inputs={{
client: deviceInfo.client.name,
appName: $appConfigStore.appName
})}
}}
/>
</p>
{:else if authorizationRequired}
@@ -219,9 +220,10 @@
<Card.Header>
<Card.Description class="text-start">
<FormattedMessage
m={m.client_wants_to_access_the_following_information({
message={m.client_wants_to_access_the_following_information}
inputs={{
client: deviceInfo!.client.name
})}
}}
/>
</Card.Description>
</Card.Header>
+8 -5
View File
@@ -120,14 +120,16 @@
{errorMessage}.
{:else if currentStep == 'select_account' && $userStore}
<FormattedMessage
m={m.account_selection_signin_confirmation({ name: interactionSession.client.name })}
message={m.account_selection_signin_confirmation}
inputs={{ name: interactionSession.client.name }}
/>
{:else}
<FormattedMessage
m={m.do_you_want_to_sign_in_to_client_with_your_app_name_account({
message={m.do_you_want_to_sign_in_to_client_with_your_app_name_account}
inputs={{
client: interactionSession.client.name,
appName: $appConfigStore.appName
})}
}}
/>
{/if}
</p>
@@ -174,9 +176,10 @@
<Card.Header>
<p class="text-muted-foreground text-start">
<FormattedMessage
m={m.client_wants_to_access_the_following_information({
message={m.client_wants_to_access_the_following_information}
inputs={{
client: interactionSession.client.name
})}
}}
/>
</p>
</Card.Header>
+3 -2
View File
@@ -38,10 +38,11 @@
<p class="text-muted-foreground mt-2">
<FormattedMessage
m={m.do_you_want_to_sign_out_of_pocketid_with_the_account({
message={m.do_you_want_to_sign_out_of_pocketid_with_the_account}
inputs={{
username: $userStore?.username ?? '',
appName: $appConfigStore.appName
})}
}}
/>
</p>
<div class="mt-10 flex w-full justify-stretch gap-2">
@@ -143,7 +143,7 @@
<Item.Description>
{m.select_the_language_you_want_to_use()}
<br />
<FormattedMessage m={m.contribute_to_translation()} />
<FormattedMessage message={m.contribute_to_translation} />
</Item.Description>
</Item.Content>
<Item.Actions>
@@ -42,9 +42,9 @@
<ShieldPlus class="text-primary/80 size-5" />
{m.create_api_key()}
</Card.Title>
<Card.Description
><FormattedMessage m={m.add_a_new_api_key_for_programmatic_access()} /></Card.Description
>
<Card.Description>
<FormattedMessage message={m.add_a_new_api_key_for_programmatic_access} />
</Card.Description>
</div>
{#if !expandAddApiKey}
<Button class="w-full md:w-auto" onclick={() => (expandAddApiKey = true)}
@@ -46,9 +46,9 @@
<LucidePlus class="text-primary/80 size-5" />
{m.create_api()}
</Card.Title>
<Card.Description
><FormattedMessage m={m.create_a_new_api_description()} /></Card.Description
>
<Card.Description>
<FormattedMessage message={m.create_a_new_api_description} />
</Card.Description>
</div>
{#if !expandAddApi}
<Button class="w-full md:w-auto" onclick={() => (expandAddApi = true)}
@@ -127,7 +127,10 @@
function disableGroupRestriction() {
openConfirmDialog({
title: m.unrestrict_oidc_client({ clientName: client.name }),
message: m.confirm_unrestrict_oidc_client_description({ clientName: client.name }),
message: {
message: m.confirm_unrestrict_oidc_client_description,
inputs: { clientName: client.name }
},
confirm: {
label: m.unrestrict(),
destructive: true,
@@ -373,7 +376,7 @@
<Card.Header>
<Card.Title>{m.scim_provisioning()}</Card.Title>
<Card.Description>
<FormattedMessage m={m.scim_provisioning_description()} />
<FormattedMessage message={m.scim_provisioning_description} />
</Card.Description>
</Card.Header>
<Card.Content>
@@ -50,9 +50,10 @@
async function onDisable() {
openConfirmDialog({
title: m.disable_scim_provisioning(),
message: m.disable_scim_provisioning_confirm_description({
clientName: existingProvider!.oidcClient.name
}),
message: {
message: m.disable_scim_provisioning_confirm_description,
inputs: { clientName: existingProvider!.oidcClient.name }
},
confirm: {
label: m.disable(),
destructive: true,
@@ -16,7 +16,7 @@
...restProps
}: HTMLAttributes<HTMLDivElement> & {
label: string;
description: string;
description: string | Snippet;
callbackURLs: string[];
error?: string | null;
children?: Snippet;
@@ -1,5 +1,6 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import FormattedMessage from '$lib/components/formatted-message.svelte';
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
import { Button } from '$lib/components/ui/button';
import * as Tabs from '$lib/components/ui/tabs';
@@ -186,6 +187,14 @@
}
</script>
{#snippet callbackUrlDescription()}
<FormattedMessage message={m.callback_url_description} />
{/snippet}
{#snippet logoutCallbackUrlDescription()}
<FormattedMessage message={m.logout_callback_url_description} />
{/snippet}
<form onsubmit={preventDefault(onSubmit)}>
<div class="grid grid-cols-1 gap-x-3 gap-y-7 sm:flex-row md:grid-cols-2">
<FormInput
@@ -209,14 +218,14 @@
/>
<OidcCallbackUrlInput
label={m.callback_urls()}
description={m.callback_url_description()}
description={callbackUrlDescription}
class="w-full"
bind:callbackURLs={$inputs.callbackURLs.value}
bind:error={$inputs.callbackURLs.error}
/>
<OidcCallbackUrlInput
label={m.logout_callback_urls()}
description={m.logout_callback_url_description()}
description={logoutCallbackUrlDescription}
class="w-full"
bind:callbackURLs={$inputs.logoutCallbackURLs.value}
bind:error={$inputs.logoutCallbackURLs.error}
@@ -28,9 +28,10 @@
async function revokeAuthorizedClient(client: OidcClientMetaData) {
openConfirmDialog({
title: m.revoke_access(),
message: m.revoke_access_description({
clientName: client.name
}),
message: {
message: m.revoke_access_description,
inputs: { clientName: client.name }
},
confirm: {
label: m.revoke(),
destructive: true,