Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ Versioning: [Semantic Versioning](https://semver.org/).

## [Unreleased]

### German localization (widget)
- **`data-lang="de"`** — full German dictionary for comments, reactions and
polls; `auto` now also detects German from the browser language
(primary subtag, so `de-DE`, `de-AT`, `de-CH` all match). English stays the
fallback, and `data-text` overrides still win.
- **Admin embed builder** — "Deutsch" added to the language selector.
- **Tests** — new `widget-i18n.test.ts`: language selection, auto-detection,
override precedence, and a key-parity check so every locale defines exactly
the English keys.

### Telegram alerts v2 (runtime)
- **Per-event alert selection** — new `telegram_events` setting
(comment,poll,reaction — default `comment`) with checkboxes in the admin
Expand Down
4 changes: 2 additions & 2 deletions integrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ All templates accept the same options (see the main widget docs in
- `pollStyle` / `pollResults` — poll display options
- `sort` — `newest | oldest | best` (default `newest`)
- `reactionsPosition` — `top | bottom` (default `bottom`)
- `lang` — `en | it | auto` (default `auto`)
- `lang` — `en | it | de | auto` (default `auto`)
- `theme` — `auto | light | dark`
- `accent` / `accent2` — brand colors
- `radius` — corner radius in px (default 14)
Expand Down Expand Up @@ -102,7 +102,7 @@ type CommentProps = {
pollResults?: 'after' | 'always';
sort?: 'newest' | 'oldest' | 'best';
reactionsPosition?: 'top' | 'bottom';
lang?: 'en' | 'it' | 'auto';
lang?: 'en' | 'it' | 'de' | 'auto';
theme?: 'auto' | 'light' | 'dark';
accent?: string;
radius?: string;
Expand Down
2 changes: 1 addition & 1 deletion integrations/react/Comment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ type CommentsProps = {
pollResults?: 'after' | 'always';
sort?: 'newest' | 'oldest' | 'best';
reactionsPosition?: 'top' | 'bottom';
lang?: 'en' | 'it' | 'auto';
lang?: 'en' | 'it' | 'de' | 'auto';
theme?: 'auto' | 'light' | 'dark';
accent?: string;
accent2?: string;
Expand Down
4 changes: 2 additions & 2 deletions packages/runtime/src/static-content.ts

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/runtime/static/admin.html
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,7 @@ <h3>Look &amp; feel</h3>
<option value="auto">Auto (visitor's browser)</option>
<option value="it">Italiano</option>
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
</div>
<div class="w-field">
Expand Down
74 changes: 69 additions & 5 deletions packages/widget/src/widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
*
* Optional look & feel (all can also be passed via window.StaticLayer.mount
* opts: lang, theme, accent, accent2, radius, maxWidth, texts):
* data-lang="auto|en|it" UI language (default auto-detect)
* data-lang="auto|en|it|de" UI language (default auto-detect)
* data-theme="auto|light|dark" force a theme (default: follow OS)
* data-accent="#ff8a2a" accent color (and data-accent-2 for the end)
* data-radius="18" corner radius in px
Expand Down Expand Up @@ -87,7 +87,7 @@
}

/* ------------------------ localization ------------------------------ */
// UI copy is localizable via `data-lang="en|it|auto"` (default: auto-detect
// UI copy is localizable via `data-lang="en|it|de|auto"` (default: auto-detect
// from navigator.language, fallback EN) and overridable per-key via
// `data-text='{"post":"Pubblica","empty":"…"}'` or opts.texts.
// countOne/countMany accept a `{n}` placeholder.
Expand Down Expand Up @@ -211,9 +211,71 @@
showMoreReplies: 'Mostra altre risposte ({n})',
parentRemoved: 'Commento padre rimosso',
author: 'Autore'
},
de: {
title: 'Kommentare',
nickPlaceholder: 'Name (optional)',
bodyPlaceholder: 'Kommentar schreiben …',
post: 'Kommentar senden',
hint: 'Nur Text · Emojis willkommen · Spamschutz per Proof-of-Work',
empty: 'Noch keine Kommentare – schreiben Sie den ersten. 💬',
anonymous: 'Anonym',
emptyComment: 'Der Kommentar darf nicht leer sein.',
solving: 'Proof-of-Work wird berechnet …',
pending: 'Kommentar gesendet – wartet auf Freigabe.',
posted: 'Kommentar veröffentlicht.',
reactAria: 'Reagieren mit ',
reactionRecorded: '✓ Reaktion gespeichert',
slowDown: 'Bitte etwas langsamer – zu viele Reaktionen.',
challengeUsed: 'Challenge bereits verwendet – bitte erneut versuchen.',
countOne: '1 Kommentar',
countMany: '{n} Kommentare',
voted: '✓ Abgestimmt',
closed: 'Diese Umfrage ist beendet',
pollMissing: 'Umfrage nicht gefunden',
alreadyVoted: 'Sie haben bei dieser Umfrage bereits abgestimmt',
pollLoadError: 'Umfrage konnte nicht geladen werden',
pollVoteCountOne: '1 Stimme',
pollVoteCountMany: '{n} Stimmen',
pollLeader: 'Führt mit {n} Stimmen',
pollVoteBtn: 'Abstimmen ({n})',
pollMultiHint: 'Eine oder mehrere Optionen auswählen',
pollChangeVotes: 'Stimmen ändern',
pollVotesChanged: 'Stimmen zurückgesetzt – Sie können erneut abstimmen',
viewResults: 'Ergebnisse ansehen',
backToVote: 'Abstimmen',
resultsPreview: 'Aktuelle Ergebnisse',
startTitle: 'Starten Sie die Diskussion',
pinned: 'Angeheftet',
flag: 'Melden',
flagBusy: 'Meldung wird gesendet …',
flagDone: 'Danke – Meldung gesendet',
flagErr: 'Meldung fehlgeschlagen',
sortNewest: 'Neueste',
sortOldest: 'Älteste',
sortBest: 'Beste',
timeNow: 'gerade eben',
timeMinAgo: 'vor {n} Min.',
timeHourAgo: 'vor {n} Std.',
timeDayAgo: 'vor {n} T.',
timeWeekAgo: 'vor {n} Wo.',
timeMonthAgo: 'vor {n} Mon.',
timeYearAgo: 'vor {n} J.',
readMore: 'Weiterlesen',
showLess: 'Weniger anzeigen',
like: 'Gefällt mir',
liked: 'Gefällt mir',
reply: 'Antworten',
cancelReply: 'Abbrechen',
replyPlaceholder: 'Antwort schreiben …',
showMoreReplies: 'Weitere Antworten anzeigen ({n})',
parentRemoved: 'Ursprünglicher Kommentar entfernt',
author: 'Autor'
}
};

var SUPPORTED_LANGS = ['en', 'it', 'de'];

/* ------------------------------ styles ------------------------------ */
var STYLES = '' +
'.sl-root{--accent:#f57d1f;--accent-2:#e85d0a;--bg:#ffffff;--card:#ffffff;--border:#e8e8e6;' +
Expand Down Expand Up @@ -414,9 +476,11 @@

// -------- localization: lang auto-detect + per-key overrides ---------
var lang = ((opts && opts.lang) || root.getAttribute('data-lang') || 'auto');
if (lang !== 'en' && lang !== 'it') {
lang = (typeof navigator !== 'undefined' && navigator.language &&
navigator.language.toLowerCase().indexOf('it') === 0) ? 'it' : 'en';
if (SUPPORTED_LANGS.indexOf(lang) === -1) {
// Auto-detect from the primary subtag ("de-AT" → "de"), fallback EN.
var navLang = (typeof navigator !== 'undefined' && navigator.language)
? navigator.language.toLowerCase().split('-')[0] : '';
lang = SUPPORTED_LANGS.indexOf(navLang) !== -1 ? navLang : 'en';
}
var dict = I18N[lang] || I18N.en;
var customTexts = null;
Expand Down
97 changes: 97 additions & 0 deletions tests/security/widget-i18n.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Widget localization: `data-lang` selection, navigator.language
* auto-detection, per-key `data-text` overrides, and dictionary completeness
* (every locale must define every key the English dictionary defines, so no
* UI string silently falls back to English or to the raw key).
*/
import { describe, expect, it } from 'vitest';
import { JSDOM } from 'jsdom';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';

const WIDGET_SRC = readFileSync(
fileURLToPath(new URL('../../packages/widget/src/widget.js', import.meta.url)),
'utf8',
);
const WIDGET_JS = readFileSync(
fileURLToPath(new URL('../../packages/widget/dist/widget.js', import.meta.url)),
'utf8',
);

function makeDom(html: string, navigatorLanguage?: string) {
const dom = new JSDOM(`<!doctype html><html><body>${html}</body></html>`, {
url: 'https://site.example.com/post/1',
runScripts: 'outside-only',
});
const { window } = dom;
if (navigatorLanguage !== undefined) {
Object.defineProperty(window.navigator, 'language', { value: navigatorLanguage });
}
window.fetch = async () => ({
ok: true,
status: 200,
json: async () => ({ comments: [] }),
}) as unknown as typeof fetch;
window.eval(WIDGET_JS);
return dom;
}

async function flushAsync(): Promise<void> {
await new Promise((r) => setTimeout(r, 20));
}

function uiText(dom: JSDOM) {
const host = dom.window.document.getElementById('host')!;
return {
title: host.querySelector('.sl-heading h3')?.textContent,
submit: host.querySelector('.sl-submit')?.textContent,
};
}

const HOST = (attrs = '') =>
`<div id="host" data-staticlayer data-endpoint="https://comments.example.com" ${attrs}></div>`;

describe('widget localization', () => {
it('renders German copy with data-lang="de"', async () => {
const dom = makeDom(HOST('data-lang="de"'), 'en-US');
await flushAsync();
expect(uiText(dom)).toEqual({ title: 'Kommentare', submit: 'Kommentar senden' });
});

it('auto-detects German from a regional navigator.language (de-AT)', async () => {
const dom = makeDom(HOST(), 'de-AT');
await flushAsync();
expect(uiText(dom).title).toBe('Kommentare');
});

it('still auto-detects Italian', async () => {
const dom = makeDom(HOST(), 'it-IT');
await flushAsync();
expect(uiText(dom).title).toBe('Commenti');
});

it('falls back to English for unsupported languages', async () => {
const dom = makeDom(HOST(), 'fr-FR');
await flushAsync();
expect(uiText(dom).title).toBe('Comments');
});

it('lets data-text overrides win over the German dictionary', async () => {
const dom = makeDom(HOST(`data-lang="de" data-text='{"title":"Diskussion"}'`), 'de-DE');
await flushAsync();
expect(uiText(dom)).toEqual({ title: 'Diskussion', submit: 'Kommentar senden' });
});
});

describe('widget dictionaries', () => {
// Extract `<lang>: { key: '…', … }` blocks from the widget source.
function dictKeys(lang: string): string[] {
const match = WIDGET_SRC.match(new RegExp(`\\n ${lang}: \\{([\\s\\S]*?)\\n \\}`));
expect(match, `dictionary "${lang}" not found`).not.toBeNull();
return [...match![1].matchAll(/^\s+(\w+):/gm)].map((m) => m[1]).sort();
}

it.each(['it', 'de'])('"%s" defines exactly the English keys', (lang) => {
expect(dictKeys(lang)).toEqual(dictKeys('en'));
});
});