Add rich text support for svelte i18n, parsing <p> tags etc. Also fixed some tags.

This commit is contained in:
Donkie committed 2026-07-13 23:26:55 +02:00
1 parent 7ea89dc347
commit 9dcd6d56db
8 files changed
+167 -13

No files matched your search

+2 -2
View File
@@ -4,9 +4,9 @@
const tabs = [
{ href: '/', key: 'nav.library' },
{ href: '/locations', key: 'nav.locations' },
{ href: '/locations', key: 'locations.locations' },
{ href: '/labels', key: 'nav.labels' },
{ href: '/settings', key: 'nav.settings' }
{ href: '/settings', key: 'settings.header' }
];
function isActive(href: string): boolean {
+75
View File
@@ -0,0 +1,75 @@
<!--
Trans renders an i18n message that contains inline markup tags — the Svelte
analog of react-i18next's <Trans>.
It resolves the message with svelte-i18n (so ICU {placeholders} are still
interpolated from `values`), parses the resulting string with parseRichText,
then renders the node tree. Text nodes render as escaped text; tag nodes are
matched against the `tags` snippet map, falling back to built-in p/br/title.
SECURITY: `key`/`values` are app-controlled, but the resolved *string* comes
from a locale file that is community-maintained via Weblate and must be
treated as untrusted. Rendering never uses {@html}: every text node is
escaped by Svelte, and only allowlisted tag names map to real markup. A tag
the app didn't provide (and isn't a built-in) renders its children only, with
the wrapper dropped — so injected tags/attributes cannot become live DOM.
Usage:
<Trans key="home.description" tags={{ helpPageLink }} />
{#snippet helpPageLink(children)}
<a href="/help">{@render children()}</a>
{/snippet}
-->
<script lang="ts">
import type { Snippet } from 'svelte';
import { _ } from 'svelte-i18n';
import { parseRichText, type TransNode } from './richText';
interface Props {
/** i18n message key. */
key: string;
/** ICU placeholder values for the message. */
values?: Record<string, string | number | boolean | Date | null | undefined>;
/**
* Map of tag name -> snippet. Each snippet receives a `children` snippet
* that renders the tag's inner content. Provide the link/list/etc. tags
* the message uses; p/br/title have built-in defaults.
*/
tags?: Record<string, Snippet<[Snippet]>>;
}
let { key, values, tags }: Props = $props();
let nodes = $derived(parseRichText($_(key, { values })));
</script>
{#snippet render(list: TransNode[])}
{#each list as node, i (i)}
{#if node.type === 'text'}{node.value}{:else}{@render tagNode(node)}{/if}
{/each}
{/snippet}
{#snippet tagNode(node: TransNode & { type: 'tag' })}
{#snippet children()}{@render render(node.children)}{/snippet}
{#if tags?.[node.name]}
{@render tags[node.name](children)}
{:else if node.name === 'p'}
<p>{@render children()}</p>
{:else if node.name === 'br'}
<br />
{:else if node.name === 'title'}
<div class="trans-title">{@render children()}</div>
{:else}
{@render children()}
{/if}
{/snippet}
{@render render(nodes)}
<style>
.trans-title {
font-weight: 600;
font-size: 1.25em;
margin-bottom: 0.5em;
}
</style>
+83
View File
@@ -0,0 +1,83 @@
// Rich-text parsing for i18n strings that carry inline markup tags.
//
// Our locale strings (seeded from the old React client, which used
// react-i18next's <Trans>) contain tags like <p>, <br>, <title> and named
// component tags like <helpPageLink>Help</helpPageLink> or <itemsHelp/>.
// svelte-i18n runs with ignoreTag: true, so $_(key) returns these tags as
// literal text. This module turns that literal text into a small node tree
// that Trans.svelte renders safely.
//
// SECURITY: every non-English locale is community-maintained via Weblate, so a
// translated string is untrusted input. This parser therefore only ever
// recognises *bare* tag names (no attributes are read from the string) and
// produces text + tag nodes — never HTML. Trans.svelte renders text nodes as
// escaped text and maps tag names against a fixed allowlist, so a hostile
// translation can at worst produce odd-looking but inert text. Do not feed the
// raw string into {@html}.
export type TransNode =
| { type: 'text'; value: string }
| { type: 'tag'; name: string; children: TransNode[] };
// Matches <name>, </name> and <name/> — and nothing with attributes. The name
// must be a plain identifier; anything else (e.g. `<a href="x">`, `<img ...>`)
// fails to match and is left in place as literal text, which Trans escapes.
const TAG_RE = /<(\/?)([a-zA-Z][a-zA-Z0-9_-]*)\s*(\/?)>/g;
// Tags that never have children: an opening <br> is a leaf, not a container.
const VOID_TAGS = new Set(['br']);
/**
* Parse a string containing bare markup tags into a node tree.
*
* The parser is deliberately lenient: unmatched closing tags are ignored and
* unclosed containers are auto-closed at end of input, so a translator who
* drops or reorders a tag gets degraded rendering rather than a thrown error.
*/
export function parseRichText(input: string): TransNode[] {
const root: TransNode[] = [];
// `stack` holds the children arrays we're currently appending into; stack[0]
// is the root. `openNames` names the open container at each depth, so
// openNames[k] corresponds to stack[k + 1].
const stack: TransNode[][] = [root];
const openNames: string[] = [];
const pushText = (s: string) => {
if (s) stack[stack.length - 1].push({ type: 'text', value: s });
};
let last = 0;
let m: RegExpExecArray | null;
TAG_RE.lastIndex = 0;
while ((m = TAG_RE.exec(input)) !== null) {
pushText(input.slice(last, m.index));
last = TAG_RE.lastIndex;
const closing = m[1] === '/';
const name = m[2];
const selfClosing = m[3] === '/';
if (selfClosing || (!closing && VOID_TAGS.has(name))) {
// <name/> or a void <br>: a childless leaf.
stack[stack.length - 1].push({ type: 'tag', name, children: [] });
} else if (closing) {
// </name>: close the nearest matching open container, auto-closing any
// still-open inner containers. A stray close with no match is ignored.
const idx = openNames.lastIndexOf(name);
if (idx !== -1) {
while (openNames.length > idx) {
openNames.pop();
stack.pop();
}
}
} else {
// <name>: open a container and start appending into it.
const node: TransNode = { type: 'tag', name, children: [] };
stack[stack.length - 1].push(node);
stack.push(node.children);
openNames.push(name);
}
}
pushText(input.slice(last));
return root;
}
+1 -1
View File
@@ -15,7 +15,7 @@
</script>
<svelte:head>
<title>{$_('nav.library')}{$_('documentTitle.suffix')}</title>
<title>{$_('nav.library')} | Spoolman</title>
</svelte:head>
<div class="library">
+1 -1
View File
@@ -87,7 +87,7 @@
</script>
<svelte:head>
<title>{$_('nav.labels')}{$_('documentTitle.suffix')}</title>
<title>{$_('nav.labels')} | Spoolman</title>
</svelte:head>
<div class="page scroll-y">
+1 -1
View File
@@ -131,7 +131,7 @@
</script>
<svelte:head>
<title>{$_('nav.locations')}{$_('documentTitle.suffix')}</title>
<title>{$_('nav.locations')} | Spoolman</title>
</svelte:head>
<div class="page scroll-y">
+3 -3
View File
@@ -3,6 +3,7 @@
import Card from '$components/Card.svelte';
import SettingRow from '$components/settings/SettingRow.svelte';
import ExtraFieldsManager from '$components/settings/ExtraFieldsManager.svelte';
import Trans from '$lib/i18n/Trans.svelte';
import { settings } from '$lib/stores/settings.svelte';
import { _, locale } from 'svelte-i18n';
import { setLocale } from '$lib/i18n';
@@ -18,13 +19,12 @@
</script>
<svelte:head>
<title>{$_('settings.header')}{$_('documentTitle.suffix')}</title>
<title>{$_('settings.header')} | Spoolman</title>
</svelte:head>
<div class="page scroll-y">
<div class="wrap">
<div class="title">{$_('settings.header')}</div>
<div class="subtitle">{$_('settings.server_note')}</div>
<div class="sec-label">{$_('settings.appearance.tab')}</div>
<Card divided>
@@ -86,7 +86,7 @@
</Card>
<div class="sec-label">{$_('settings.extra_fields.tab')}</div>
<div class="subtitle sub2">{$_('settings.extra_fields.short_desc')}</div>
<div class="subtitle sub2"><Trans key="settings.extra_fields.description" /></div>
<ExtraFieldsManager />
</div>
</div>
+1 -5
View File
@@ -325,9 +325,7 @@
"actions": "Actions"
},
"settings": {
"settings": "Settings",
"header": "Settings",
"server_note": "General settings and extra fields are stored on the server — applies to every client.",
"appearance": {
"tab": "Appearance"
},
@@ -396,8 +394,6 @@
}
},
"documentTitle": {
"default": "Spoolman",
"suffix": " | Spoolman",
"home": {
"list": "Home | Spoolman"
},
@@ -755,4 +751,4 @@
"group_vendor": "Vendor"
}
}
}
}