mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-22 01:52:17 -05:00
Add rich text support for svelte i18n, parsing <p> tags etc. Also fixed some tags.
This commit is contained in:
1 parent
7ea89dc347
commit
9dcd6d56db
8 files changed
+167
-13
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('nav.library')}{$_('documentTitle.suffix')}</title>
|
||||
<title>{$_('nav.library')} | Spoolman</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="library">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user