From 9dcd6d56db5503502045cab0eec06ceb29abaae2 Mon Sep 17 00:00:00 2001 From: Donkie Date: Mon, 13 Jul 2026 23:26:55 +0200 Subject: [PATCH] Add rich text support for svelte i18n, parsing

tags etc. Also fixed some tags. --- client_v2/src/lib/components/NavTabs.svelte | 4 +- client_v2/src/lib/i18n/Trans.svelte | 75 +++++++++++++++++++ client_v2/src/lib/i18n/richText.ts | 83 +++++++++++++++++++++ client_v2/src/routes/+page.svelte | 2 +- client_v2/src/routes/labels/+page.svelte | 2 +- client_v2/src/routes/locations/+page.svelte | 2 +- client_v2/src/routes/settings/+page.svelte | 6 +- client_v2/static/locales/en/common.json | 6 +- 8 files changed, 167 insertions(+), 13 deletions(-) create mode 100644 client_v2/src/lib/i18n/Trans.svelte create mode 100644 client_v2/src/lib/i18n/richText.ts diff --git a/client_v2/src/lib/components/NavTabs.svelte b/client_v2/src/lib/components/NavTabs.svelte index c0bc76aa..d6ff903b 100644 --- a/client_v2/src/lib/components/NavTabs.svelte +++ b/client_v2/src/lib/components/NavTabs.svelte @@ -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 { diff --git a/client_v2/src/lib/i18n/Trans.svelte b/client_v2/src/lib/i18n/Trans.svelte new file mode 100644 index 00000000..55f5252d --- /dev/null +++ b/client_v2/src/lib/i18n/Trans.svelte @@ -0,0 +1,75 @@ + + + +{#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'} +

{@render children()}

+ {:else if node.name === 'br'} +
+ {:else if node.name === 'title'} +
{@render children()}
+ {:else} + {@render children()} + {/if} +{/snippet} + +{@render render(nodes)} + + diff --git a/client_v2/src/lib/i18n/richText.ts b/client_v2/src/lib/i18n/richText.ts new file mode 100644 index 00000000..de3dc7c9 --- /dev/null +++ b/client_v2/src/lib/i18n/richText.ts @@ -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 ) contain tags like

,
, 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; +} diff --git a/client_v2/src/routes/+page.svelte b/client_v2/src/routes/+page.svelte index d5239c14..86d3a1a1 100644 --- a/client_v2/src/routes/+page.svelte +++ b/client_v2/src/routes/+page.svelte @@ -15,7 +15,7 @@ </script> <svelte:head> - <title>{$_('nav.library')}{$_('documentTitle.suffix')} + {$_('nav.library')} | Spoolman

diff --git a/client_v2/src/routes/labels/+page.svelte b/client_v2/src/routes/labels/+page.svelte index 04aa2e36..0e22a5f6 100644 --- a/client_v2/src/routes/labels/+page.svelte +++ b/client_v2/src/routes/labels/+page.svelte @@ -87,7 +87,7 @@ - {$_('nav.labels')}{$_('documentTitle.suffix')} + {$_('nav.labels')} | Spoolman
diff --git a/client_v2/src/routes/locations/+page.svelte b/client_v2/src/routes/locations/+page.svelte index 471279fb..567b97e1 100644 --- a/client_v2/src/routes/locations/+page.svelte +++ b/client_v2/src/routes/locations/+page.svelte @@ -131,7 +131,7 @@ - {$_('nav.locations')}{$_('documentTitle.suffix')} + {$_('nav.locations')} | Spoolman
diff --git a/client_v2/src/routes/settings/+page.svelte b/client_v2/src/routes/settings/+page.svelte index 57186b0d..17cfbebc 100644 --- a/client_v2/src/routes/settings/+page.svelte +++ b/client_v2/src/routes/settings/+page.svelte @@ -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 @@ - {$_('settings.header')}{$_('documentTitle.suffix')} + {$_('settings.header')} | Spoolman
{$_('settings.header')}
-
{$_('settings.server_note')}
{$_('settings.appearance.tab')}
@@ -86,7 +86,7 @@
{$_('settings.extra_fields.tab')}
-
{$_('settings.extra_fields.short_desc')}
+
diff --git a/client_v2/static/locales/en/common.json b/client_v2/static/locales/en/common.json index c42ffbd9..0ddeeab2 100644 --- a/client_v2/static/locales/en/common.json +++ b/client_v2/static/locales/en/common.json @@ -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" } } -} +} \ No newline at end of file