From 7a23e8c06326adc5e3d677b52350a22546bc6857 Mon Sep 17 00:00:00 2001 From: Stig Dreyer <4902916+stigdreyer@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:13:52 +1200 Subject: [PATCH] feat(widget): German localization (data-lang="de") Add a full German dictionary for the widget (comments, reactions, polls) and generalize auto-detection to a SUPPORTED_LANGS list matched on the primary language subtag, so de-DE/de-AT/de-CH resolve to German. English remains the fallback; data-text overrides still take precedence. Also: 'Deutsch' option in the admin embed builder, lang type/docs in integrations, and widget-i18n tests incl. a key-parity check across locales. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 +++ integrations/README.md | 4 +- integrations/react/Comment.tsx | 2 +- packages/runtime/src/static-content.ts | 4 +- packages/runtime/static/admin.html | 1 + packages/widget/src/widget.js | 74 ++++++++++++++++++-- tests/security/widget-i18n.test.ts | 97 ++++++++++++++++++++++++++ 7 files changed, 182 insertions(+), 10 deletions(-) create mode 100644 tests/security/widget-i18n.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b3bd18..ecdb389 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/integrations/README.md b/integrations/README.md index 1f7c48d..0a6b89b 100644 --- a/integrations/README.md +++ b/integrations/README.md @@ -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) @@ -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; diff --git a/integrations/react/Comment.tsx b/integrations/react/Comment.tsx index 305d77d..3f874a7 100644 --- a/integrations/react/Comment.tsx +++ b/integrations/react/Comment.tsx @@ -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; diff --git a/packages/runtime/src/static-content.ts b/packages/runtime/src/static-content.ts index e35a649..3b8d2cd 100644 --- a/packages/runtime/src/static-content.ts +++ b/packages/runtime/src/static-content.ts @@ -1,10 +1,10 @@ // AUTO-GENERATED by scripts/sync-static.mjs — DO NOT EDIT BY HAND. // Regenerate with: npm run build -export const WIDGET_JS: string = "(() => {\n // src/widget.js\n (function() {\n \"use strict\";\n var WORKER_FILE = \"pow-worker.js\";\n var workerUrl = WORKER_FILE;\n var scriptSrc = typeof document !== \"undefined\" && document.currentScript && document.currentScript.src;\n if (scriptSrc) {\n try {\n workerUrl = new URL(WORKER_FILE, scriptSrc).href;\n } catch (e) {\n workerUrl = WORKER_FILE;\n }\n }\n var AVATAR_GRADIENTS = [\n \"linear-gradient(135deg,#ff9a5a,#f05a1c)\",\n \"linear-gradient(135deg,#a78bfa,#7c3aed)\",\n \"linear-gradient(135deg,#5eead4,#0d9488)\",\n \"linear-gradient(135deg,#7dd3fc,#0284c7)\",\n \"linear-gradient(135deg,#f9a8d4,#db2777)\",\n \"linear-gradient(135deg,#bef264,#65a30d)\",\n \"linear-gradient(135deg,#fda4af,#e11d48)\",\n \"linear-gradient(135deg,#fcd34d,#d97706)\"\n ];\n function hashString(str) {\n var h = 0;\n for (var i = 0; i < str.length; i += 1) {\n h = h * 31 + str.charCodeAt(i) | 0;\n }\n return Math.abs(h);\n }\n function el(tag, className, text) {\n var node = document.createElement(tag);\n if (className) node.className = className;\n if (text !== void 0) node.textContent = text;\n return node;\n }\n var I18N = {\n en: {\n title: \"Comments\",\n nickPlaceholder: \"Name (optional)\",\n bodyPlaceholder: \"Write a comment\\u2026\",\n post: \"Post comment\",\n hint: \"Plain text \\xB7 emoji welcome \\xB7 anti-spam proof-of-work\",\n empty: \"No comments yet \\u2014 be the first. \\u{1F4AC}\",\n anonymous: \"Anonymous\",\n emptyComment: \"Comment cannot be empty.\",\n solving: \"Solving proof-of-work\\u2026\",\n pending: \"Comment submitted \\u2014 awaiting moderation.\",\n posted: \"Comment posted.\",\n reactAria: \"React with \",\n reactionRecorded: \"\\u2713 Reaction recorded\",\n slowDown: \"Slow down \\u2014 too many reactions.\",\n challengeUsed: \"Challenge already used \\u2014 try again.\",\n countOne: \"1 comment\",\n countMany: \"{n} comments\",\n voted: \"\\u2713 You voted\",\n closed: \"This poll is closed\",\n pollMissing: \"Poll not found\",\n alreadyVoted: \"You already voted in this poll\",\n pollLoadError: \"Failed to load the poll\",\n pollVoteCountOne: \"1 vote\",\n pollVoteCountMany: \"{n} votes\",\n pollLeader: \"Leads by {n} votes\",\n pollVoteBtn: \"Vote ({n})\",\n pollMultiHint: \"Select one or more options\",\n pollChangeVotes: \"Change your votes\",\n pollVotesChanged: \"Votes updated \\u2014 you can vote again\",\n viewResults: \"View results\",\n backToVote: \"Vote\",\n resultsPreview: \"Live results\",\n startTitle: \"Start the conversation\",\n pinned: \"Pinned\",\n flag: \"Report\",\n flagBusy: \"Sending report\\u2026\",\n flagDone: \"Thanks \\u2014 report sent\",\n flagErr: \"Report failed\",\n sortNewest: \"Newest\",\n sortOldest: \"Oldest\",\n sortBest: \"Best\",\n timeNow: \"now\",\n timeMinAgo: \"{n} min ago\",\n timeHourAgo: \"{n} h ago\",\n timeDayAgo: \"{n} d ago\",\n timeWeekAgo: \"{n} w ago\",\n timeMonthAgo: \"{n} mo ago\",\n timeYearAgo: \"{n} y ago\",\n readMore: \"Read more\",\n showLess: \"Show less\",\n like: \"Like\",\n liked: \"Liked\",\n reply: \"Reply\",\n cancelReply: \"Cancel\",\n replyPlaceholder: \"Write a reply\\u2026\",\n showMoreReplies: \"Show more replies ({n})\",\n parentRemoved: \"Parent comment removed\",\n author: \"Author\"\n },\n it: {\n title: \"Commenti\",\n nickPlaceholder: \"Nome (facoltativo)\",\n bodyPlaceholder: \"Scrivi un commento\\u2026\",\n post: \"Pubblica commento\",\n hint: \"Testo semplice \\xB7 emoji benvenute \\xB7 anti-spam proof-of-work\",\n empty: \"Nessun commento ancora \\u2014 sii il primo. \\u{1F4AC}\",\n anonymous: \"Anonimo\",\n emptyComment: \"Il commento non pu\\xF2 essere vuoto.\",\n solving: \"Risoluzione proof-of-work\\u2026\",\n pending: \"Commento inviato \\u2014 in attesa di moderazione.\",\n posted: \"Commento pubblicato.\",\n reactAria: \"Reagisci con \",\n reactionRecorded: \"\\u2713 Reazione registrata\",\n slowDown: \"Rallenta \\u2014 troppe reazioni.\",\n challengeUsed: \"Challenge gi\\xE0 usata \\u2014 riprova.\",\n countOne: \"1 commento\",\n countMany: \"{n} commenti\",\n voted: \"\\u2713 Hai votato\",\n closed: \"Sondaggio chiuso\",\n pollMissing: \"Sondaggio non trovato\",\n alreadyVoted: \"Hai gi\\xE0 votato in questo sondaggio\",\n pollLoadError: \"Impossibile caricare il sondaggio\",\n pollVoteCountOne: \"1 voto\",\n pollVoteCountMany: \"{n} voti\",\n pollLeader: \"Conduce con {n} voti\",\n pollVoteBtn: \"Vota ({n})\",\n pollMultiHint: \"Seleziona una o pi\\xF9 opzioni\",\n pollChangeVotes: \"Modifica i tuoi voti\",\n pollVotesChanged: \"Voti aggiornati \\u2014 puoi votare di nuovo\",\n viewResults: \"Vedi risultati\",\n backToVote: \"Vota\",\n resultsPreview: \"Risultati in tempo reale\",\n startTitle: \"Inizia la conversazione\",\n pinned: \"In evidenza\",\n flag: \"Segnala\",\n flagBusy: \"Invio segnalazione\\u2026\",\n flagDone: \"Grazie \\u2014 segnalazione inviata\",\n flagErr: \"Segnalazione non riuscita\",\n sortNewest: \"Pi\\xF9 recenti\",\n sortOldest: \"Pi\\xF9 vecchi\",\n sortBest: \"Migliori\",\n timeNow: \"ora\",\n timeMinAgo: \"{n} min fa\",\n timeHourAgo: \"{n} h fa\",\n timeDayAgo: \"{n} g fa\",\n timeWeekAgo: \"{n} sett fa\",\n timeMonthAgo: \"{n} mesi fa\",\n timeYearAgo: \"{n} anni fa\",\n readMore: \"Leggi tutto\",\n showLess: \"Mostra meno\",\n like: \"Mi piace\",\n liked: \"Ti piace\",\n reply: \"Rispondi\",\n cancelReply: \"Annulla\",\n replyPlaceholder: \"Scrivi una risposta\\u2026\",\n showMoreReplies: \"Mostra altre risposte ({n})\",\n parentRemoved: \"Commento padre rimosso\",\n author: \"Autore\"\n }\n };\n var STYLES = '.sl-root{--accent:#f57d1f;--accent-2:#e85d0a;--bg:#ffffff;--card:#ffffff;--border:#e8e8e6;--text:#1a1a1a;--muted:#6f6f6f;--radius:14px;--shadow:0 1px 2px rgba(16,24,40,.04),0 6px 20px -6px rgba(16,24,40,.08);font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",Inter,Roboto,\"Helvetica Neue\",Arial,sans-serif;color:var(--text);max-width:640px;margin:0 auto;line-height:1.55;-webkit-font-smoothing:antialiased}.sl-root.sl-theme-dark{--bg:#161616;--card:#1f1f1f;--border:#2b2b2b;--text:#f4f4f4;--muted:#9d9d9d;--shadow:0 1px 2px rgba(0,0,0,.3),0 10px 30px -12px rgba(0,0,0,.5)}@media(prefers-color-scheme:dark){.sl-root:not(.sl-theme-light){--bg:#161616;--card:#1f1f1f;--border:#2b2b2b;--text:#f4f4f4;--muted:#9d9d9d;--shadow:0 1px 2px rgba(0,0,0,.3),0 10px 30px -12px rgba(0,0,0,.5)}}.sl-root *{box-sizing:border-box}.sl-heading{display:flex;align-items:center;justify-content:space-between;margin:0 0 18px}.sl-heading h3{margin:0;font-size:15px;font-weight:650;letter-spacing:-.1px}.sl-count{font-size:11px;font-weight:600;color:var(--muted);background:color-mix(in srgb,var(--border) 60%,transparent);padding:3px 10px;border-radius:999px}.sl-list{list-style:none;margin:0 0 20px;padding:0;display:flex;flex-direction:column;gap:14px}.sl-comment{display:flex;flex-direction:column;gap:14px;padding:16px 18px;background:var(--card);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow);transition:box-shadow .18s ease,transform .18s ease,border-color .18s ease}.sl-comment .sl-top{display:flex;gap:12px;min-width:0}.sl-comment:hover{box-shadow:0 2px 4px rgba(16,24,40,.05),0 12px 32px -10px rgba(16,24,40,.12);transform:translateY(-1px);border-color:color-mix(in srgb,var(--accent) 25%,var(--border))}.sl-avatar{flex:none;width:36px;height:36px;border-radius:10px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:650;font-size:14px;user-select:none;box-shadow:inset 0 -2px 6px rgba(0,0,0,.14)}.sl-main{min-width:0;flex:1}.sl-head{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;margin-bottom:3px}.sl-nick{font-weight:600;font-size:13.5px;letter-spacing:-.1px}.sl-time{font-size:11.5px;color:var(--muted);font-variant-numeric:tabular-nums}.sl-body{margin:0;font-size:14.5px;white-space:pre-wrap;overflow-wrap:break-word;word-break:break-word}.sl-empty{padding:28px 18px;text-align:center;color:var(--muted);font-size:14px;background:var(--card);border:1px dashed var(--border);border-radius:var(--radius)}.sl-hidden{display:none!important}.sl-start{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px 16px 16px;margin-bottom:20px;text-align:center}.sl-start .sl-form{margin:0;box-shadow:none;text-align:left}.sl-start-msg{margin:0 0 14px;font-size:15px;font-weight:650;letter-spacing:-.2px}.sl-start-sub{display:block;margin-top:3px;font-weight:400;font-size:13px;color:var(--muted)}.sl-start:not(.sl-start-empty) .sl-start-msg{display:none}.sl-form{display:flex;flex-direction:column;gap:10px;padding:16px;background:var(--card);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow)}.sl-nick-input,.sl-body-input{width:100%;padding:11px 14px;border:1px solid var(--border);border-radius:10px;background:var(--bg);color:var(--text);font:inherit;font-size:14px;outline:none;transition:border-color .15s ease,box-shadow .15s ease,background .15s ease}.sl-nick-input:focus,.sl-body-input:focus{border-color:var(--accent);background:var(--bg);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 14%,transparent)}.sl-nick-input::placeholder,.sl-body-input::placeholder{color:var(--muted)}.sl-body-input{resize:vertical;min-height:80px;line-height:1.5}.sl-form-row{display:flex;align-items:center;gap:12px;justify-content:space-between;margin-top:2px}.sl-hint{font-size:11.5px;color:var(--muted)}.sl-submit{appearance:none;border:0;cursor:pointer;padding:10px 18px;border-radius:999px;font:inherit;font-weight:600;font-size:13.5px;color:#fff;background:linear-gradient(135deg,var(--accent),var(--accent-2));box-shadow:0 6px 16px -6px color-mix(in srgb,var(--accent) 60%,transparent);transition:transform .12s ease,filter .12s ease,opacity .12s ease}.sl-submit:hover{transform:translateY(-1px);filter:brightness(1.05)}.sl-submit:disabled{opacity:.55;cursor:progress;transform:none;filter:none}.sl-status{display:flex;align-items:center;gap:8px;min-height:20px;margin:12px 2px 0;font-size:13px;color:var(--muted)}.sl-status[data-kind=\"ok\"]{color:#16a34a}.sl-status[data-kind=\"err\"]{color:#dc2626}.sl-status .sl-spinner{width:14px;height:14px;border:2px solid color-mix(in srgb,var(--muted) 30%,transparent);border-top-color:var(--accent);border-radius:50%;animation:sl-spin .7s linear infinite}.sl-reactions{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 16px}.sl-reaction{appearance:none;display:inline-flex;align-items:center;gap:7px;cursor:pointer;font:inherit;font-size:14px;padding:7px 13px;border-radius:999px;border:1px solid var(--border);background:var(--card);color:var(--text);transition:transform .12s ease,border-color .12s ease,box-shadow .12s ease}.sl-reaction:hover{transform:translateY(-1px);border-color:var(--accent);box-shadow:0 4px 14px -6px color-mix(in srgb,var(--accent) 45%,transparent)}.sl-reaction:disabled{opacity:.6;cursor:progress;transform:none}.sl-reaction .sl-reaction-count{font-size:11.5px;font-weight:700;color:var(--muted);font-variant-numeric:tabular-nums}.sl-reaction-status{min-height:16px;margin:0 2px 12px;font-size:12px;color:var(--muted)}.sl-reaction-status[data-kind=\"ok\"]{color:#16a34a}.sl-reaction-status[data-kind=\"err\"]{color:#dc2626}.sl-poll-heading{display:flex;align-items:center;justify-content:space-between;margin:0 0 14px}.sl-poll-heading h3{margin:0;font-size:15px;font-weight:650;letter-spacing:-.1px;line-height:1.4}.sl-poll-options{list-style:none;margin:0 0 12px;padding:0;display:flex;flex-direction:column;gap:10px}.sl-poll-btn{appearance:none;width:100%;text-align:left;cursor:pointer;font:inherit;font-size:14px;padding:12px 16px;border-radius:12px;border:1px solid var(--border);background:var(--card);color:var(--text);transition:transform .12s ease,border-color .12s ease,box-shadow .12s ease}.sl-poll-btn:hover{transform:translateY(-1px);border-color:var(--accent);box-shadow:0 4px 14px -6px color-mix(in srgb,var(--accent) 45%,transparent)}.sl-poll-btn:disabled{opacity:.55;cursor:progress;transform:none}.sl-poll-opt-label{display:flex;align-items:baseline;justify-content:space-between;gap:10px;margin-bottom:6px}.sl-poll-opt-name{font-size:14px;font-weight:600}.sl-poll-opt-meta{font-size:11.5px;color:var(--muted);font-variant-numeric:tabular-nums}.sl-poll-bar{height:8px;border-radius:999px;background:color-mix(in srgb,var(--border) 60%,transparent);overflow:hidden}.sl-poll-fill{height:100%;border-radius:999px;background:linear-gradient(135deg,var(--accent),var(--accent-2));transition:width .5s ease}.sl-poll-status{display:flex;align-items:center;gap:8px;min-height:20px;margin:10px 2px 0;font-size:13px;color:var(--muted)}.sl-poll-status[data-kind=\"ok\"]{color:#16a34a}.sl-poll-status[data-kind=\"err\"]{color:#dc2626}.sl-poll-count{font-size:11px;font-weight:600;color:var(--muted);background:color-mix(in srgb,var(--border) 60%,transparent);padding:3px 10px;border-radius:999px;white-space:nowrap;font-variant-numeric:tabular-nums}.sl-poll-rank{flex:none;font-size:10.5px;font-weight:700;color:var(--muted);background:color-mix(in srgb,var(--border) 55%,transparent);border-radius:999px;padding:1px 7px;letter-spacing:.2px;font-variant-numeric:tabular-nums}.sl-poll-opt-label{display:flex;align-items:baseline;gap:8px;margin-bottom:6px}.sl-poll-opt-name{font-size:14px;font-weight:600;flex:1;min-width:0}.sl-poll-opt-meta{font-size:11.5px;color:var(--muted);font-variant-numeric:tabular-nums;flex:none}.sl-poll-bar{height:8px;border-radius:999px;background:color-mix(in srgb,var(--border) 60%,transparent);overflow:hidden}.sl-poll-fill{height:100%;border-radius:999px;background:linear-gradient(135deg,var(--accent),var(--accent-2));transition:width .6s cubic-bezier(.22,1,.36,1)}.sl-poll-option.sl-poll-leader .sl-poll-fill{background:linear-gradient(135deg,var(--accent),var(--accent-2));box-shadow:0 0 14px -2px color-mix(in srgb,var(--accent) 65%,transparent)}.sl-poll-option.sl-poll-leader .sl-poll-opt-name{color:var(--accent)}.sl-poll-lead-info{margin:2px 0 0;font-size:12px;font-weight:600;color:var(--accent);animation:sl-pop .3s ease}.sl-poll-check{display:flex;align-items:center;gap:10px}.sl-poll-check::before{content:\"\";flex:none;width:16px;height:16px;border-radius:5px;border:1.5px solid var(--border);background:var(--bg);transition:background .12s ease,border-color .12s ease}.sl-poll-check.selected{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 8%,var(--card))}.sl-poll-check.selected::before{background:var(--accent);border-color:var(--accent);box-shadow:inset 0 0 0 3px var(--card)}.sl-poll-vote-btn{appearance:none;border:0;cursor:pointer;padding:9px 18px;border-radius:999px;font:inherit;font-weight:600;font-size:13px;color:#fff;background:linear-gradient(135deg,var(--accent),var(--accent-2));box-shadow:0 6px 16px -6px color-mix(in srgb,var(--accent) 60%,transparent);transition:transform .12s ease,filter .12s ease,opacity .12s ease;margin-top:2px}.sl-poll-vote-btn:hover{transform:translateY(-1px);filter:brightness(1.05)}.sl-poll-vote-btn:disabled{opacity:.55;cursor:not-allowed;transform:none;filter:none}.sl-poll-multi-hint{margin:10px 2px 0;font-size:11.5px;color:var(--muted)}.sl-poll-results-link{appearance:none;cursor:pointer;font:inherit;font-size:12px;font-weight:600;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:999px;padding:5px 14px;margin-top:12px;transition:color .12s ease,border-color .12s ease}.sl-poll-results-link:hover{color:var(--accent);border-color:var(--accent)}.sl-poll-actions{display:flex;flex-direction:column;align-items:flex-start;gap:2px}.sl-pop{animation:sl-pop .35s cubic-bezier(.22,1,.36,1)}@keyframes sl-pop{0%{transform:scale(.94)}60%{transform:scale(1.03)}100%{transform:scale(1)}}.sl-replies{list-style:none;margin:0;padding:4px 0 0 14px;display:flex;flex-direction:column;gap:10px;border-left:2px solid color-mix(in srgb,var(--border) 65%,transparent)}.sl-reply{box-shadow:none;padding:12px 14px;background:color-mix(in srgb,var(--card) 55%,transparent)}.sl-reply:hover{box-shadow:none;transform:none}.sl-reply .sl-avatar{width:28px;height:28px;font-size:12px;border-radius:8px}.sl-reply .sl-nick{font-size:12.5px}.sl-owner-badge{display:inline-block;margin-left:7px;font-size:10px;font-weight:700;letter-spacing:.3px;text-transform:uppercase;color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,transparent);border:1px solid color-mix(in srgb,var(--accent) 30%,transparent);padding:1px 7px;border-radius:999px;vertical-align:1px}.sl-reply-btn{appearance:none;border:0;background:transparent;color:var(--muted);font:inherit;font-size:12px;font-weight:600;cursor:pointer;padding:0;margin-top:6px;transition:color .15s}.sl-reply-btn:hover{color:var(--accent)}.sl-reply-form{margin-top:12px}.sl-parent-removed{color:var(--muted);font-style:italic}.sl-show-more{list-style:none}.sl-sort{appearance:none;font:inherit;font-size:11.5px;color:var(--muted);background:var(--card);border:1px solid var(--border);border-radius:999px;padding:3px 9px;cursor:pointer;margin-left:8px;transition:border-color .15s}.sl-sort:hover,.sl-sort:focus-visible{border-color:var(--accent);outline:none}.sl-pin-badge{display:inline-block;margin-left:7px;font-size:10px;font-weight:700;letter-spacing:.3px;color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,transparent);border:1px solid color-mix(in srgb,var(--accent) 30%,transparent);padding:1px 7px;border-radius:999px;vertical-align:1px}.sl-pinned{border-color:color-mix(in srgb,var(--accent) 35%,var(--border))}.sl-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px}.sl-vote-btn{appearance:none;cursor:pointer;font:inherit;font-size:12px;font-weight:600;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:999px;padding:3px 10px;transition:color .12s ease,border-color .12s ease,transform .12s ease}.sl-vote-btn:hover{color:var(--accent);border-color:var(--accent);transform:translateY(-1px)}.sl-vote-btn.liked{color:var(--accent);border-color:var(--accent);background:color-mix(in srgb,var(--accent) 10%,transparent)}.sl-vote-btn:disabled{opacity:.6;cursor:progress;transform:none}.sl-flag-btn{appearance:none;cursor:pointer;font:inherit;font-size:11.5px;color:var(--muted);background:transparent;border:0;padding:3px 6px;border-radius:8px}.sl-flag-btn:hover{color:#dc2626;background:color-mix(in srgb,#dc2626 8%,transparent)}.sl-flag-btn:disabled{opacity:.6;cursor:progress}.sl-flag-btn.done{color:#16a34a}.sl-more-btn{appearance:none;cursor:pointer;font:inherit;font-size:12px;font-weight:600;color:var(--accent);background:none;border:0;padding:0 0 0 4px}.sl-skeleton-card{pointer-events:none}.sl-sk{height:12px;border-radius:6px;background:linear-gradient(90deg,color-mix(in srgb,var(--border) 55%,transparent),color-mix(in srgb,var(--border) 22%,transparent),color-mix(in srgb,var(--border) 55%,transparent));background-size:200% 100%;animation:sl-shimmer 1.3s infinite linear}.sl-sk-nick{width:30%;height:10px;margin-bottom:10px}.sl-sk-line{width:100%;margin-bottom:8px}.sl-sk-line.short{width:70%}.sl-sk-bar{height:8px;border-radius:999px}@keyframes sl-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}@keyframes sl-spin{to{transform:rotate(360deg)}}.sl-root :focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:4px}@media (prefers-reduced-motion: reduce){.sl-root *{transition:none!important;animation:none!important}.sl-poll-fill{transition:none!important}}';\n function injectStyles() {\n if (document.getElementById(\"sl-styles\")) return;\n var style = document.createElement(\"style\");\n style.id = \"sl-styles\";\n style.textContent = STYLES;\n (document.head || document.documentElement).appendChild(style);\n }\n function mount(root, opts) {\n if (!root || root.__slMounted) return;\n var endpoint = (opts && opts.endpoint || root.getAttribute(\"data-endpoint\") || root.getAttribute(\"data-api\") || \"\").replace(/\\/+$/, \"\");\n var articlePath = opts && opts.articlePath || root.getAttribute(\"data-article-path\") || root.getAttribute(\"data-article-id\") || (typeof location !== \"undefined\" ? location.pathname : \"\");\n var hostContext = opts && opts.hostContext || root.getAttribute(\"data-host-context\") || (typeof location !== \"undefined\" ? location.hostname : \"\");\n if (!endpoint) return;\n root.__slMounted = true;\n root.classList.add(\"sl-root\");\n var lang = opts && opts.lang || root.getAttribute(\"data-lang\") || \"auto\";\n if (lang !== \"en\" && lang !== \"it\") {\n lang = typeof navigator !== \"undefined\" && navigator.language && navigator.language.toLowerCase().indexOf(\"it\") === 0 ? \"it\" : \"en\";\n }\n var dict = I18N[lang] || I18N.en;\n var customTexts = null;\n if (opts && opts.texts) {\n customTexts = opts.texts;\n } else {\n var rawTexts = root.getAttribute(\"data-text\");\n if (rawTexts) {\n try {\n customTexts = JSON.parse(rawTexts);\n } catch (e) {\n customTexts = null;\n }\n }\n }\n function t(key) {\n if (customTexts && Object.prototype.hasOwnProperty.call(customTexts, key) && typeof customTexts[key] === \"string\" && customTexts[key].length) return customTexts[key];\n if (dict && Object.prototype.hasOwnProperty.call(dict, key)) return dict[key];\n return key;\n }\n function countLabel(n) {\n return String(t(n === 1 ? \"countOne\" : \"countMany\")).replace(\"{n}\", String(n));\n }\n function pollCountLabel(n) {\n return String(t(n === 1 ? \"pollVoteCountOne\" : \"pollVoteCountMany\")).replace(\"{n}\", String(n));\n }\n function relativeTime(ts) {\n var diff = Math.floor(Date.now() / 1e3) - ts;\n if (diff < 60) return t(\"timeNow\");\n if (diff < 3600) return t(\"timeMinAgo\").replace(\"{n}\", String(Math.floor(diff / 60)));\n if (diff < 86400) return t(\"timeHourAgo\").replace(\"{n}\", String(Math.floor(diff / 3600)));\n if (diff < 604800) return t(\"timeDayAgo\").replace(\"{n}\", String(Math.floor(diff / 86400)));\n if (diff < 2592e3) return t(\"timeWeekAgo\").replace(\"{n}\", String(Math.floor(diff / 604800)));\n if (diff < 31536e3) return t(\"timeMonthAgo\").replace(\"{n}\", String(Math.floor(diff / 2592e3)));\n if (diff < 31536e4) return t(\"timeYearAgo\").replace(\"{n}\", String(Math.floor(diff / 31536e3)));\n return new Date(ts * 1e3).toLocaleDateString();\n }\n var theme = String(opts && opts.theme || root.getAttribute(\"data-theme\") || \"\").trim();\n if (theme === \"dark\") root.classList.add(\"sl-theme-dark\");\n else if (theme === \"light\") root.classList.add(\"sl-theme-light\");\n var accent = String(opts && opts.accent || root.getAttribute(\"data-accent\") || \"\").trim();\n if (accent) root.style.setProperty(\"--accent\", accent);\n var accent2 = String(opts && opts.accent2 || root.getAttribute(\"data-accent-2\") || \"\").trim();\n if (accent2) root.style.setProperty(\"--accent-2\", accent2);\n var radius = String(opts && opts.radius || root.getAttribute(\"data-radius\") || \"\").trim();\n if (radius) root.style.setProperty(\"--radius\", radius.replace(/[^0-9.]/g, \"\") + \"px\");\n var maxWidth = String(opts && opts.maxWidth || root.getAttribute(\"data-max-width\") || \"\").trim();\n if (maxWidth) root.style.setProperty(\"max-width\", maxWidth.replace(/[^0-9.]/g, \"\") + \"px\");\n var pollId = String(opts && opts.pollId || root.getAttribute(\"data-poll-id\") || \"\").trim();\n var pollStyle = String(opts && opts.pollStyle || root.getAttribute(\"data-poll-style\") || \"bars\").trim();\n var pollResults = String(opts && opts.pollResults || root.getAttribute(\"data-poll-results\") || \"after\").trim();\n var timeGateMs = 3e3;\n var rawGate = opts && opts.timeGateMs != null ? String(opts.timeGateMs) : root.getAttribute(\"data-time-gate-ms\");\n if (rawGate != null && /^\\d+$/.test(rawGate)) timeGateMs = parseInt(rawGate, 10);\n var reactionsPosition = String(opts && opts.reactionsPosition || root.getAttribute(\"data-reactions-position\") || \"bottom\").trim();\n if (reactionsPosition !== \"top\" && reactionsPosition !== \"bottom\") reactionsPosition = \"bottom\";\n var commentsSort = String(opts && opts.commentsSort || root.getAttribute(\"data-comments-sort\") || \"newest\").trim();\n if (commentsSort !== \"newest\" && commentsSort !== \"oldest\" && commentsSort !== \"best\") commentsSort = \"newest\";\n if (pollId) {\n initPoll();\n return;\n }\n var reactionsOnly = !!(opts && opts.reactionsOnly) || root.getAttribute(\"data-reactions-only\") !== null;\n var reactions = null;\n if (opts && Array.isArray(opts.reactions)) {\n reactions = opts.reactions.filter(function(s) {\n return typeof s === \"string\" && s.length;\n });\n } else {\n var rawAttr = root.getAttribute(\"data-reactions\");\n if (rawAttr) {\n reactions = rawAttr.split(\",\").map(function(s) {\n return s.trim();\n }).filter(Boolean);\n }\n }\n var heading = null, count = null, list = null, nickInput = null, bodyInput = null;\n var submitBtn = null, status = null, form = null;\n var reactBar = null, reactStatus = null;\n var reactBtns = {}, reactBusy = false;\n var sortSelect = null;\n var loadedOnce = false;\n var commentVoterKey = \"sl-comment-voter-token\";\n var commentVoterToken = null;\n try {\n commentVoterToken = localStorage.getItem(commentVoterKey);\n } catch (e) {\n commentVoterToken = null;\n }\n function buildReactions() {\n if (!reactBar || !reactions) return;\n reactions.forEach(function(r) {\n var btn = el(\"button\", \"sl-reaction\");\n btn.type = \"button\";\n btn.setAttribute(\"aria-label\", t(\"reactAria\") + r);\n var c = el(\"span\", \"sl-reaction-count\", \"0\");\n btn.append(el(\"span\", \"sl-reaction-emoji\", r), c);\n reactBtns[r] = { btn, count: c };\n btn.addEventListener(\"click\", function() {\n submitReaction(r);\n });\n reactBar.appendChild(btn);\n });\n }\n if (reactionsOnly) {\n reactStatus = el(\"p\", \"sl-reaction-status\");\n reactBar = el(\"div\", \"sl-reactions\");\n buildReactions();\n root.append(reactBar, reactStatus);\n } else {\n heading = el(\"div\", \"sl-heading\");\n heading.appendChild(el(\"h3\", null, t(\"title\")));\n count = el(\"span\", \"sl-count\", \"\\u2026\");\n heading.appendChild(count);\n sortSelect = el(\"select\", \"sl-sort\");\n sortSelect.setAttribute(\"aria-label\", t(\"title\") + \" \\u2014 sort\");\n [[\"newest\", t(\"sortNewest\")], [\"oldest\", t(\"sortOldest\")], [\"best\", t(\"sortBest\")]].forEach(function(opt) {\n var o = document.createElement(\"option\");\n o.value = opt[0];\n o.textContent = opt[1];\n sortSelect.appendChild(o);\n });\n sortSelect.value = commentsSort;\n sortSelect.style.display = \"none\";\n sortSelect.addEventListener(\"change\", function() {\n commentsSort = sortSelect.value;\n loadComments();\n });\n heading.appendChild(sortSelect);\n list = el(\"ul\", \"sl-list\");\n nickInput = el(\"input\");\n nickInput.type = \"text\";\n nickInput.maxLength = 50;\n nickInput.placeholder = t(\"nickPlaceholder\");\n nickInput.className = \"sl-nick-input\";\n bodyInput = el(\"textarea\");\n bodyInput.maxLength = 3e3;\n bodyInput.rows = 3;\n bodyInput.placeholder = t(\"bodyPlaceholder\");\n bodyInput.className = \"sl-body-input\";\n submitBtn = el(\"button\", \"sl-submit\", t(\"post\"));\n submitBtn.type = \"submit\";\n var hint = el(\"span\", \"sl-hint\", t(\"hint\"));\n status = el(\"p\", \"sl-status\");\n form = el(\"form\", \"sl-form\");\n var row = el(\"div\", \"sl-form-row\");\n row.append(hint, submitBtn);\n var hpInput = makeHoneypot(\"website\");\n form.append(nickInput, bodyInput, hpInput, row);\n var startCard = el(\"div\", \"sl-start sl-start-empty\");\n var startMsg = el(\"p\", \"sl-start-msg\", t(\"startTitle\"));\n startMsg.appendChild(el(\"span\", \"sl-start-sub\", t(\"empty\")));\n startCard.append(startMsg, form, status);\n root.append(heading);\n if (reactions && reactions.length) {\n reactStatus = el(\"p\", \"sl-reaction-status\");\n reactBar = el(\"div\", \"sl-reactions\");\n buildReactions();\n if (reactionsPosition === \"top\") root.append(reactBar, reactStatus);\n }\n root.append(list, startCard);\n if (reactions && reactions.length && reactionsPosition !== \"top\") {\n root.append(reactBar, reactStatus);\n }\n }\n function setStatus(message, kind) {\n status.replaceChildren();\n status.removeAttribute(\"data-kind\");\n if (kind === \"ok\") {\n status.setAttribute(\"data-kind\", \"ok\");\n status.appendChild(el(\"span\", null, \"\\u2713\"));\n }\n if (kind === \"err\") {\n status.setAttribute(\"data-kind\", \"err\");\n status.appendChild(el(\"span\", null, \"\\u2715\"));\n }\n if (kind === \"busy\") {\n status.appendChild(el(\"span\", \"sl-spinner\"));\n }\n status.appendChild(document.createTextNode(message));\n }\n var MAX_DEPTH = 3;\n var REPLIES_PER_PAGE = 5;\n function buildTree(comments) {\n var byId = {};\n var roots = [];\n comments.forEach(function(c) {\n c.children = [];\n c.parentMissing = false;\n byId[c.id] = c;\n });\n comments.forEach(function(c) {\n if (c.parent_id && byId[c.parent_id]) {\n byId[c.parent_id].children.push(c);\n } else {\n if (c.parent_id) c.parentMissing = true;\n roots.push(c);\n }\n });\n return roots;\n }\n var READ_MORE_CHARS = 400;\n function skeletonItem() {\n var li = el(\"li\", \"sl-comment sl-skeleton-card\");\n var nick = el(\"div\", \"sl-sk sl-sk-nick\");\n var line1 = el(\"div\", \"sl-sk sl-sk-line\");\n var line2 = el(\"div\", \"sl-sk sl-sk-line short\");\n li.append(nick, line1, line2);\n return li;\n }\n function makeBody(text) {\n var p = el(\"p\", \"sl-body\");\n if (text.length <= READ_MORE_CHARS) {\n p.textContent = text;\n return p;\n }\n var short = text.slice(0, READ_MORE_CHARS).replace(/\\s+\\S*$/, \"\") + \"\\u2026\";\n var shown = el(\"span\", null, short);\n var full = el(\"span\", null, text);\n full.style.display = \"none\";\n var btn = el(\"button\", \"sl-more-btn\", t(\"readMore\"));\n btn.type = \"button\";\n btn.addEventListener(\"click\", function() {\n var isCollapsed = full.style.display === \"none\";\n shown.style.display = isCollapsed ? \"none\" : \"inline\";\n full.style.display = isCollapsed ? \"inline\" : \"none\";\n btn.textContent = isCollapsed ? t(\"showLess\") : t(\"readMore\");\n });\n p.append(shown, full, btn);\n return p;\n }\n function renderComment(c, depth) {\n var li = el(\"li\", \"sl-comment\" + (depth > 0 ? \" sl-reply\" : \"\") + (c.pinned && depth === 0 ? \" sl-pinned\" : \"\"));\n var nick = c.nickname && c.nickname.trim() || t(\"anonymous\");\n var avatar = el(\"div\", \"sl-avatar\", nick.charAt(0).toUpperCase());\n avatar.style.background = AVATAR_GRADIENTS[hashString(nick) % AVATAR_GRADIENTS.length];\n var main = el(\"div\", \"sl-main\");\n var head = el(\"div\", \"sl-head\");\n var nickWrap = el(\"span\", \"sl-nick\", nick);\n if (c.is_owner) nickWrap.appendChild(el(\"span\", \"sl-owner-badge\", t(\"author\")));\n if (c.pinned && depth === 0) nickWrap.appendChild(el(\"span\", \"sl-pin-badge\", \"\\u{1F4CC} \" + t(\"pinned\")));\n head.append(nickWrap, el(\"span\", \"sl-time\", relativeTime(c.created_at)));\n var bodyEl = c.parentMissing ? el(\"p\", \"sl-body sl-parent-removed\", t(\"parentRemoved\")) : makeBody(c.body);\n main.append(head, bodyEl);\n var actions = el(\"div\", \"sl-actions\");\n var votes = Number(c.votes) || 0;\n var voteBtn = el(\"button\", \"sl-vote-btn\" + (c.voted ? \" liked\" : \"\"), (c.voted ? \"\\u2713 \" : \"\\u25B2 \") + votes);\n voteBtn.type = \"button\";\n voteBtn.setAttribute(\"aria-pressed\", c.voted ? \"true\" : \"false\");\n voteBtn.title = c.voted ? t(\"liked\") : t(\"like\");\n voteBtn.addEventListener(\"click\", function() {\n voteComment(c, voteBtn);\n });\n actions.appendChild(voteBtn);\n if (depth < MAX_DEPTH) {\n var replyBtn = el(\"button\", \"sl-reply-btn\", t(\"reply\"));\n replyBtn.type = \"button\";\n replyBtn.addEventListener(\"click\", function() {\n toggleReplyForm(c, main, replyBtn);\n });\n actions.appendChild(replyBtn);\n }\n var flagBtn = el(\"button\", \"sl-flag-btn\", t(\"flag\"));\n flagBtn.type = \"button\";\n flagBtn.addEventListener(\"click\", function() {\n flagComment(c, flagBtn);\n });\n actions.appendChild(flagBtn);\n main.appendChild(actions);\n var top = el(\"div\", \"sl-top\");\n top.append(avatar, main);\n li.append(top);\n if (c.children && c.children.length) {\n var ul = el(\"ul\", \"sl-replies\");\n var visible = c.children.slice(0, REPLIES_PER_PAGE);\n visible.forEach(function(ch) {\n ul.appendChild(renderComment(ch, depth + 1));\n });\n var hidden = c.children.slice(REPLIES_PER_PAGE);\n if (hidden.length) {\n var more = el(\"li\", \"sl-show-more\");\n var moreBtn = el(\"button\", \"sl-reply-btn\", t(\"showMoreReplies\").replace(\"{n}\", String(hidden.length)));\n moreBtn.type = \"button\";\n moreBtn.addEventListener(\"click\", function() {\n hidden.forEach(function(ch) {\n ul.insertBefore(renderComment(ch, depth + 1), more);\n });\n more.remove();\n });\n more.appendChild(moreBtn);\n ul.appendChild(more);\n }\n li.appendChild(ul);\n }\n return li;\n }\n function commentActionChallenge() {\n return fetch(\n endpoint + \"/api/comments/challenge?hostContext=\" + encodeURIComponent(hostContext) + \"&articlePath=\" + encodeURIComponent(articlePath)\n ).then(function(res) {\n if (!res.ok) throw new Error(\"challenge request failed\");\n return res.json();\n });\n }\n function voteComment(c, btn) {\n if (btn.disabled || c.voted) return;\n btn.disabled = true;\n commentActionChallenge().then(function(challenge) {\n return solveWithWorker(challenge, \"comment-action\", \"vote\", c.id).then(function(nonce) {\n return { challenge, nonce };\n });\n }).then(function(solved) {\n var payload = {\n challengeId: solved.challenge.challengeId,\n hostContext: solved.challenge.hostContext,\n articlePath: solved.challenge.articlePath,\n commentId: c.id,\n difficulty: solved.challenge.difficulty,\n expiresAt: solved.challenge.expiresAt,\n signature: solved.challenge.signature,\n nonce: solved.nonce\n };\n if (commentVoterToken) payload.voterToken = commentVoterToken;\n return fetch(endpoint + \"/api/comments/vote\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify(payload)\n });\n }).then(function(res) {\n return res.json().then(function(d) {\n return { ok: res.ok, status: res.status, data: d };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n if (outcome.data.voterToken) {\n commentVoterToken = outcome.data.voterToken;\n try {\n localStorage.setItem(commentVoterKey, commentVoterToken);\n } catch (e) {\n }\n }\n c.voted = true;\n c.votes = Number(outcome.data.votes) || Number(c.votes) + 1;\n btn.classList.add(\"liked\");\n btn.setAttribute(\"aria-pressed\", \"true\");\n btn.textContent = \"\\u2713 \" + c.votes;\n btn.title = t(\"liked\");\n } else if (outcome.status === 409) {\n c.voted = true;\n btn.classList.add(\"liked\");\n btn.setAttribute(\"aria-pressed\", \"true\");\n btn.textContent = \"\\u2713 \" + (Number(c.votes) || 0);\n }\n }).catch(function() {\n }).finally(function() {\n btn.disabled = false;\n });\n }\n function flagComment(c, btn) {\n if (btn.disabled) return;\n btn.disabled = true;\n btn.textContent = t(\"flagBusy\");\n commentActionChallenge().then(function(challenge) {\n return solveWithWorker(challenge, \"comment-action\", \"flag\", c.id).then(function(nonce) {\n return { challenge, nonce };\n });\n }).then(function(solved) {\n return fetch(endpoint + \"/api/comments/flag\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({\n challengeId: solved.challenge.challengeId,\n hostContext: solved.challenge.hostContext,\n articlePath: solved.challenge.articlePath,\n commentId: c.id,\n difficulty: solved.challenge.difficulty,\n expiresAt: solved.challenge.expiresAt,\n signature: solved.challenge.signature,\n nonce: solved.nonce\n })\n });\n }).then(function(res) {\n return res.json().then(function(d) {\n return { ok: res.ok, data: d };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n btn.textContent = t(\"flagDone\");\n btn.classList.add(\"done\");\n } else {\n btn.textContent = t(\"flag\");\n btn.disabled = false;\n }\n }).catch(function() {\n btn.textContent = t(\"flag\");\n btn.disabled = false;\n });\n }\n function toggleReplyForm(c, main, btn) {\n var existing = main.querySelector(\".sl-reply-form\");\n if (existing) {\n existing.remove();\n btn.textContent = t(\"reply\");\n return;\n }\n var form2 = el(\"form\", \"sl-form sl-reply-form\");\n var nickInput2 = el(\"input\");\n nickInput2.type = \"text\";\n nickInput2.maxLength = 50;\n nickInput2.placeholder = t(\"nickPlaceholder\");\n nickInput2.className = \"sl-nick-input\";\n var bodyInput2 = el(\"textarea\");\n bodyInput2.maxLength = 3e3;\n bodyInput2.rows = 2;\n bodyInput2.placeholder = t(\"replyPlaceholder\");\n bodyInput2.className = \"sl-body-input\";\n var row2 = el(\"div\", \"sl-form-row\");\n var submit = el(\"button\", \"sl-submit\", t(\"post\"));\n submit.type = \"submit\";\n var cancel = el(\"button\", \"sl-reply-btn\", t(\"cancelReply\"));\n cancel.type = \"button\";\n cancel.addEventListener(\"click\", function() {\n form2.remove();\n btn.textContent = t(\"reply\");\n });\n row2.append(cancel, submit);\n var hpInput2 = makeHoneypot(\"website\");\n form2.append(nickInput2, bodyInput2, hpInput2, row2);\n var statusEl = el(\"p\", \"sl-status\");\n form2.appendChild(statusEl);\n var busy = false;\n form2.addEventListener(\"submit\", function(event) {\n event.preventDefault();\n if (busy) return;\n var nickname = nickInput2.value.trim();\n var body = bodyInput2.value.trim();\n if (!body) {\n statusEl.textContent = t(\"emptyComment\");\n return;\n }\n busy = true;\n submit.disabled = true;\n statusEl.textContent = t(\"solving\");\n fetch(\n endpoint + \"/api/comments/challenge?hostContext=\" + encodeURIComponent(hostContext) + \"&articlePath=\" + encodeURIComponent(articlePath)\n ).then(function(res) {\n if (!res.ok) throw new Error(\"challenge request failed\");\n return res.json();\n }).then(function(challenge) {\n return solveWithWorker(challenge, \"comment\", nickname, body).then(function(nonce) {\n return { challenge, nonce };\n });\n }).then(function(solved) {\n return fetch(endpoint + \"/api/comments\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({\n challengeId: solved.challenge.challengeId,\n hostContext: solved.challenge.hostContext,\n articlePath: solved.challenge.articlePath,\n nickname,\n body,\n parentId: c.id,\n difficulty: solved.challenge.difficulty,\n expiresAt: solved.challenge.expiresAt,\n signature: solved.challenge.signature,\n nonce: solved.nonce\n })\n });\n }).then(function(res) {\n return res.json().then(function(data) {\n return { ok: res.ok, data };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n statusEl.textContent = outcome.data.comment && outcome.data.comment.status === \"pending\" ? t(\"pending\") : t(\"posted\");\n bodyInput2.value = \"\";\n return loadComments();\n }\n statusEl.textContent = \"Error: \" + (outcome.data && outcome.data.error ? outcome.data.error : \"unknown\");\n }).catch(function(err) {\n statusEl.textContent = \"Error: \" + err.message;\n }).finally(function() {\n busy = false;\n submit.disabled = false;\n });\n });\n main.appendChild(form2);\n btn.textContent = t(\"cancelReply\");\n bodyInput2.focus();\n }\n function loadComments() {\n if (!loadedOnce) {\n loadedOnce = true;\n list.classList.remove(\"sl-hidden\");\n startCard.classList.add(\"sl-hidden\");\n var frag = document.createDocumentFragment();\n for (var i = 0; i < 3; i += 1) frag.appendChild(skeletonItem());\n list.appendChild(frag);\n }\n var suffix = commentVoterToken ? \"&voterToken=\" + encodeURIComponent(commentVoterToken) : \"\";\n return fetch(\n endpoint + \"/api/comments?article_path=\" + encodeURIComponent(articlePath) + \"&host_context=\" + encodeURIComponent(hostContext) + suffix\n ).then(function(res) {\n if (!res.ok) throw new Error(\"failed to load comments\");\n return res.json();\n }).then(function(data) {\n var comments = Array.isArray(data.comments) ? data.comments : [];\n count.textContent = countLabel(comments.length);\n list.replaceChildren();\n if (comments.length === 0) {\n list.classList.add(\"sl-hidden\");\n startCard.classList.remove(\"sl-hidden\");\n startCard.classList.add(\"sl-start-empty\");\n if (sortSelect) sortSelect.style.display = \"none\";\n return;\n }\n list.classList.remove(\"sl-hidden\");\n startCard.classList.remove(\"sl-hidden\");\n startCard.classList.remove(\"sl-start-empty\");\n if (sortSelect) sortSelect.style.display = comments.length > 1 ? \"\" : \"none\";\n var roots = buildTree(comments);\n var pinnedRoots = roots.filter(function(r) {\n return r.pinned;\n });\n var restRoots = roots.filter(function(r) {\n return !r.pinned;\n });\n var dir = commentsSort === \"oldest\" ? 1 : -1;\n restRoots.sort(function(a, b) {\n if (commentsSort === \"best\") {\n return (Number(b.votes) || 0) - (Number(a.votes) || 0) || a.created_at - b.created_at;\n }\n return (a.created_at - b.created_at) * dir;\n });\n pinnedRoots.concat(restRoots).forEach(function(r) {\n list.appendChild(renderComment(r, 0));\n });\n });\n }\n function createPowWorkerUrl() {\n return fetch(workerUrl, { mode: \"cors\", credentials: \"omit\" }).then(function(res) {\n if (!res.ok) throw new Error(\"failed to load pow worker\");\n return res.blob();\n }).then(function(blob) {\n return URL.createObjectURL(blob);\n });\n }\n function makeHoneypot(name) {\n var hp = el(\"input\");\n hp.type = \"text\";\n hp.name = name || \"website\";\n hp.tabIndex = -1;\n hp.autocomplete = \"off\";\n hp.setAttribute(\"aria-hidden\", \"true\");\n hp.className = \"sl-hp\";\n hp.style.cssText = \"position:absolute!important;left:-9999px!important;width:1px!important;height:1px!important;opacity:0!important;overflow:hidden!important;\";\n return hp;\n }\n function waitForGate(startMs) {\n if (timeGateMs <= 0) return Promise.resolve();\n var wait = Math.max(0, timeGateMs + 500 - (Date.now() - startMs));\n if (wait <= 0) return Promise.resolve();\n return new Promise(function(resolveGate) {\n setTimeout(resolveGate, wait);\n });\n }\n function solveWithWorker(challenge, kind, a, b) {\n return new Promise(function(resolve, reject) {\n createPowWorkerUrl().then(function(url) {\n var worker;\n try {\n worker = new Worker(url);\n } catch (err) {\n reject(err);\n return null;\n }\n worker.onmessage = function(e) {\n URL.revokeObjectURL(url);\n worker.terminate();\n if (e.data && e.data.type === \"nonce\") resolve(e.data.nonce);\n else reject(new Error(e.data && e.data.message || \"pow worker failed\"));\n };\n worker.onerror = function() {\n URL.revokeObjectURL(url);\n worker.terminate();\n reject(new Error(\"pow worker error\"));\n };\n var msg = { challenge };\n if (kind === \"poll\") {\n msg.pollId = a;\n if (Array.isArray(b)) msg.options = b;\n else msg.option = b;\n } else if (kind === \"comment-action\") {\n msg.action = a;\n msg.commentId = b;\n } else {\n msg.nickname = a;\n msg.body = b;\n }\n worker.postMessage(msg);\n return null;\n }).catch(reject);\n });\n }\n function submitComment(event) {\n event.preventDefault();\n var nickname = nickInput.value.trim();\n var body = bodyInput.value.trim();\n if (!body) {\n setStatus(t(\"emptyComment\"), \"err\");\n return;\n }\n submitBtn.disabled = true;\n setStatus(t(\"solving\"), \"busy\");\n var t0 = Date.now();\n fetch(\n endpoint + \"/api/comments/challenge?hostContext=\" + encodeURIComponent(hostContext) + \"&articlePath=\" + encodeURIComponent(articlePath)\n ).then(function(res) {\n if (!res.ok) throw new Error(\"challenge request failed\");\n return res.json();\n }).then(function(challenge) {\n return solveWithWorker(challenge, \"comment\", nickname, body).then(function(nonce) {\n return { challenge, nonce };\n });\n }).then(function(solved) {\n return waitForGate(t0).then(function() {\n return fetch(endpoint + \"/api/comments\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({\n challengeId: solved.challenge.challengeId,\n hostContext: solved.challenge.hostContext,\n articlePath: solved.challenge.articlePath,\n nickname,\n body,\n difficulty: solved.challenge.difficulty,\n expiresAt: solved.challenge.expiresAt,\n signature: solved.challenge.signature,\n nonce: solved.nonce,\n honeypot: hpInput.value\n })\n });\n });\n }).then(function(res) {\n return res.json().then(function(data) {\n return { ok: res.ok, data };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n bodyInput.value = \"\";\n setStatus(\n outcome.data.comment && outcome.data.comment.status === \"pending\" ? t(\"pending\") : t(\"posted\"),\n \"ok\"\n );\n return loadComments();\n }\n setStatus(\"Error: \" + (outcome.data && outcome.data.error ? outcome.data.error : \"unknown\"), \"err\");\n }).catch(function(err) {\n setStatus(\"Error: \" + err.message, \"err\");\n }).finally(function() {\n submitBtn.disabled = false;\n });\n }\n if (form) form.addEventListener(\"submit\", submitComment);\n if (!reactionsOnly) loadComments();\n loadReactions();\n function setReactStatus(message, kind) {\n if (!reactStatus) return;\n reactStatus.textContent = message;\n if (kind) reactStatus.setAttribute(\"data-kind\", kind);\n else reactStatus.removeAttribute(\"data-kind\");\n }\n function setReactBusy(busy) {\n reactBusy = busy;\n reactions.forEach(function(x) {\n if (reactBtns[x]) reactBtns[x].btn.disabled = busy;\n });\n }\n function renderReactionCounts(counts) {\n var map = {};\n counts.forEach(function(r) {\n map[r.reaction] = Number(r.count) || 0;\n });\n reactions.forEach(function(x) {\n if (reactBtns[x]) reactBtns[x].count.textContent = String(map[x] || 0);\n });\n }\n function loadReactions() {\n if (!reactBar) return;\n fetch(endpoint + \"/api/reactions?article_path=\" + encodeURIComponent(articlePath)).then(function(res) {\n if (!res.ok) throw new Error(\"failed to load reactions\");\n return res.json();\n }).then(function(data) {\n if (Array.isArray(data.reactions)) renderReactionCounts(data.reactions);\n }).catch(function() {\n });\n }\n function submitReaction(r) {\n if (reactBusy || !reactBtns[r]) return;\n setReactBusy(true);\n setReactStatus(t(\"solving\"));\n var t0 = Date.now();\n fetch(\n endpoint + \"/api/reactions/challenge?hostContext=\" + encodeURIComponent(hostContext) + \"&articlePath=\" + encodeURIComponent(articlePath)\n ).then(function(res) {\n if (!res.ok) throw new Error(\"challenge request failed\");\n return res.json();\n }).then(function(challenge) {\n return solveWithWorker(challenge, \"comment\", \"\", \"\").then(function(nonce) {\n return { challenge, nonce };\n });\n }).then(function(solved) {\n return waitForGate(t0).then(function() {\n return fetch(endpoint + \"/api/reactions\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({\n challengeId: solved.challenge.challengeId,\n hostContext: solved.challenge.hostContext,\n articlePath: solved.challenge.articlePath,\n reaction: r,\n difficulty: solved.challenge.difficulty,\n expiresAt: solved.challenge.expiresAt,\n signature: solved.challenge.signature,\n nonce: solved.nonce\n })\n });\n });\n }).then(function(res) {\n return res.json().then(function(data) {\n return { ok: res.ok, status: res.status, data };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n setReactStatus(t(\"reactionRecorded\"), \"ok\");\n if (Array.isArray(outcome.data.reactions)) renderReactionCounts(outcome.data.reactions);\n } else if (outcome.status === 429) {\n setReactStatus(t(\"slowDown\"), \"err\");\n } else if (outcome.status === 409) {\n setReactStatus(t(\"challengeUsed\"), \"err\");\n } else {\n setReactStatus(\n \"Error: \" + (outcome.data && outcome.data.error ? outcome.data.error : \"unknown\"),\n \"err\"\n );\n }\n }).catch(function(err) {\n setReactStatus(\"Error: \" + err.message, \"err\");\n }).finally(function() {\n setReactBusy(false);\n });\n }\n function initPoll() {\n var voterKey = \"sl-voter-token\";\n var voterToken = null;\n try {\n voterToken = localStorage.getItem(voterKey);\n } catch (e) {\n voterToken = null;\n }\n var heading2 = el(\"div\", \"sl-poll-heading\");\n var qEl = el(\"h3\", null, \"\\u2026\");\n var totalChip = el(\"span\", \"sl-poll-count\", \"\");\n heading2.append(qEl, totalChip);\n var statusEl = el(\"p\", \"sl-poll-status\");\n var listEl = el(\"ul\", \"sl-poll-options\");\n var actionsEl = el(\"div\", \"sl-poll-actions\");\n root.append(heading2, listEl, actionsEl, statusEl);\n renderPollSkeleton();\n var selected = {};\n var multiVoteBtn = null;\n var revealed = false;\n var currentPoll = null;\n function renderPollSkeleton() {\n listEl.replaceChildren();\n for (var i = 0; i < 3; i += 1) {\n var li = el(\"li\", \"sl-poll-option\");\n li.appendChild(el(\"div\", \"sl-sk sl-sk-bar\"));\n listEl.appendChild(li);\n }\n }\n function setStatus2(message, kind) {\n statusEl.replaceChildren();\n statusEl.removeAttribute(\"data-kind\");\n if (kind === \"ok\") {\n statusEl.setAttribute(\"data-kind\", \"ok\");\n statusEl.appendChild(el(\"span\", null, \"\\u2713\"));\n } else if (kind === \"err\") {\n statusEl.setAttribute(\"data-kind\", \"err\");\n statusEl.appendChild(el(\"span\", null, \"\\u2715\"));\n } else if (kind === \"busy\") {\n statusEl.appendChild(el(\"span\", \"sl-spinner\"));\n }\n statusEl.appendChild(document.createTextNode(message));\n }\n function animateValue(node, to, suffix) {\n var dur = 520, start = null;\n function step(ts) {\n if (!start) start = ts;\n var p = Math.min(1, (ts - start) / dur);\n var eased = p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2;\n node.textContent = Math.round(to * eased) + (suffix || \"\");\n if (p < 1) requestAnimationFrame(step);\n }\n requestAnimationFrame(step);\n }\n function renderPoll(poll) {\n currentPoll = poll;\n qEl.textContent = poll.question || \"\";\n totalChip.textContent = pollCountLabel(Number(poll.total) || 0);\n var opts2 = Array.isArray(poll.options) ? poll.options : [];\n var total = Number(poll.total) || 0;\n var showResults = poll.status !== \"open\" || !!poll.voted || pollResults === \"always\" || revealed;\n listEl.replaceChildren();\n actionsEl.replaceChildren();\n multiVoteBtn = null;\n var rows = opts2.map(function(o) {\n return { option: o, count: poll.counts && poll.counts[o] ? Number(poll.counts[o]) : 0 };\n });\n if (showResults) {\n rows = rows.slice().sort(function(a, b) {\n return b.count - a.count || opts2.indexOf(a.option) - opts2.indexOf(b.option);\n });\n }\n var leader = showResults && rows.length > 0 && rows[0].count > (rows[1] ? rows[1].count : 0);\n rows.forEach(function(r, i) {\n var o = r.option, c = r.count;\n var pct = total > 0 ? Math.round(c / total * 100) : 0;\n var li = el(\"li\", \"sl-poll-option\" + (showResults && leader && i === 0 ? \" sl-poll-leader\" : \"\"));\n if (showResults) {\n if (pollStyle !== \"counts\") {\n var label = el(\"div\", \"sl-poll-opt-label\");\n var meta = el(\"span\", \"sl-poll-opt-meta\", \"\");\n if (pollStyle === \"percent\") animateValue(meta, pct, \"%\");\n else if (pollStyle === \"minimal\") meta.textContent = \"\";\n else animateValue(meta, c, \" \\xB7 \" + pct + \"%\");\n label.append(el(\"span\", \"sl-poll-rank\", \"#\" + (i + 1)), el(\"span\", \"sl-poll-opt-name\", o), meta);\n var bar = el(\"div\", \"sl-poll-bar\");\n var fill = el(\"div\", \"sl-poll-fill\");\n fill.style.width = \"0%\";\n fill.style.transitionDelay = i * 90 + \"ms\";\n bar.appendChild(fill);\n li.append(label, bar);\n requestAnimationFrame(function() {\n requestAnimationFrame(function() {\n fill.style.width = pct + \"%\";\n });\n });\n } else {\n var cLabel = el(\"div\", \"sl-poll-opt-label\");\n var cMeta = el(\"span\", \"sl-poll-opt-meta\", \"\");\n animateValue(cMeta, c, \"\");\n cLabel.append(el(\"span\", \"sl-poll-rank\", \"#\" + (i + 1)), el(\"span\", \"sl-poll-opt-name\", o), cMeta);\n li.appendChild(cLabel);\n }\n } else {\n var btn = el(\"button\", \"sl-poll-btn\" + (poll.multi ? \" sl-poll-check\" : \"\"), o);\n btn.type = \"button\";\n if (poll.multi) {\n btn.setAttribute(\"aria-pressed\", \"false\");\n btn.addEventListener(\"click\", function() {\n toggleSelect(o, btn);\n });\n } else {\n btn.addEventListener(\"click\", function() {\n vote([o], btn, false);\n });\n }\n li.appendChild(btn);\n }\n listEl.appendChild(li);\n });\n if (showResults) {\n if (leader) {\n var gap = rows[0].count - (rows[1] ? rows[1].count : 0);\n actionsEl.appendChild(el(\"p\", \"sl-poll-lead-info\", t(\"pollLeader\").replace(\"{n}\", String(gap))));\n }\n if (poll.multi && poll.singleVote && poll.status === \"open\" && poll.voted) {\n var changeBtn = el(\"button\", \"sl-poll-vote-btn\", t(\"pollChangeVotes\"));\n changeBtn.type = \"button\";\n changeBtn.addEventListener(\"click\", changeVotes);\n actionsEl.appendChild(changeBtn);\n }\n if (revealed && !poll.voted && poll.status === \"open\") {\n setStatus2(t(\"resultsPreview\"), void 0);\n var backBtn = el(\"button\", \"sl-poll-results-link\", t(\"backToVote\"));\n backBtn.type = \"button\";\n backBtn.addEventListener(\"click\", function() {\n revealed = false;\n renderPoll(currentPoll);\n });\n actionsEl.appendChild(backBtn);\n } else {\n setStatus2(poll.status !== \"open\" ? t(\"closed\") : t(\"voted\"), poll.status !== \"open\" ? void 0 : \"ok\");\n }\n } else {\n if (poll.multi) {\n multiVoteBtn = el(\"button\", \"sl-poll-vote-btn\", t(\"pollVoteBtn\").replace(\"{n}\", \"0\"));\n multiVoteBtn.type = \"button\";\n multiVoteBtn.disabled = true;\n multiVoteBtn.addEventListener(\"click\", function() {\n submitMulti();\n });\n actionsEl.appendChild(multiVoteBtn);\n actionsEl.appendChild(el(\"p\", \"sl-poll-multi-hint\", t(\"pollMultiHint\")));\n }\n var resultsLink = el(\"button\", \"sl-poll-results-link\", t(\"viewResults\"));\n resultsLink.type = \"button\";\n resultsLink.addEventListener(\"click\", function() {\n revealed = true;\n renderPoll(currentPoll);\n });\n actionsEl.appendChild(resultsLink);\n setStatus2(\"\");\n }\n }\n function toggleSelect(o, btn) {\n if (selected[o]) {\n delete selected[o];\n btn.classList.remove(\"selected\");\n btn.setAttribute(\"aria-pressed\", \"false\");\n } else {\n selected[o] = true;\n btn.classList.add(\"selected\");\n btn.setAttribute(\"aria-pressed\", \"true\");\n }\n var n = Object.keys(selected).length;\n if (multiVoteBtn) {\n multiVoteBtn.disabled = n === 0;\n multiVoteBtn.textContent = t(\"pollVoteBtn\").replace(\"{n}\", String(n));\n }\n }\n function submitMulti() {\n var opts2 = Object.keys(selected);\n if (!opts2.length) return;\n vote(opts2, multiVoteBtn, true);\n }\n function changeVotes() {\n setStatus2(t(\"solving\"), \"busy\");\n fetch(endpoint + \"/api/polls/revoke\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ pollId, voterToken })\n }).then(function(res) {\n return res.json().then(function(d) {\n return { ok: res.ok, data: d };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n selected = {};\n setStatus2(t(\"pollVotesChanged\"), \"ok\");\n renderPoll(outcome.data.poll);\n } else {\n setStatus2(\"Error: \" + (outcome.data && outcome.data.error ? outcome.data.error : \"unknown\"), \"err\");\n }\n }).catch(function(err) {\n setStatus2(\"Error: \" + err.message, \"err\");\n });\n }\n function findIn(data) {\n var found = null;\n (Array.isArray(data.polls) ? data.polls : []).forEach(function(p) {\n if (p.id === pollId) found = p;\n });\n return found;\n }\n function loadPoll() {\n var suffix = voterToken ? \"&voterToken=\" + encodeURIComponent(voterToken) : \"\";\n return fetch(endpoint + \"/api/polls?article_path=\" + encodeURIComponent(articlePath) + suffix).then(function(res) {\n if (!res.ok) throw new Error(t(\"pollLoadError\"));\n return res.json();\n }).then(function(data) {\n var found = findIn(data);\n if (found) {\n renderPoll(found);\n return;\n }\n return fetch(endpoint + \"/api/polls?id=\" + encodeURIComponent(pollId) + suffix).then(function(res2) {\n if (!res2.ok) throw new Error(t(\"pollLoadError\"));\n return res2.json();\n }).then(function(data2) {\n var g = findIn(data2);\n if (g) {\n renderPoll(g);\n return;\n }\n qEl.textContent = t(\"pollMissing\");\n });\n }).catch(function() {\n setStatus2(t(\"pollLoadError\"), \"err\");\n });\n }\n function vote(options, btn, multi) {\n if (btn) {\n btn.disabled = true;\n btn.classList.add(\"sl-pop\");\n }\n setStatus2(t(\"solving\"), \"busy\");\n var t0 = Date.now();\n fetch(\n endpoint + \"/api/polls/challenge?hostContext=\" + encodeURIComponent(hostContext) + \"&articlePath=\" + encodeURIComponent(articlePath)\n ).then(function(res) {\n if (!res.ok) throw new Error(\"challenge request failed\");\n return res.json();\n }).then(function(challenge) {\n var solveArg = multi ? options : options[0];\n return solveWithWorker(challenge, \"poll\", pollId, solveArg).then(function(nonce) {\n return { challenge, nonce };\n });\n }).then(function(solved) {\n return waitForGate(t0).then(function() {\n var payload = {\n challengeId: solved.challenge.challengeId,\n hostContext: solved.challenge.hostContext,\n articlePath: solved.challenge.articlePath,\n pollId,\n difficulty: solved.challenge.difficulty,\n expiresAt: solved.challenge.expiresAt,\n signature: solved.challenge.signature,\n nonce: solved.nonce\n };\n if (multi) payload.options = options;\n else payload.option = options[0];\n if (voterToken) payload.voterToken = voterToken;\n return fetch(endpoint + \"/api/polls/vote\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify(payload)\n });\n });\n }).then(function(res) {\n return res.json().then(function(data) {\n return { ok: res.ok, status: res.status, data };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n if (outcome.data.voterToken) {\n voterToken = outcome.data.voterToken;\n try {\n localStorage.setItem(voterKey, voterToken);\n } catch (e) {\n }\n }\n selected = {};\n var updatedPoll = outcome.data.poll;\n if (updatedPoll) updatedPoll.voted = !!outcome.data.voted;\n renderPoll(updatedPoll);\n } else if (outcome.status === 409) {\n setStatus2(t(\"alreadyVoted\"), \"err\");\n loadPoll();\n } else {\n setStatus2(\"Error: \" + (outcome.data && outcome.data.error ? outcome.data.error : \"unknown\"), \"err\");\n }\n }).catch(function(err) {\n setStatus2(\"Error: \" + err.message, \"err\");\n }).finally(function() {\n if (btn) btn.disabled = false;\n });\n }\n loadPoll();\n }\n }\n function unmount(root) {\n if (!root) return;\n root.__slMounted = false;\n root.classList.remove(\"sl-root\");\n root.replaceChildren();\n }\n function initAll() {\n injectStyles();\n var roots = document.querySelectorAll(\"[data-staticlayer]\");\n for (var i = 0; i < roots.length; i += 1) mount(roots[i]);\n if (typeof window !== \"undefined\") {\n window.StaticLayer = { mount, unmount };\n }\n }\n if (document.readyState === \"loading\") {\n document.addEventListener(\"DOMContentLoaded\", initAll);\n } else {\n initAll();\n }\n })();\n})();\n"; +export const WIDGET_JS: string = "(() => {\n // src/widget.js\n (function() {\n \"use strict\";\n var WORKER_FILE = \"pow-worker.js\";\n var workerUrl = WORKER_FILE;\n var scriptSrc = typeof document !== \"undefined\" && document.currentScript && document.currentScript.src;\n if (scriptSrc) {\n try {\n workerUrl = new URL(WORKER_FILE, scriptSrc).href;\n } catch (e) {\n workerUrl = WORKER_FILE;\n }\n }\n var AVATAR_GRADIENTS = [\n \"linear-gradient(135deg,#ff9a5a,#f05a1c)\",\n \"linear-gradient(135deg,#a78bfa,#7c3aed)\",\n \"linear-gradient(135deg,#5eead4,#0d9488)\",\n \"linear-gradient(135deg,#7dd3fc,#0284c7)\",\n \"linear-gradient(135deg,#f9a8d4,#db2777)\",\n \"linear-gradient(135deg,#bef264,#65a30d)\",\n \"linear-gradient(135deg,#fda4af,#e11d48)\",\n \"linear-gradient(135deg,#fcd34d,#d97706)\"\n ];\n function hashString(str) {\n var h = 0;\n for (var i = 0; i < str.length; i += 1) {\n h = h * 31 + str.charCodeAt(i) | 0;\n }\n return Math.abs(h);\n }\n function el(tag, className, text) {\n var node = document.createElement(tag);\n if (className) node.className = className;\n if (text !== void 0) node.textContent = text;\n return node;\n }\n var I18N = {\n en: {\n title: \"Comments\",\n nickPlaceholder: \"Name (optional)\",\n bodyPlaceholder: \"Write a comment\\u2026\",\n post: \"Post comment\",\n hint: \"Plain text \\xB7 emoji welcome \\xB7 anti-spam proof-of-work\",\n empty: \"No comments yet \\u2014 be the first. \\u{1F4AC}\",\n anonymous: \"Anonymous\",\n emptyComment: \"Comment cannot be empty.\",\n solving: \"Solving proof-of-work\\u2026\",\n pending: \"Comment submitted \\u2014 awaiting moderation.\",\n posted: \"Comment posted.\",\n reactAria: \"React with \",\n reactionRecorded: \"\\u2713 Reaction recorded\",\n slowDown: \"Slow down \\u2014 too many reactions.\",\n challengeUsed: \"Challenge already used \\u2014 try again.\",\n countOne: \"1 comment\",\n countMany: \"{n} comments\",\n voted: \"\\u2713 You voted\",\n closed: \"This poll is closed\",\n pollMissing: \"Poll not found\",\n alreadyVoted: \"You already voted in this poll\",\n pollLoadError: \"Failed to load the poll\",\n pollVoteCountOne: \"1 vote\",\n pollVoteCountMany: \"{n} votes\",\n pollLeader: \"Leads by {n} votes\",\n pollVoteBtn: \"Vote ({n})\",\n pollMultiHint: \"Select one or more options\",\n pollChangeVotes: \"Change your votes\",\n pollVotesChanged: \"Votes updated \\u2014 you can vote again\",\n viewResults: \"View results\",\n backToVote: \"Vote\",\n resultsPreview: \"Live results\",\n startTitle: \"Start the conversation\",\n pinned: \"Pinned\",\n flag: \"Report\",\n flagBusy: \"Sending report\\u2026\",\n flagDone: \"Thanks \\u2014 report sent\",\n flagErr: \"Report failed\",\n sortNewest: \"Newest\",\n sortOldest: \"Oldest\",\n sortBest: \"Best\",\n timeNow: \"now\",\n timeMinAgo: \"{n} min ago\",\n timeHourAgo: \"{n} h ago\",\n timeDayAgo: \"{n} d ago\",\n timeWeekAgo: \"{n} w ago\",\n timeMonthAgo: \"{n} mo ago\",\n timeYearAgo: \"{n} y ago\",\n readMore: \"Read more\",\n showLess: \"Show less\",\n like: \"Like\",\n liked: \"Liked\",\n reply: \"Reply\",\n cancelReply: \"Cancel\",\n replyPlaceholder: \"Write a reply\\u2026\",\n showMoreReplies: \"Show more replies ({n})\",\n parentRemoved: \"Parent comment removed\",\n author: \"Author\"\n },\n it: {\n title: \"Commenti\",\n nickPlaceholder: \"Nome (facoltativo)\",\n bodyPlaceholder: \"Scrivi un commento\\u2026\",\n post: \"Pubblica commento\",\n hint: \"Testo semplice \\xB7 emoji benvenute \\xB7 anti-spam proof-of-work\",\n empty: \"Nessun commento ancora \\u2014 sii il primo. \\u{1F4AC}\",\n anonymous: \"Anonimo\",\n emptyComment: \"Il commento non pu\\xF2 essere vuoto.\",\n solving: \"Risoluzione proof-of-work\\u2026\",\n pending: \"Commento inviato \\u2014 in attesa di moderazione.\",\n posted: \"Commento pubblicato.\",\n reactAria: \"Reagisci con \",\n reactionRecorded: \"\\u2713 Reazione registrata\",\n slowDown: \"Rallenta \\u2014 troppe reazioni.\",\n challengeUsed: \"Challenge gi\\xE0 usata \\u2014 riprova.\",\n countOne: \"1 commento\",\n countMany: \"{n} commenti\",\n voted: \"\\u2713 Hai votato\",\n closed: \"Sondaggio chiuso\",\n pollMissing: \"Sondaggio non trovato\",\n alreadyVoted: \"Hai gi\\xE0 votato in questo sondaggio\",\n pollLoadError: \"Impossibile caricare il sondaggio\",\n pollVoteCountOne: \"1 voto\",\n pollVoteCountMany: \"{n} voti\",\n pollLeader: \"Conduce con {n} voti\",\n pollVoteBtn: \"Vota ({n})\",\n pollMultiHint: \"Seleziona una o pi\\xF9 opzioni\",\n pollChangeVotes: \"Modifica i tuoi voti\",\n pollVotesChanged: \"Voti aggiornati \\u2014 puoi votare di nuovo\",\n viewResults: \"Vedi risultati\",\n backToVote: \"Vota\",\n resultsPreview: \"Risultati in tempo reale\",\n startTitle: \"Inizia la conversazione\",\n pinned: \"In evidenza\",\n flag: \"Segnala\",\n flagBusy: \"Invio segnalazione\\u2026\",\n flagDone: \"Grazie \\u2014 segnalazione inviata\",\n flagErr: \"Segnalazione non riuscita\",\n sortNewest: \"Pi\\xF9 recenti\",\n sortOldest: \"Pi\\xF9 vecchi\",\n sortBest: \"Migliori\",\n timeNow: \"ora\",\n timeMinAgo: \"{n} min fa\",\n timeHourAgo: \"{n} h fa\",\n timeDayAgo: \"{n} g fa\",\n timeWeekAgo: \"{n} sett fa\",\n timeMonthAgo: \"{n} mesi fa\",\n timeYearAgo: \"{n} anni fa\",\n readMore: \"Leggi tutto\",\n showLess: \"Mostra meno\",\n like: \"Mi piace\",\n liked: \"Ti piace\",\n reply: \"Rispondi\",\n cancelReply: \"Annulla\",\n replyPlaceholder: \"Scrivi una risposta\\u2026\",\n showMoreReplies: \"Mostra altre risposte ({n})\",\n parentRemoved: \"Commento padre rimosso\",\n author: \"Autore\"\n },\n de: {\n title: \"Kommentare\",\n nickPlaceholder: \"Name (optional)\",\n bodyPlaceholder: \"Kommentar schreiben \\u2026\",\n post: \"Kommentar senden\",\n hint: \"Nur Text \\xB7 Emojis willkommen \\xB7 Spamschutz per Proof-of-Work\",\n empty: \"Noch keine Kommentare \\u2013 schreiben Sie den ersten. \\u{1F4AC}\",\n anonymous: \"Anonym\",\n emptyComment: \"Der Kommentar darf nicht leer sein.\",\n solving: \"Proof-of-Work wird berechnet \\u2026\",\n pending: \"Kommentar gesendet \\u2013 wartet auf Freigabe.\",\n posted: \"Kommentar ver\\xF6ffentlicht.\",\n reactAria: \"Reagieren mit \",\n reactionRecorded: \"\\u2713 Reaktion gespeichert\",\n slowDown: \"Bitte etwas langsamer \\u2013 zu viele Reaktionen.\",\n challengeUsed: \"Challenge bereits verwendet \\u2013 bitte erneut versuchen.\",\n countOne: \"1 Kommentar\",\n countMany: \"{n} Kommentare\",\n voted: \"\\u2713 Abgestimmt\",\n closed: \"Diese Umfrage ist beendet\",\n pollMissing: \"Umfrage nicht gefunden\",\n alreadyVoted: \"Sie haben bei dieser Umfrage bereits abgestimmt\",\n pollLoadError: \"Umfrage konnte nicht geladen werden\",\n pollVoteCountOne: \"1 Stimme\",\n pollVoteCountMany: \"{n} Stimmen\",\n pollLeader: \"F\\xFChrt mit {n} Stimmen\",\n pollVoteBtn: \"Abstimmen ({n})\",\n pollMultiHint: \"Eine oder mehrere Optionen ausw\\xE4hlen\",\n pollChangeVotes: \"Stimmen \\xE4ndern\",\n pollVotesChanged: \"Stimmen zur\\xFCckgesetzt \\u2013 Sie k\\xF6nnen erneut abstimmen\",\n viewResults: \"Ergebnisse ansehen\",\n backToVote: \"Abstimmen\",\n resultsPreview: \"Aktuelle Ergebnisse\",\n startTitle: \"Starten Sie die Diskussion\",\n pinned: \"Angeheftet\",\n flag: \"Melden\",\n flagBusy: \"Meldung wird gesendet \\u2026\",\n flagDone: \"Danke \\u2013 Meldung gesendet\",\n flagErr: \"Meldung fehlgeschlagen\",\n sortNewest: \"Neueste\",\n sortOldest: \"\\xC4lteste\",\n sortBest: \"Beste\",\n timeNow: \"gerade eben\",\n timeMinAgo: \"vor {n} Min.\",\n timeHourAgo: \"vor {n} Std.\",\n timeDayAgo: \"vor {n} T.\",\n timeWeekAgo: \"vor {n} Wo.\",\n timeMonthAgo: \"vor {n} Mon.\",\n timeYearAgo: \"vor {n} J.\",\n readMore: \"Weiterlesen\",\n showLess: \"Weniger anzeigen\",\n like: \"Gef\\xE4llt mir\",\n liked: \"Gef\\xE4llt mir\",\n reply: \"Antworten\",\n cancelReply: \"Abbrechen\",\n replyPlaceholder: \"Antwort schreiben \\u2026\",\n showMoreReplies: \"Weitere Antworten anzeigen ({n})\",\n parentRemoved: \"Urspr\\xFCnglicher Kommentar entfernt\",\n author: \"Autor\"\n }\n };\n var SUPPORTED_LANGS = [\"en\", \"it\", \"de\"];\n var STYLES = '.sl-root{--accent:#f57d1f;--accent-2:#e85d0a;--bg:#ffffff;--card:#ffffff;--border:#e8e8e6;--text:#1a1a1a;--muted:#6f6f6f;--radius:14px;--shadow:0 1px 2px rgba(16,24,40,.04),0 6px 20px -6px rgba(16,24,40,.08);font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",Inter,Roboto,\"Helvetica Neue\",Arial,sans-serif;color:var(--text);max-width:640px;margin:0 auto;line-height:1.55;-webkit-font-smoothing:antialiased}.sl-root.sl-theme-dark{--bg:#161616;--card:#1f1f1f;--border:#2b2b2b;--text:#f4f4f4;--muted:#9d9d9d;--shadow:0 1px 2px rgba(0,0,0,.3),0 10px 30px -12px rgba(0,0,0,.5)}@media(prefers-color-scheme:dark){.sl-root:not(.sl-theme-light){--bg:#161616;--card:#1f1f1f;--border:#2b2b2b;--text:#f4f4f4;--muted:#9d9d9d;--shadow:0 1px 2px rgba(0,0,0,.3),0 10px 30px -12px rgba(0,0,0,.5)}}.sl-root *{box-sizing:border-box}.sl-heading{display:flex;align-items:center;justify-content:space-between;margin:0 0 18px}.sl-heading h3{margin:0;font-size:15px;font-weight:650;letter-spacing:-.1px}.sl-count{font-size:11px;font-weight:600;color:var(--muted);background:color-mix(in srgb,var(--border) 60%,transparent);padding:3px 10px;border-radius:999px}.sl-list{list-style:none;margin:0 0 20px;padding:0;display:flex;flex-direction:column;gap:14px}.sl-comment{display:flex;flex-direction:column;gap:14px;padding:16px 18px;background:var(--card);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow);transition:box-shadow .18s ease,transform .18s ease,border-color .18s ease}.sl-comment .sl-top{display:flex;gap:12px;min-width:0}.sl-comment:hover{box-shadow:0 2px 4px rgba(16,24,40,.05),0 12px 32px -10px rgba(16,24,40,.12);transform:translateY(-1px);border-color:color-mix(in srgb,var(--accent) 25%,var(--border))}.sl-avatar{flex:none;width:36px;height:36px;border-radius:10px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:650;font-size:14px;user-select:none;box-shadow:inset 0 -2px 6px rgba(0,0,0,.14)}.sl-main{min-width:0;flex:1}.sl-head{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;margin-bottom:3px}.sl-nick{font-weight:600;font-size:13.5px;letter-spacing:-.1px}.sl-time{font-size:11.5px;color:var(--muted);font-variant-numeric:tabular-nums}.sl-body{margin:0;font-size:14.5px;white-space:pre-wrap;overflow-wrap:break-word;word-break:break-word}.sl-empty{padding:28px 18px;text-align:center;color:var(--muted);font-size:14px;background:var(--card);border:1px dashed var(--border);border-radius:var(--radius)}.sl-hidden{display:none!important}.sl-start{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px 16px 16px;margin-bottom:20px;text-align:center}.sl-start .sl-form{margin:0;box-shadow:none;text-align:left}.sl-start-msg{margin:0 0 14px;font-size:15px;font-weight:650;letter-spacing:-.2px}.sl-start-sub{display:block;margin-top:3px;font-weight:400;font-size:13px;color:var(--muted)}.sl-start:not(.sl-start-empty) .sl-start-msg{display:none}.sl-form{display:flex;flex-direction:column;gap:10px;padding:16px;background:var(--card);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow)}.sl-nick-input,.sl-body-input{width:100%;padding:11px 14px;border:1px solid var(--border);border-radius:10px;background:var(--bg);color:var(--text);font:inherit;font-size:14px;outline:none;transition:border-color .15s ease,box-shadow .15s ease,background .15s ease}.sl-nick-input:focus,.sl-body-input:focus{border-color:var(--accent);background:var(--bg);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 14%,transparent)}.sl-nick-input::placeholder,.sl-body-input::placeholder{color:var(--muted)}.sl-body-input{resize:vertical;min-height:80px;line-height:1.5}.sl-form-row{display:flex;align-items:center;gap:12px;justify-content:space-between;margin-top:2px}.sl-hint{font-size:11.5px;color:var(--muted)}.sl-submit{appearance:none;border:0;cursor:pointer;padding:10px 18px;border-radius:999px;font:inherit;font-weight:600;font-size:13.5px;color:#fff;background:linear-gradient(135deg,var(--accent),var(--accent-2));box-shadow:0 6px 16px -6px color-mix(in srgb,var(--accent) 60%,transparent);transition:transform .12s ease,filter .12s ease,opacity .12s ease}.sl-submit:hover{transform:translateY(-1px);filter:brightness(1.05)}.sl-submit:disabled{opacity:.55;cursor:progress;transform:none;filter:none}.sl-status{display:flex;align-items:center;gap:8px;min-height:20px;margin:12px 2px 0;font-size:13px;color:var(--muted)}.sl-status[data-kind=\"ok\"]{color:#16a34a}.sl-status[data-kind=\"err\"]{color:#dc2626}.sl-status .sl-spinner{width:14px;height:14px;border:2px solid color-mix(in srgb,var(--muted) 30%,transparent);border-top-color:var(--accent);border-radius:50%;animation:sl-spin .7s linear infinite}.sl-reactions{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 16px}.sl-reaction{appearance:none;display:inline-flex;align-items:center;gap:7px;cursor:pointer;font:inherit;font-size:14px;padding:7px 13px;border-radius:999px;border:1px solid var(--border);background:var(--card);color:var(--text);transition:transform .12s ease,border-color .12s ease,box-shadow .12s ease}.sl-reaction:hover{transform:translateY(-1px);border-color:var(--accent);box-shadow:0 4px 14px -6px color-mix(in srgb,var(--accent) 45%,transparent)}.sl-reaction:disabled{opacity:.6;cursor:progress;transform:none}.sl-reaction .sl-reaction-count{font-size:11.5px;font-weight:700;color:var(--muted);font-variant-numeric:tabular-nums}.sl-reaction-status{min-height:16px;margin:0 2px 12px;font-size:12px;color:var(--muted)}.sl-reaction-status[data-kind=\"ok\"]{color:#16a34a}.sl-reaction-status[data-kind=\"err\"]{color:#dc2626}.sl-poll-heading{display:flex;align-items:center;justify-content:space-between;margin:0 0 14px}.sl-poll-heading h3{margin:0;font-size:15px;font-weight:650;letter-spacing:-.1px;line-height:1.4}.sl-poll-options{list-style:none;margin:0 0 12px;padding:0;display:flex;flex-direction:column;gap:10px}.sl-poll-btn{appearance:none;width:100%;text-align:left;cursor:pointer;font:inherit;font-size:14px;padding:12px 16px;border-radius:12px;border:1px solid var(--border);background:var(--card);color:var(--text);transition:transform .12s ease,border-color .12s ease,box-shadow .12s ease}.sl-poll-btn:hover{transform:translateY(-1px);border-color:var(--accent);box-shadow:0 4px 14px -6px color-mix(in srgb,var(--accent) 45%,transparent)}.sl-poll-btn:disabled{opacity:.55;cursor:progress;transform:none}.sl-poll-opt-label{display:flex;align-items:baseline;justify-content:space-between;gap:10px;margin-bottom:6px}.sl-poll-opt-name{font-size:14px;font-weight:600}.sl-poll-opt-meta{font-size:11.5px;color:var(--muted);font-variant-numeric:tabular-nums}.sl-poll-bar{height:8px;border-radius:999px;background:color-mix(in srgb,var(--border) 60%,transparent);overflow:hidden}.sl-poll-fill{height:100%;border-radius:999px;background:linear-gradient(135deg,var(--accent),var(--accent-2));transition:width .5s ease}.sl-poll-status{display:flex;align-items:center;gap:8px;min-height:20px;margin:10px 2px 0;font-size:13px;color:var(--muted)}.sl-poll-status[data-kind=\"ok\"]{color:#16a34a}.sl-poll-status[data-kind=\"err\"]{color:#dc2626}.sl-poll-count{font-size:11px;font-weight:600;color:var(--muted);background:color-mix(in srgb,var(--border) 60%,transparent);padding:3px 10px;border-radius:999px;white-space:nowrap;font-variant-numeric:tabular-nums}.sl-poll-rank{flex:none;font-size:10.5px;font-weight:700;color:var(--muted);background:color-mix(in srgb,var(--border) 55%,transparent);border-radius:999px;padding:1px 7px;letter-spacing:.2px;font-variant-numeric:tabular-nums}.sl-poll-opt-label{display:flex;align-items:baseline;gap:8px;margin-bottom:6px}.sl-poll-opt-name{font-size:14px;font-weight:600;flex:1;min-width:0}.sl-poll-opt-meta{font-size:11.5px;color:var(--muted);font-variant-numeric:tabular-nums;flex:none}.sl-poll-bar{height:8px;border-radius:999px;background:color-mix(in srgb,var(--border) 60%,transparent);overflow:hidden}.sl-poll-fill{height:100%;border-radius:999px;background:linear-gradient(135deg,var(--accent),var(--accent-2));transition:width .6s cubic-bezier(.22,1,.36,1)}.sl-poll-option.sl-poll-leader .sl-poll-fill{background:linear-gradient(135deg,var(--accent),var(--accent-2));box-shadow:0 0 14px -2px color-mix(in srgb,var(--accent) 65%,transparent)}.sl-poll-option.sl-poll-leader .sl-poll-opt-name{color:var(--accent)}.sl-poll-lead-info{margin:2px 0 0;font-size:12px;font-weight:600;color:var(--accent);animation:sl-pop .3s ease}.sl-poll-check{display:flex;align-items:center;gap:10px}.sl-poll-check::before{content:\"\";flex:none;width:16px;height:16px;border-radius:5px;border:1.5px solid var(--border);background:var(--bg);transition:background .12s ease,border-color .12s ease}.sl-poll-check.selected{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 8%,var(--card))}.sl-poll-check.selected::before{background:var(--accent);border-color:var(--accent);box-shadow:inset 0 0 0 3px var(--card)}.sl-poll-vote-btn{appearance:none;border:0;cursor:pointer;padding:9px 18px;border-radius:999px;font:inherit;font-weight:600;font-size:13px;color:#fff;background:linear-gradient(135deg,var(--accent),var(--accent-2));box-shadow:0 6px 16px -6px color-mix(in srgb,var(--accent) 60%,transparent);transition:transform .12s ease,filter .12s ease,opacity .12s ease;margin-top:2px}.sl-poll-vote-btn:hover{transform:translateY(-1px);filter:brightness(1.05)}.sl-poll-vote-btn:disabled{opacity:.55;cursor:not-allowed;transform:none;filter:none}.sl-poll-multi-hint{margin:10px 2px 0;font-size:11.5px;color:var(--muted)}.sl-poll-results-link{appearance:none;cursor:pointer;font:inherit;font-size:12px;font-weight:600;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:999px;padding:5px 14px;margin-top:12px;transition:color .12s ease,border-color .12s ease}.sl-poll-results-link:hover{color:var(--accent);border-color:var(--accent)}.sl-poll-actions{display:flex;flex-direction:column;align-items:flex-start;gap:2px}.sl-pop{animation:sl-pop .35s cubic-bezier(.22,1,.36,1)}@keyframes sl-pop{0%{transform:scale(.94)}60%{transform:scale(1.03)}100%{transform:scale(1)}}.sl-replies{list-style:none;margin:0;padding:4px 0 0 14px;display:flex;flex-direction:column;gap:10px;border-left:2px solid color-mix(in srgb,var(--border) 65%,transparent)}.sl-reply{box-shadow:none;padding:12px 14px;background:color-mix(in srgb,var(--card) 55%,transparent)}.sl-reply:hover{box-shadow:none;transform:none}.sl-reply .sl-avatar{width:28px;height:28px;font-size:12px;border-radius:8px}.sl-reply .sl-nick{font-size:12.5px}.sl-owner-badge{display:inline-block;margin-left:7px;font-size:10px;font-weight:700;letter-spacing:.3px;text-transform:uppercase;color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,transparent);border:1px solid color-mix(in srgb,var(--accent) 30%,transparent);padding:1px 7px;border-radius:999px;vertical-align:1px}.sl-reply-btn{appearance:none;border:0;background:transparent;color:var(--muted);font:inherit;font-size:12px;font-weight:600;cursor:pointer;padding:0;margin-top:6px;transition:color .15s}.sl-reply-btn:hover{color:var(--accent)}.sl-reply-form{margin-top:12px}.sl-parent-removed{color:var(--muted);font-style:italic}.sl-show-more{list-style:none}.sl-sort{appearance:none;font:inherit;font-size:11.5px;color:var(--muted);background:var(--card);border:1px solid var(--border);border-radius:999px;padding:3px 9px;cursor:pointer;margin-left:8px;transition:border-color .15s}.sl-sort:hover,.sl-sort:focus-visible{border-color:var(--accent);outline:none}.sl-pin-badge{display:inline-block;margin-left:7px;font-size:10px;font-weight:700;letter-spacing:.3px;color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,transparent);border:1px solid color-mix(in srgb,var(--accent) 30%,transparent);padding:1px 7px;border-radius:999px;vertical-align:1px}.sl-pinned{border-color:color-mix(in srgb,var(--accent) 35%,var(--border))}.sl-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px}.sl-vote-btn{appearance:none;cursor:pointer;font:inherit;font-size:12px;font-weight:600;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:999px;padding:3px 10px;transition:color .12s ease,border-color .12s ease,transform .12s ease}.sl-vote-btn:hover{color:var(--accent);border-color:var(--accent);transform:translateY(-1px)}.sl-vote-btn.liked{color:var(--accent);border-color:var(--accent);background:color-mix(in srgb,var(--accent) 10%,transparent)}.sl-vote-btn:disabled{opacity:.6;cursor:progress;transform:none}.sl-flag-btn{appearance:none;cursor:pointer;font:inherit;font-size:11.5px;color:var(--muted);background:transparent;border:0;padding:3px 6px;border-radius:8px}.sl-flag-btn:hover{color:#dc2626;background:color-mix(in srgb,#dc2626 8%,transparent)}.sl-flag-btn:disabled{opacity:.6;cursor:progress}.sl-flag-btn.done{color:#16a34a}.sl-more-btn{appearance:none;cursor:pointer;font:inherit;font-size:12px;font-weight:600;color:var(--accent);background:none;border:0;padding:0 0 0 4px}.sl-skeleton-card{pointer-events:none}.sl-sk{height:12px;border-radius:6px;background:linear-gradient(90deg,color-mix(in srgb,var(--border) 55%,transparent),color-mix(in srgb,var(--border) 22%,transparent),color-mix(in srgb,var(--border) 55%,transparent));background-size:200% 100%;animation:sl-shimmer 1.3s infinite linear}.sl-sk-nick{width:30%;height:10px;margin-bottom:10px}.sl-sk-line{width:100%;margin-bottom:8px}.sl-sk-line.short{width:70%}.sl-sk-bar{height:8px;border-radius:999px}@keyframes sl-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}@keyframes sl-spin{to{transform:rotate(360deg)}}.sl-root :focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:4px}@media (prefers-reduced-motion: reduce){.sl-root *{transition:none!important;animation:none!important}.sl-poll-fill{transition:none!important}}';\n function injectStyles() {\n if (document.getElementById(\"sl-styles\")) return;\n var style = document.createElement(\"style\");\n style.id = \"sl-styles\";\n style.textContent = STYLES;\n (document.head || document.documentElement).appendChild(style);\n }\n function mount(root, opts) {\n if (!root || root.__slMounted) return;\n var endpoint = (opts && opts.endpoint || root.getAttribute(\"data-endpoint\") || root.getAttribute(\"data-api\") || \"\").replace(/\\/+$/, \"\");\n var articlePath = opts && opts.articlePath || root.getAttribute(\"data-article-path\") || root.getAttribute(\"data-article-id\") || (typeof location !== \"undefined\" ? location.pathname : \"\");\n var hostContext = opts && opts.hostContext || root.getAttribute(\"data-host-context\") || (typeof location !== \"undefined\" ? location.hostname : \"\");\n if (!endpoint) return;\n root.__slMounted = true;\n root.classList.add(\"sl-root\");\n var lang = opts && opts.lang || root.getAttribute(\"data-lang\") || \"auto\";\n if (SUPPORTED_LANGS.indexOf(lang) === -1) {\n var navLang = typeof navigator !== \"undefined\" && navigator.language ? navigator.language.toLowerCase().split(\"-\")[0] : \"\";\n lang = SUPPORTED_LANGS.indexOf(navLang) !== -1 ? navLang : \"en\";\n }\n var dict = I18N[lang] || I18N.en;\n var customTexts = null;\n if (opts && opts.texts) {\n customTexts = opts.texts;\n } else {\n var rawTexts = root.getAttribute(\"data-text\");\n if (rawTexts) {\n try {\n customTexts = JSON.parse(rawTexts);\n } catch (e) {\n customTexts = null;\n }\n }\n }\n function t(key) {\n if (customTexts && Object.prototype.hasOwnProperty.call(customTexts, key) && typeof customTexts[key] === \"string\" && customTexts[key].length) return customTexts[key];\n if (dict && Object.prototype.hasOwnProperty.call(dict, key)) return dict[key];\n return key;\n }\n function countLabel(n) {\n return String(t(n === 1 ? \"countOne\" : \"countMany\")).replace(\"{n}\", String(n));\n }\n function pollCountLabel(n) {\n return String(t(n === 1 ? \"pollVoteCountOne\" : \"pollVoteCountMany\")).replace(\"{n}\", String(n));\n }\n function relativeTime(ts) {\n var diff = Math.floor(Date.now() / 1e3) - ts;\n if (diff < 60) return t(\"timeNow\");\n if (diff < 3600) return t(\"timeMinAgo\").replace(\"{n}\", String(Math.floor(diff / 60)));\n if (diff < 86400) return t(\"timeHourAgo\").replace(\"{n}\", String(Math.floor(diff / 3600)));\n if (diff < 604800) return t(\"timeDayAgo\").replace(\"{n}\", String(Math.floor(diff / 86400)));\n if (diff < 2592e3) return t(\"timeWeekAgo\").replace(\"{n}\", String(Math.floor(diff / 604800)));\n if (diff < 31536e3) return t(\"timeMonthAgo\").replace(\"{n}\", String(Math.floor(diff / 2592e3)));\n if (diff < 31536e4) return t(\"timeYearAgo\").replace(\"{n}\", String(Math.floor(diff / 31536e3)));\n return new Date(ts * 1e3).toLocaleDateString();\n }\n var theme = String(opts && opts.theme || root.getAttribute(\"data-theme\") || \"\").trim();\n if (theme === \"dark\") root.classList.add(\"sl-theme-dark\");\n else if (theme === \"light\") root.classList.add(\"sl-theme-light\");\n var accent = String(opts && opts.accent || root.getAttribute(\"data-accent\") || \"\").trim();\n if (accent) root.style.setProperty(\"--accent\", accent);\n var accent2 = String(opts && opts.accent2 || root.getAttribute(\"data-accent-2\") || \"\").trim();\n if (accent2) root.style.setProperty(\"--accent-2\", accent2);\n var radius = String(opts && opts.radius || root.getAttribute(\"data-radius\") || \"\").trim();\n if (radius) root.style.setProperty(\"--radius\", radius.replace(/[^0-9.]/g, \"\") + \"px\");\n var maxWidth = String(opts && opts.maxWidth || root.getAttribute(\"data-max-width\") || \"\").trim();\n if (maxWidth) root.style.setProperty(\"max-width\", maxWidth.replace(/[^0-9.]/g, \"\") + \"px\");\n var pollId = String(opts && opts.pollId || root.getAttribute(\"data-poll-id\") || \"\").trim();\n var pollStyle = String(opts && opts.pollStyle || root.getAttribute(\"data-poll-style\") || \"bars\").trim();\n var pollResults = String(opts && opts.pollResults || root.getAttribute(\"data-poll-results\") || \"after\").trim();\n var timeGateMs = 3e3;\n var rawGate = opts && opts.timeGateMs != null ? String(opts.timeGateMs) : root.getAttribute(\"data-time-gate-ms\");\n if (rawGate != null && /^\\d+$/.test(rawGate)) timeGateMs = parseInt(rawGate, 10);\n var reactionsPosition = String(opts && opts.reactionsPosition || root.getAttribute(\"data-reactions-position\") || \"bottom\").trim();\n if (reactionsPosition !== \"top\" && reactionsPosition !== \"bottom\") reactionsPosition = \"bottom\";\n var commentsSort = String(opts && opts.commentsSort || root.getAttribute(\"data-comments-sort\") || \"newest\").trim();\n if (commentsSort !== \"newest\" && commentsSort !== \"oldest\" && commentsSort !== \"best\") commentsSort = \"newest\";\n if (pollId) {\n initPoll();\n return;\n }\n var reactionsOnly = !!(opts && opts.reactionsOnly) || root.getAttribute(\"data-reactions-only\") !== null;\n var reactions = null;\n if (opts && Array.isArray(opts.reactions)) {\n reactions = opts.reactions.filter(function(s) {\n return typeof s === \"string\" && s.length;\n });\n } else {\n var rawAttr = root.getAttribute(\"data-reactions\");\n if (rawAttr) {\n reactions = rawAttr.split(\",\").map(function(s) {\n return s.trim();\n }).filter(Boolean);\n }\n }\n var heading = null, count = null, list = null, nickInput = null, bodyInput = null;\n var submitBtn = null, status = null, form = null;\n var reactBar = null, reactStatus = null;\n var reactBtns = {}, reactBusy = false;\n var sortSelect = null;\n var loadedOnce = false;\n var commentVoterKey = \"sl-comment-voter-token\";\n var commentVoterToken = null;\n try {\n commentVoterToken = localStorage.getItem(commentVoterKey);\n } catch (e) {\n commentVoterToken = null;\n }\n function buildReactions() {\n if (!reactBar || !reactions) return;\n reactions.forEach(function(r) {\n var btn = el(\"button\", \"sl-reaction\");\n btn.type = \"button\";\n btn.setAttribute(\"aria-label\", t(\"reactAria\") + r);\n var c = el(\"span\", \"sl-reaction-count\", \"0\");\n btn.append(el(\"span\", \"sl-reaction-emoji\", r), c);\n reactBtns[r] = { btn, count: c };\n btn.addEventListener(\"click\", function() {\n submitReaction(r);\n });\n reactBar.appendChild(btn);\n });\n }\n if (reactionsOnly) {\n reactStatus = el(\"p\", \"sl-reaction-status\");\n reactBar = el(\"div\", \"sl-reactions\");\n buildReactions();\n root.append(reactBar, reactStatus);\n } else {\n heading = el(\"div\", \"sl-heading\");\n heading.appendChild(el(\"h3\", null, t(\"title\")));\n count = el(\"span\", \"sl-count\", \"\\u2026\");\n heading.appendChild(count);\n sortSelect = el(\"select\", \"sl-sort\");\n sortSelect.setAttribute(\"aria-label\", t(\"title\") + \" \\u2014 sort\");\n [[\"newest\", t(\"sortNewest\")], [\"oldest\", t(\"sortOldest\")], [\"best\", t(\"sortBest\")]].forEach(function(opt) {\n var o = document.createElement(\"option\");\n o.value = opt[0];\n o.textContent = opt[1];\n sortSelect.appendChild(o);\n });\n sortSelect.value = commentsSort;\n sortSelect.style.display = \"none\";\n sortSelect.addEventListener(\"change\", function() {\n commentsSort = sortSelect.value;\n loadComments();\n });\n heading.appendChild(sortSelect);\n list = el(\"ul\", \"sl-list\");\n nickInput = el(\"input\");\n nickInput.type = \"text\";\n nickInput.maxLength = 50;\n nickInput.placeholder = t(\"nickPlaceholder\");\n nickInput.className = \"sl-nick-input\";\n bodyInput = el(\"textarea\");\n bodyInput.maxLength = 3e3;\n bodyInput.rows = 3;\n bodyInput.placeholder = t(\"bodyPlaceholder\");\n bodyInput.className = \"sl-body-input\";\n submitBtn = el(\"button\", \"sl-submit\", t(\"post\"));\n submitBtn.type = \"submit\";\n var hint = el(\"span\", \"sl-hint\", t(\"hint\"));\n status = el(\"p\", \"sl-status\");\n form = el(\"form\", \"sl-form\");\n var row = el(\"div\", \"sl-form-row\");\n row.append(hint, submitBtn);\n var hpInput = makeHoneypot(\"website\");\n form.append(nickInput, bodyInput, hpInput, row);\n var startCard = el(\"div\", \"sl-start sl-start-empty\");\n var startMsg = el(\"p\", \"sl-start-msg\", t(\"startTitle\"));\n startMsg.appendChild(el(\"span\", \"sl-start-sub\", t(\"empty\")));\n startCard.append(startMsg, form, status);\n root.append(heading);\n if (reactions && reactions.length) {\n reactStatus = el(\"p\", \"sl-reaction-status\");\n reactBar = el(\"div\", \"sl-reactions\");\n buildReactions();\n if (reactionsPosition === \"top\") root.append(reactBar, reactStatus);\n }\n root.append(list, startCard);\n if (reactions && reactions.length && reactionsPosition !== \"top\") {\n root.append(reactBar, reactStatus);\n }\n }\n function setStatus(message, kind) {\n status.replaceChildren();\n status.removeAttribute(\"data-kind\");\n if (kind === \"ok\") {\n status.setAttribute(\"data-kind\", \"ok\");\n status.appendChild(el(\"span\", null, \"\\u2713\"));\n }\n if (kind === \"err\") {\n status.setAttribute(\"data-kind\", \"err\");\n status.appendChild(el(\"span\", null, \"\\u2715\"));\n }\n if (kind === \"busy\") {\n status.appendChild(el(\"span\", \"sl-spinner\"));\n }\n status.appendChild(document.createTextNode(message));\n }\n var MAX_DEPTH = 3;\n var REPLIES_PER_PAGE = 5;\n function buildTree(comments) {\n var byId = {};\n var roots = [];\n comments.forEach(function(c) {\n c.children = [];\n c.parentMissing = false;\n byId[c.id] = c;\n });\n comments.forEach(function(c) {\n if (c.parent_id && byId[c.parent_id]) {\n byId[c.parent_id].children.push(c);\n } else {\n if (c.parent_id) c.parentMissing = true;\n roots.push(c);\n }\n });\n return roots;\n }\n var READ_MORE_CHARS = 400;\n function skeletonItem() {\n var li = el(\"li\", \"sl-comment sl-skeleton-card\");\n var nick = el(\"div\", \"sl-sk sl-sk-nick\");\n var line1 = el(\"div\", \"sl-sk sl-sk-line\");\n var line2 = el(\"div\", \"sl-sk sl-sk-line short\");\n li.append(nick, line1, line2);\n return li;\n }\n function makeBody(text) {\n var p = el(\"p\", \"sl-body\");\n if (text.length <= READ_MORE_CHARS) {\n p.textContent = text;\n return p;\n }\n var short = text.slice(0, READ_MORE_CHARS).replace(/\\s+\\S*$/, \"\") + \"\\u2026\";\n var shown = el(\"span\", null, short);\n var full = el(\"span\", null, text);\n full.style.display = \"none\";\n var btn = el(\"button\", \"sl-more-btn\", t(\"readMore\"));\n btn.type = \"button\";\n btn.addEventListener(\"click\", function() {\n var isCollapsed = full.style.display === \"none\";\n shown.style.display = isCollapsed ? \"none\" : \"inline\";\n full.style.display = isCollapsed ? \"inline\" : \"none\";\n btn.textContent = isCollapsed ? t(\"showLess\") : t(\"readMore\");\n });\n p.append(shown, full, btn);\n return p;\n }\n function renderComment(c, depth) {\n var li = el(\"li\", \"sl-comment\" + (depth > 0 ? \" sl-reply\" : \"\") + (c.pinned && depth === 0 ? \" sl-pinned\" : \"\"));\n var nick = c.nickname && c.nickname.trim() || t(\"anonymous\");\n var avatar = el(\"div\", \"sl-avatar\", nick.charAt(0).toUpperCase());\n avatar.style.background = AVATAR_GRADIENTS[hashString(nick) % AVATAR_GRADIENTS.length];\n var main = el(\"div\", \"sl-main\");\n var head = el(\"div\", \"sl-head\");\n var nickWrap = el(\"span\", \"sl-nick\", nick);\n if (c.is_owner) nickWrap.appendChild(el(\"span\", \"sl-owner-badge\", t(\"author\")));\n if (c.pinned && depth === 0) nickWrap.appendChild(el(\"span\", \"sl-pin-badge\", \"\\u{1F4CC} \" + t(\"pinned\")));\n head.append(nickWrap, el(\"span\", \"sl-time\", relativeTime(c.created_at)));\n var bodyEl = c.parentMissing ? el(\"p\", \"sl-body sl-parent-removed\", t(\"parentRemoved\")) : makeBody(c.body);\n main.append(head, bodyEl);\n var actions = el(\"div\", \"sl-actions\");\n var votes = Number(c.votes) || 0;\n var voteBtn = el(\"button\", \"sl-vote-btn\" + (c.voted ? \" liked\" : \"\"), (c.voted ? \"\\u2713 \" : \"\\u25B2 \") + votes);\n voteBtn.type = \"button\";\n voteBtn.setAttribute(\"aria-pressed\", c.voted ? \"true\" : \"false\");\n voteBtn.title = c.voted ? t(\"liked\") : t(\"like\");\n voteBtn.addEventListener(\"click\", function() {\n voteComment(c, voteBtn);\n });\n actions.appendChild(voteBtn);\n if (depth < MAX_DEPTH) {\n var replyBtn = el(\"button\", \"sl-reply-btn\", t(\"reply\"));\n replyBtn.type = \"button\";\n replyBtn.addEventListener(\"click\", function() {\n toggleReplyForm(c, main, replyBtn);\n });\n actions.appendChild(replyBtn);\n }\n var flagBtn = el(\"button\", \"sl-flag-btn\", t(\"flag\"));\n flagBtn.type = \"button\";\n flagBtn.addEventListener(\"click\", function() {\n flagComment(c, flagBtn);\n });\n actions.appendChild(flagBtn);\n main.appendChild(actions);\n var top = el(\"div\", \"sl-top\");\n top.append(avatar, main);\n li.append(top);\n if (c.children && c.children.length) {\n var ul = el(\"ul\", \"sl-replies\");\n var visible = c.children.slice(0, REPLIES_PER_PAGE);\n visible.forEach(function(ch) {\n ul.appendChild(renderComment(ch, depth + 1));\n });\n var hidden = c.children.slice(REPLIES_PER_PAGE);\n if (hidden.length) {\n var more = el(\"li\", \"sl-show-more\");\n var moreBtn = el(\"button\", \"sl-reply-btn\", t(\"showMoreReplies\").replace(\"{n}\", String(hidden.length)));\n moreBtn.type = \"button\";\n moreBtn.addEventListener(\"click\", function() {\n hidden.forEach(function(ch) {\n ul.insertBefore(renderComment(ch, depth + 1), more);\n });\n more.remove();\n });\n more.appendChild(moreBtn);\n ul.appendChild(more);\n }\n li.appendChild(ul);\n }\n return li;\n }\n function commentActionChallenge() {\n return fetch(\n endpoint + \"/api/comments/challenge?hostContext=\" + encodeURIComponent(hostContext) + \"&articlePath=\" + encodeURIComponent(articlePath)\n ).then(function(res) {\n if (!res.ok) throw new Error(\"challenge request failed\");\n return res.json();\n });\n }\n function voteComment(c, btn) {\n if (btn.disabled || c.voted) return;\n btn.disabled = true;\n commentActionChallenge().then(function(challenge) {\n return solveWithWorker(challenge, \"comment-action\", \"vote\", c.id).then(function(nonce) {\n return { challenge, nonce };\n });\n }).then(function(solved) {\n var payload = {\n challengeId: solved.challenge.challengeId,\n hostContext: solved.challenge.hostContext,\n articlePath: solved.challenge.articlePath,\n commentId: c.id,\n difficulty: solved.challenge.difficulty,\n expiresAt: solved.challenge.expiresAt,\n signature: solved.challenge.signature,\n nonce: solved.nonce\n };\n if (commentVoterToken) payload.voterToken = commentVoterToken;\n return fetch(endpoint + \"/api/comments/vote\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify(payload)\n });\n }).then(function(res) {\n return res.json().then(function(d) {\n return { ok: res.ok, status: res.status, data: d };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n if (outcome.data.voterToken) {\n commentVoterToken = outcome.data.voterToken;\n try {\n localStorage.setItem(commentVoterKey, commentVoterToken);\n } catch (e) {\n }\n }\n c.voted = true;\n c.votes = Number(outcome.data.votes) || Number(c.votes) + 1;\n btn.classList.add(\"liked\");\n btn.setAttribute(\"aria-pressed\", \"true\");\n btn.textContent = \"\\u2713 \" + c.votes;\n btn.title = t(\"liked\");\n } else if (outcome.status === 409) {\n c.voted = true;\n btn.classList.add(\"liked\");\n btn.setAttribute(\"aria-pressed\", \"true\");\n btn.textContent = \"\\u2713 \" + (Number(c.votes) || 0);\n }\n }).catch(function() {\n }).finally(function() {\n btn.disabled = false;\n });\n }\n function flagComment(c, btn) {\n if (btn.disabled) return;\n btn.disabled = true;\n btn.textContent = t(\"flagBusy\");\n commentActionChallenge().then(function(challenge) {\n return solveWithWorker(challenge, \"comment-action\", \"flag\", c.id).then(function(nonce) {\n return { challenge, nonce };\n });\n }).then(function(solved) {\n return fetch(endpoint + \"/api/comments/flag\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({\n challengeId: solved.challenge.challengeId,\n hostContext: solved.challenge.hostContext,\n articlePath: solved.challenge.articlePath,\n commentId: c.id,\n difficulty: solved.challenge.difficulty,\n expiresAt: solved.challenge.expiresAt,\n signature: solved.challenge.signature,\n nonce: solved.nonce\n })\n });\n }).then(function(res) {\n return res.json().then(function(d) {\n return { ok: res.ok, data: d };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n btn.textContent = t(\"flagDone\");\n btn.classList.add(\"done\");\n } else {\n btn.textContent = t(\"flag\");\n btn.disabled = false;\n }\n }).catch(function() {\n btn.textContent = t(\"flag\");\n btn.disabled = false;\n });\n }\n function toggleReplyForm(c, main, btn) {\n var existing = main.querySelector(\".sl-reply-form\");\n if (existing) {\n existing.remove();\n btn.textContent = t(\"reply\");\n return;\n }\n var form2 = el(\"form\", \"sl-form sl-reply-form\");\n var nickInput2 = el(\"input\");\n nickInput2.type = \"text\";\n nickInput2.maxLength = 50;\n nickInput2.placeholder = t(\"nickPlaceholder\");\n nickInput2.className = \"sl-nick-input\";\n var bodyInput2 = el(\"textarea\");\n bodyInput2.maxLength = 3e3;\n bodyInput2.rows = 2;\n bodyInput2.placeholder = t(\"replyPlaceholder\");\n bodyInput2.className = \"sl-body-input\";\n var row2 = el(\"div\", \"sl-form-row\");\n var submit = el(\"button\", \"sl-submit\", t(\"post\"));\n submit.type = \"submit\";\n var cancel = el(\"button\", \"sl-reply-btn\", t(\"cancelReply\"));\n cancel.type = \"button\";\n cancel.addEventListener(\"click\", function() {\n form2.remove();\n btn.textContent = t(\"reply\");\n });\n row2.append(cancel, submit);\n var hpInput2 = makeHoneypot(\"website\");\n form2.append(nickInput2, bodyInput2, hpInput2, row2);\n var statusEl = el(\"p\", \"sl-status\");\n form2.appendChild(statusEl);\n var busy = false;\n form2.addEventListener(\"submit\", function(event) {\n event.preventDefault();\n if (busy) return;\n var nickname = nickInput2.value.trim();\n var body = bodyInput2.value.trim();\n if (!body) {\n statusEl.textContent = t(\"emptyComment\");\n return;\n }\n busy = true;\n submit.disabled = true;\n statusEl.textContent = t(\"solving\");\n fetch(\n endpoint + \"/api/comments/challenge?hostContext=\" + encodeURIComponent(hostContext) + \"&articlePath=\" + encodeURIComponent(articlePath)\n ).then(function(res) {\n if (!res.ok) throw new Error(\"challenge request failed\");\n return res.json();\n }).then(function(challenge) {\n return solveWithWorker(challenge, \"comment\", nickname, body).then(function(nonce) {\n return { challenge, nonce };\n });\n }).then(function(solved) {\n return fetch(endpoint + \"/api/comments\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({\n challengeId: solved.challenge.challengeId,\n hostContext: solved.challenge.hostContext,\n articlePath: solved.challenge.articlePath,\n nickname,\n body,\n parentId: c.id,\n difficulty: solved.challenge.difficulty,\n expiresAt: solved.challenge.expiresAt,\n signature: solved.challenge.signature,\n nonce: solved.nonce\n })\n });\n }).then(function(res) {\n return res.json().then(function(data) {\n return { ok: res.ok, data };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n statusEl.textContent = outcome.data.comment && outcome.data.comment.status === \"pending\" ? t(\"pending\") : t(\"posted\");\n bodyInput2.value = \"\";\n return loadComments();\n }\n statusEl.textContent = \"Error: \" + (outcome.data && outcome.data.error ? outcome.data.error : \"unknown\");\n }).catch(function(err) {\n statusEl.textContent = \"Error: \" + err.message;\n }).finally(function() {\n busy = false;\n submit.disabled = false;\n });\n });\n main.appendChild(form2);\n btn.textContent = t(\"cancelReply\");\n bodyInput2.focus();\n }\n function loadComments() {\n if (!loadedOnce) {\n loadedOnce = true;\n list.classList.remove(\"sl-hidden\");\n startCard.classList.add(\"sl-hidden\");\n var frag = document.createDocumentFragment();\n for (var i = 0; i < 3; i += 1) frag.appendChild(skeletonItem());\n list.appendChild(frag);\n }\n var suffix = commentVoterToken ? \"&voterToken=\" + encodeURIComponent(commentVoterToken) : \"\";\n return fetch(\n endpoint + \"/api/comments?article_path=\" + encodeURIComponent(articlePath) + \"&host_context=\" + encodeURIComponent(hostContext) + suffix\n ).then(function(res) {\n if (!res.ok) throw new Error(\"failed to load comments\");\n return res.json();\n }).then(function(data) {\n var comments = Array.isArray(data.comments) ? data.comments : [];\n count.textContent = countLabel(comments.length);\n list.replaceChildren();\n if (comments.length === 0) {\n list.classList.add(\"sl-hidden\");\n startCard.classList.remove(\"sl-hidden\");\n startCard.classList.add(\"sl-start-empty\");\n if (sortSelect) sortSelect.style.display = \"none\";\n return;\n }\n list.classList.remove(\"sl-hidden\");\n startCard.classList.remove(\"sl-hidden\");\n startCard.classList.remove(\"sl-start-empty\");\n if (sortSelect) sortSelect.style.display = comments.length > 1 ? \"\" : \"none\";\n var roots = buildTree(comments);\n var pinnedRoots = roots.filter(function(r) {\n return r.pinned;\n });\n var restRoots = roots.filter(function(r) {\n return !r.pinned;\n });\n var dir = commentsSort === \"oldest\" ? 1 : -1;\n restRoots.sort(function(a, b) {\n if (commentsSort === \"best\") {\n return (Number(b.votes) || 0) - (Number(a.votes) || 0) || a.created_at - b.created_at;\n }\n return (a.created_at - b.created_at) * dir;\n });\n pinnedRoots.concat(restRoots).forEach(function(r) {\n list.appendChild(renderComment(r, 0));\n });\n });\n }\n function createPowWorkerUrl() {\n return fetch(workerUrl, { mode: \"cors\", credentials: \"omit\" }).then(function(res) {\n if (!res.ok) throw new Error(\"failed to load pow worker\");\n return res.blob();\n }).then(function(blob) {\n return URL.createObjectURL(blob);\n });\n }\n function makeHoneypot(name) {\n var hp = el(\"input\");\n hp.type = \"text\";\n hp.name = name || \"website\";\n hp.tabIndex = -1;\n hp.autocomplete = \"off\";\n hp.setAttribute(\"aria-hidden\", \"true\");\n hp.className = \"sl-hp\";\n hp.style.cssText = \"position:absolute!important;left:-9999px!important;width:1px!important;height:1px!important;opacity:0!important;overflow:hidden!important;\";\n return hp;\n }\n function waitForGate(startMs) {\n if (timeGateMs <= 0) return Promise.resolve();\n var wait = Math.max(0, timeGateMs + 500 - (Date.now() - startMs));\n if (wait <= 0) return Promise.resolve();\n return new Promise(function(resolveGate) {\n setTimeout(resolveGate, wait);\n });\n }\n function solveWithWorker(challenge, kind, a, b) {\n return new Promise(function(resolve, reject) {\n createPowWorkerUrl().then(function(url) {\n var worker;\n try {\n worker = new Worker(url);\n } catch (err) {\n reject(err);\n return null;\n }\n worker.onmessage = function(e) {\n URL.revokeObjectURL(url);\n worker.terminate();\n if (e.data && e.data.type === \"nonce\") resolve(e.data.nonce);\n else reject(new Error(e.data && e.data.message || \"pow worker failed\"));\n };\n worker.onerror = function() {\n URL.revokeObjectURL(url);\n worker.terminate();\n reject(new Error(\"pow worker error\"));\n };\n var msg = { challenge };\n if (kind === \"poll\") {\n msg.pollId = a;\n if (Array.isArray(b)) msg.options = b;\n else msg.option = b;\n } else if (kind === \"comment-action\") {\n msg.action = a;\n msg.commentId = b;\n } else {\n msg.nickname = a;\n msg.body = b;\n }\n worker.postMessage(msg);\n return null;\n }).catch(reject);\n });\n }\n function submitComment(event) {\n event.preventDefault();\n var nickname = nickInput.value.trim();\n var body = bodyInput.value.trim();\n if (!body) {\n setStatus(t(\"emptyComment\"), \"err\");\n return;\n }\n submitBtn.disabled = true;\n setStatus(t(\"solving\"), \"busy\");\n var t0 = Date.now();\n fetch(\n endpoint + \"/api/comments/challenge?hostContext=\" + encodeURIComponent(hostContext) + \"&articlePath=\" + encodeURIComponent(articlePath)\n ).then(function(res) {\n if (!res.ok) throw new Error(\"challenge request failed\");\n return res.json();\n }).then(function(challenge) {\n return solveWithWorker(challenge, \"comment\", nickname, body).then(function(nonce) {\n return { challenge, nonce };\n });\n }).then(function(solved) {\n return waitForGate(t0).then(function() {\n return fetch(endpoint + \"/api/comments\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({\n challengeId: solved.challenge.challengeId,\n hostContext: solved.challenge.hostContext,\n articlePath: solved.challenge.articlePath,\n nickname,\n body,\n difficulty: solved.challenge.difficulty,\n expiresAt: solved.challenge.expiresAt,\n signature: solved.challenge.signature,\n nonce: solved.nonce,\n honeypot: hpInput.value\n })\n });\n });\n }).then(function(res) {\n return res.json().then(function(data) {\n return { ok: res.ok, data };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n bodyInput.value = \"\";\n setStatus(\n outcome.data.comment && outcome.data.comment.status === \"pending\" ? t(\"pending\") : t(\"posted\"),\n \"ok\"\n );\n return loadComments();\n }\n setStatus(\"Error: \" + (outcome.data && outcome.data.error ? outcome.data.error : \"unknown\"), \"err\");\n }).catch(function(err) {\n setStatus(\"Error: \" + err.message, \"err\");\n }).finally(function() {\n submitBtn.disabled = false;\n });\n }\n if (form) form.addEventListener(\"submit\", submitComment);\n if (!reactionsOnly) loadComments();\n loadReactions();\n function setReactStatus(message, kind) {\n if (!reactStatus) return;\n reactStatus.textContent = message;\n if (kind) reactStatus.setAttribute(\"data-kind\", kind);\n else reactStatus.removeAttribute(\"data-kind\");\n }\n function setReactBusy(busy) {\n reactBusy = busy;\n reactions.forEach(function(x) {\n if (reactBtns[x]) reactBtns[x].btn.disabled = busy;\n });\n }\n function renderReactionCounts(counts) {\n var map = {};\n counts.forEach(function(r) {\n map[r.reaction] = Number(r.count) || 0;\n });\n reactions.forEach(function(x) {\n if (reactBtns[x]) reactBtns[x].count.textContent = String(map[x] || 0);\n });\n }\n function loadReactions() {\n if (!reactBar) return;\n fetch(endpoint + \"/api/reactions?article_path=\" + encodeURIComponent(articlePath)).then(function(res) {\n if (!res.ok) throw new Error(\"failed to load reactions\");\n return res.json();\n }).then(function(data) {\n if (Array.isArray(data.reactions)) renderReactionCounts(data.reactions);\n }).catch(function() {\n });\n }\n function submitReaction(r) {\n if (reactBusy || !reactBtns[r]) return;\n setReactBusy(true);\n setReactStatus(t(\"solving\"));\n var t0 = Date.now();\n fetch(\n endpoint + \"/api/reactions/challenge?hostContext=\" + encodeURIComponent(hostContext) + \"&articlePath=\" + encodeURIComponent(articlePath)\n ).then(function(res) {\n if (!res.ok) throw new Error(\"challenge request failed\");\n return res.json();\n }).then(function(challenge) {\n return solveWithWorker(challenge, \"comment\", \"\", \"\").then(function(nonce) {\n return { challenge, nonce };\n });\n }).then(function(solved) {\n return waitForGate(t0).then(function() {\n return fetch(endpoint + \"/api/reactions\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({\n challengeId: solved.challenge.challengeId,\n hostContext: solved.challenge.hostContext,\n articlePath: solved.challenge.articlePath,\n reaction: r,\n difficulty: solved.challenge.difficulty,\n expiresAt: solved.challenge.expiresAt,\n signature: solved.challenge.signature,\n nonce: solved.nonce\n })\n });\n });\n }).then(function(res) {\n return res.json().then(function(data) {\n return { ok: res.ok, status: res.status, data };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n setReactStatus(t(\"reactionRecorded\"), \"ok\");\n if (Array.isArray(outcome.data.reactions)) renderReactionCounts(outcome.data.reactions);\n } else if (outcome.status === 429) {\n setReactStatus(t(\"slowDown\"), \"err\");\n } else if (outcome.status === 409) {\n setReactStatus(t(\"challengeUsed\"), \"err\");\n } else {\n setReactStatus(\n \"Error: \" + (outcome.data && outcome.data.error ? outcome.data.error : \"unknown\"),\n \"err\"\n );\n }\n }).catch(function(err) {\n setReactStatus(\"Error: \" + err.message, \"err\");\n }).finally(function() {\n setReactBusy(false);\n });\n }\n function initPoll() {\n var voterKey = \"sl-voter-token\";\n var voterToken = null;\n try {\n voterToken = localStorage.getItem(voterKey);\n } catch (e) {\n voterToken = null;\n }\n var heading2 = el(\"div\", \"sl-poll-heading\");\n var qEl = el(\"h3\", null, \"\\u2026\");\n var totalChip = el(\"span\", \"sl-poll-count\", \"\");\n heading2.append(qEl, totalChip);\n var statusEl = el(\"p\", \"sl-poll-status\");\n var listEl = el(\"ul\", \"sl-poll-options\");\n var actionsEl = el(\"div\", \"sl-poll-actions\");\n root.append(heading2, listEl, actionsEl, statusEl);\n renderPollSkeleton();\n var selected = {};\n var multiVoteBtn = null;\n var revealed = false;\n var currentPoll = null;\n function renderPollSkeleton() {\n listEl.replaceChildren();\n for (var i = 0; i < 3; i += 1) {\n var li = el(\"li\", \"sl-poll-option\");\n li.appendChild(el(\"div\", \"sl-sk sl-sk-bar\"));\n listEl.appendChild(li);\n }\n }\n function setStatus2(message, kind) {\n statusEl.replaceChildren();\n statusEl.removeAttribute(\"data-kind\");\n if (kind === \"ok\") {\n statusEl.setAttribute(\"data-kind\", \"ok\");\n statusEl.appendChild(el(\"span\", null, \"\\u2713\"));\n } else if (kind === \"err\") {\n statusEl.setAttribute(\"data-kind\", \"err\");\n statusEl.appendChild(el(\"span\", null, \"\\u2715\"));\n } else if (kind === \"busy\") {\n statusEl.appendChild(el(\"span\", \"sl-spinner\"));\n }\n statusEl.appendChild(document.createTextNode(message));\n }\n function animateValue(node, to, suffix) {\n var dur = 520, start = null;\n function step(ts) {\n if (!start) start = ts;\n var p = Math.min(1, (ts - start) / dur);\n var eased = p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2;\n node.textContent = Math.round(to * eased) + (suffix || \"\");\n if (p < 1) requestAnimationFrame(step);\n }\n requestAnimationFrame(step);\n }\n function renderPoll(poll) {\n currentPoll = poll;\n qEl.textContent = poll.question || \"\";\n totalChip.textContent = pollCountLabel(Number(poll.total) || 0);\n var opts2 = Array.isArray(poll.options) ? poll.options : [];\n var total = Number(poll.total) || 0;\n var showResults = poll.status !== \"open\" || !!poll.voted || pollResults === \"always\" || revealed;\n listEl.replaceChildren();\n actionsEl.replaceChildren();\n multiVoteBtn = null;\n var rows = opts2.map(function(o) {\n return { option: o, count: poll.counts && poll.counts[o] ? Number(poll.counts[o]) : 0 };\n });\n if (showResults) {\n rows = rows.slice().sort(function(a, b) {\n return b.count - a.count || opts2.indexOf(a.option) - opts2.indexOf(b.option);\n });\n }\n var leader = showResults && rows.length > 0 && rows[0].count > (rows[1] ? rows[1].count : 0);\n rows.forEach(function(r, i) {\n var o = r.option, c = r.count;\n var pct = total > 0 ? Math.round(c / total * 100) : 0;\n var li = el(\"li\", \"sl-poll-option\" + (showResults && leader && i === 0 ? \" sl-poll-leader\" : \"\"));\n if (showResults) {\n if (pollStyle !== \"counts\") {\n var label = el(\"div\", \"sl-poll-opt-label\");\n var meta = el(\"span\", \"sl-poll-opt-meta\", \"\");\n if (pollStyle === \"percent\") animateValue(meta, pct, \"%\");\n else if (pollStyle === \"minimal\") meta.textContent = \"\";\n else animateValue(meta, c, \" \\xB7 \" + pct + \"%\");\n label.append(el(\"span\", \"sl-poll-rank\", \"#\" + (i + 1)), el(\"span\", \"sl-poll-opt-name\", o), meta);\n var bar = el(\"div\", \"sl-poll-bar\");\n var fill = el(\"div\", \"sl-poll-fill\");\n fill.style.width = \"0%\";\n fill.style.transitionDelay = i * 90 + \"ms\";\n bar.appendChild(fill);\n li.append(label, bar);\n requestAnimationFrame(function() {\n requestAnimationFrame(function() {\n fill.style.width = pct + \"%\";\n });\n });\n } else {\n var cLabel = el(\"div\", \"sl-poll-opt-label\");\n var cMeta = el(\"span\", \"sl-poll-opt-meta\", \"\");\n animateValue(cMeta, c, \"\");\n cLabel.append(el(\"span\", \"sl-poll-rank\", \"#\" + (i + 1)), el(\"span\", \"sl-poll-opt-name\", o), cMeta);\n li.appendChild(cLabel);\n }\n } else {\n var btn = el(\"button\", \"sl-poll-btn\" + (poll.multi ? \" sl-poll-check\" : \"\"), o);\n btn.type = \"button\";\n if (poll.multi) {\n btn.setAttribute(\"aria-pressed\", \"false\");\n btn.addEventListener(\"click\", function() {\n toggleSelect(o, btn);\n });\n } else {\n btn.addEventListener(\"click\", function() {\n vote([o], btn, false);\n });\n }\n li.appendChild(btn);\n }\n listEl.appendChild(li);\n });\n if (showResults) {\n if (leader) {\n var gap = rows[0].count - (rows[1] ? rows[1].count : 0);\n actionsEl.appendChild(el(\"p\", \"sl-poll-lead-info\", t(\"pollLeader\").replace(\"{n}\", String(gap))));\n }\n if (poll.multi && poll.singleVote && poll.status === \"open\" && poll.voted) {\n var changeBtn = el(\"button\", \"sl-poll-vote-btn\", t(\"pollChangeVotes\"));\n changeBtn.type = \"button\";\n changeBtn.addEventListener(\"click\", changeVotes);\n actionsEl.appendChild(changeBtn);\n }\n if (revealed && !poll.voted && poll.status === \"open\") {\n setStatus2(t(\"resultsPreview\"), void 0);\n var backBtn = el(\"button\", \"sl-poll-results-link\", t(\"backToVote\"));\n backBtn.type = \"button\";\n backBtn.addEventListener(\"click\", function() {\n revealed = false;\n renderPoll(currentPoll);\n });\n actionsEl.appendChild(backBtn);\n } else {\n setStatus2(poll.status !== \"open\" ? t(\"closed\") : t(\"voted\"), poll.status !== \"open\" ? void 0 : \"ok\");\n }\n } else {\n if (poll.multi) {\n multiVoteBtn = el(\"button\", \"sl-poll-vote-btn\", t(\"pollVoteBtn\").replace(\"{n}\", \"0\"));\n multiVoteBtn.type = \"button\";\n multiVoteBtn.disabled = true;\n multiVoteBtn.addEventListener(\"click\", function() {\n submitMulti();\n });\n actionsEl.appendChild(multiVoteBtn);\n actionsEl.appendChild(el(\"p\", \"sl-poll-multi-hint\", t(\"pollMultiHint\")));\n }\n var resultsLink = el(\"button\", \"sl-poll-results-link\", t(\"viewResults\"));\n resultsLink.type = \"button\";\n resultsLink.addEventListener(\"click\", function() {\n revealed = true;\n renderPoll(currentPoll);\n });\n actionsEl.appendChild(resultsLink);\n setStatus2(\"\");\n }\n }\n function toggleSelect(o, btn) {\n if (selected[o]) {\n delete selected[o];\n btn.classList.remove(\"selected\");\n btn.setAttribute(\"aria-pressed\", \"false\");\n } else {\n selected[o] = true;\n btn.classList.add(\"selected\");\n btn.setAttribute(\"aria-pressed\", \"true\");\n }\n var n = Object.keys(selected).length;\n if (multiVoteBtn) {\n multiVoteBtn.disabled = n === 0;\n multiVoteBtn.textContent = t(\"pollVoteBtn\").replace(\"{n}\", String(n));\n }\n }\n function submitMulti() {\n var opts2 = Object.keys(selected);\n if (!opts2.length) return;\n vote(opts2, multiVoteBtn, true);\n }\n function changeVotes() {\n setStatus2(t(\"solving\"), \"busy\");\n fetch(endpoint + \"/api/polls/revoke\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ pollId, voterToken })\n }).then(function(res) {\n return res.json().then(function(d) {\n return { ok: res.ok, data: d };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n selected = {};\n setStatus2(t(\"pollVotesChanged\"), \"ok\");\n renderPoll(outcome.data.poll);\n } else {\n setStatus2(\"Error: \" + (outcome.data && outcome.data.error ? outcome.data.error : \"unknown\"), \"err\");\n }\n }).catch(function(err) {\n setStatus2(\"Error: \" + err.message, \"err\");\n });\n }\n function findIn(data) {\n var found = null;\n (Array.isArray(data.polls) ? data.polls : []).forEach(function(p) {\n if (p.id === pollId) found = p;\n });\n return found;\n }\n function loadPoll() {\n var suffix = voterToken ? \"&voterToken=\" + encodeURIComponent(voterToken) : \"\";\n return fetch(endpoint + \"/api/polls?article_path=\" + encodeURIComponent(articlePath) + suffix).then(function(res) {\n if (!res.ok) throw new Error(t(\"pollLoadError\"));\n return res.json();\n }).then(function(data) {\n var found = findIn(data);\n if (found) {\n renderPoll(found);\n return;\n }\n return fetch(endpoint + \"/api/polls?id=\" + encodeURIComponent(pollId) + suffix).then(function(res2) {\n if (!res2.ok) throw new Error(t(\"pollLoadError\"));\n return res2.json();\n }).then(function(data2) {\n var g = findIn(data2);\n if (g) {\n renderPoll(g);\n return;\n }\n qEl.textContent = t(\"pollMissing\");\n });\n }).catch(function() {\n setStatus2(t(\"pollLoadError\"), \"err\");\n });\n }\n function vote(options, btn, multi) {\n if (btn) {\n btn.disabled = true;\n btn.classList.add(\"sl-pop\");\n }\n setStatus2(t(\"solving\"), \"busy\");\n var t0 = Date.now();\n fetch(\n endpoint + \"/api/polls/challenge?hostContext=\" + encodeURIComponent(hostContext) + \"&articlePath=\" + encodeURIComponent(articlePath)\n ).then(function(res) {\n if (!res.ok) throw new Error(\"challenge request failed\");\n return res.json();\n }).then(function(challenge) {\n var solveArg = multi ? options : options[0];\n return solveWithWorker(challenge, \"poll\", pollId, solveArg).then(function(nonce) {\n return { challenge, nonce };\n });\n }).then(function(solved) {\n return waitForGate(t0).then(function() {\n var payload = {\n challengeId: solved.challenge.challengeId,\n hostContext: solved.challenge.hostContext,\n articlePath: solved.challenge.articlePath,\n pollId,\n difficulty: solved.challenge.difficulty,\n expiresAt: solved.challenge.expiresAt,\n signature: solved.challenge.signature,\n nonce: solved.nonce\n };\n if (multi) payload.options = options;\n else payload.option = options[0];\n if (voterToken) payload.voterToken = voterToken;\n return fetch(endpoint + \"/api/polls/vote\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify(payload)\n });\n });\n }).then(function(res) {\n return res.json().then(function(data) {\n return { ok: res.ok, status: res.status, data };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n if (outcome.data.voterToken) {\n voterToken = outcome.data.voterToken;\n try {\n localStorage.setItem(voterKey, voterToken);\n } catch (e) {\n }\n }\n selected = {};\n var updatedPoll = outcome.data.poll;\n if (updatedPoll) updatedPoll.voted = !!outcome.data.voted;\n renderPoll(updatedPoll);\n } else if (outcome.status === 409) {\n setStatus2(t(\"alreadyVoted\"), \"err\");\n loadPoll();\n } else {\n setStatus2(\"Error: \" + (outcome.data && outcome.data.error ? outcome.data.error : \"unknown\"), \"err\");\n }\n }).catch(function(err) {\n setStatus2(\"Error: \" + err.message, \"err\");\n }).finally(function() {\n if (btn) btn.disabled = false;\n });\n }\n loadPoll();\n }\n }\n function unmount(root) {\n if (!root) return;\n root.__slMounted = false;\n root.classList.remove(\"sl-root\");\n root.replaceChildren();\n }\n function initAll() {\n injectStyles();\n var roots = document.querySelectorAll(\"[data-staticlayer]\");\n for (var i = 0; i < roots.length; i += 1) mount(roots[i]);\n if (typeof window !== \"undefined\") {\n window.StaticLayer = { mount, unmount };\n }\n }\n if (document.readyState === \"loading\") {\n document.addEventListener(\"DOMContentLoaded\", initAll);\n } else {\n initAll();\n }\n })();\n})();\n"; export const POW_WORKER_JS: string = "(() => {\n // ../protocol/dist/constants.js\n var PROTOCOL_VERSION = 1;\n var CHALLENGE_ID_BYTES = 32;\n var NONCE_BYTES = 8;\n var UINT64_MAX = (1n << 64n) - 1n;\n var MAX_HOST_CONTEXT_BYTES = 255;\n var MAX_ARTICLE_PATH_BYTES = 255;\n var MAX_NICKNAME_BYTES = 50;\n var MAX_BODY_BYTES = 3e3;\n var MAX_POLL_ID_BYTES = 64;\n var MAX_OPTION_BYTES = 100;\n var MAX_POLL_OPTIONS = 10;\n var MAX_COMMENT_ID_BYTES = 64;\n var MIN_DIFFICULTY = 0;\n var MAX_DIFFICULTY = 256;\n\n // ../protocol/dist/errors.js\n var ProtocolError = class extends Error {\n constructor(message) {\n super(message);\n this.name = \"ProtocolError\";\n }\n };\n\n // ../protocol/dist/utf8.js\n var encoder = new TextEncoder();\n var decoder = new TextDecoder(\"utf-8\", { fatal: true });\n function hasUnpairedSurrogate(s) {\n for (let i = 0; i < s.length; i++) {\n const code = s.charCodeAt(i);\n if (code >= 55296 && code <= 56319) {\n const next = s.charCodeAt(i + 1);\n if (!(next >= 56320 && next <= 57343))\n return true;\n i++;\n } else if (code >= 56320 && code <= 57343) {\n return true;\n }\n }\n return false;\n }\n function utf8EncodeStrict(s) {\n if (hasUnpairedSurrogate(s)) {\n throw new ProtocolError(\"Invalid UTF-8: input contains an unpaired surrogate\");\n }\n return encoder.encode(s);\n }\n\n // ../protocol/dist/encoding.js\n function encodeCanonicalPayload(p) {\n if (p.version !== PROTOCOL_VERSION) {\n throw new ProtocolError(`Unsupported protocol version: ${p.version}`);\n }\n const host = utf8EncodeStrict(p.hostContext);\n const path = utf8EncodeStrict(p.articlePath);\n const nick = utf8EncodeStrict(p.nickname);\n const body = utf8EncodeStrict(p.body);\n assertByteLen(\"hostContext\", host.length, MAX_HOST_CONTEXT_BYTES);\n assertByteLen(\"articlePath\", path.length, MAX_ARTICLE_PATH_BYTES);\n assertByteLen(\"nickname\", nick.length, MAX_NICKNAME_BYTES);\n assertByteLen(\"body\", body.length, MAX_BODY_BYTES);\n if (p.challengeId.length !== CHALLENGE_ID_BYTES) {\n throw new ProtocolError(`challengeId must be exactly ${CHALLENGE_ID_BYTES} bytes, got ${p.challengeId.length}`);\n }\n if (p.nonce < 0n || p.nonce > UINT64_MAX) {\n throw new ProtocolError(\"nonce out of uint64 range\");\n }\n const total = 1 + 2 + host.length + 2 + path.length + 2 + nick.length + 4 + body.length + CHALLENGE_ID_BYTES + NONCE_BYTES;\n const out = new Uint8Array(total);\n const view = new DataView(out.buffer);\n let o = 0;\n view.setUint8(o, p.version);\n o += 1;\n view.setUint16(o, host.length, false);\n o += 2;\n out.set(host, o);\n o += host.length;\n view.setUint16(o, path.length, false);\n o += 2;\n out.set(path, o);\n o += path.length;\n view.setUint16(o, nick.length, false);\n o += 2;\n out.set(nick, o);\n o += nick.length;\n view.setUint32(o, body.length, false);\n o += 4;\n out.set(body, o);\n o += body.length;\n out.set(p.challengeId, o);\n o += CHALLENGE_ID_BYTES;\n view.setBigUint64(o, p.nonce, false);\n o += NONCE_BYTES;\n if (o !== total) {\n throw new ProtocolError(`internal encoding error: wrote ${o} of ${total} bytes`);\n }\n return out;\n }\n function encodeCanonicalPollPayload(p) {\n if (p.version !== PROTOCOL_VERSION) {\n throw new ProtocolError(`Unsupported protocol version: ${p.version}`);\n }\n const host = utf8EncodeStrict(p.hostContext);\n const path = utf8EncodeStrict(p.articlePath);\n const pollId = utf8EncodeStrict(p.pollId);\n const option = utf8EncodeStrict(p.option);\n assertByteLen(\"hostContext\", host.length, MAX_HOST_CONTEXT_BYTES);\n assertByteLen(\"articlePath\", path.length, MAX_ARTICLE_PATH_BYTES);\n assertByteLen(\"pollId\", pollId.length, MAX_POLL_ID_BYTES);\n assertByteLen(\"option\", option.length, MAX_OPTION_BYTES);\n if (p.challengeId.length !== CHALLENGE_ID_BYTES) {\n throw new ProtocolError(`challengeId must be exactly ${CHALLENGE_ID_BYTES} bytes, got ${p.challengeId.length}`);\n }\n if (p.nonce < 0n || p.nonce > UINT64_MAX) {\n throw new ProtocolError(\"nonce out of uint64 range\");\n }\n const total = 1 + 2 + host.length + 2 + path.length + 2 + pollId.length + 2 + option.length + CHALLENGE_ID_BYTES + NONCE_BYTES;\n const out = new Uint8Array(total);\n const view = new DataView(out.buffer);\n let o = 0;\n view.setUint8(o, p.version);\n o += 1;\n view.setUint16(o, host.length, false);\n o += 2;\n out.set(host, o);\n o += host.length;\n view.setUint16(o, path.length, false);\n o += 2;\n out.set(path, o);\n o += path.length;\n view.setUint16(o, pollId.length, false);\n o += 2;\n out.set(pollId, o);\n o += pollId.length;\n view.setUint16(o, option.length, false);\n o += 2;\n out.set(option, o);\n o += option.length;\n out.set(p.challengeId, o);\n o += CHALLENGE_ID_BYTES;\n view.setBigUint64(o, p.nonce, false);\n o += NONCE_BYTES;\n if (o !== total) {\n throw new ProtocolError(`internal poll encoding error: wrote ${o} of ${total} bytes`);\n }\n return out;\n }\n function sortPollOptions(options) {\n return [...options].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);\n }\n var ACTION_BYTE = { flag: 1, vote: 2 };\n function encodeCanonicalCommentActionPayload(p) {\n if (p.version !== PROTOCOL_VERSION) {\n throw new ProtocolError(`Unsupported protocol version: ${p.version}`);\n }\n const host = utf8EncodeStrict(p.hostContext);\n const path = utf8EncodeStrict(p.articlePath);\n const commentId = utf8EncodeStrict(p.commentId);\n const actionByte = ACTION_BYTE[p.action];\n assertByteLen(\"hostContext\", host.length, MAX_HOST_CONTEXT_BYTES);\n assertByteLen(\"articlePath\", path.length, MAX_ARTICLE_PATH_BYTES);\n assertByteLen(\"commentId\", commentId.length, MAX_COMMENT_ID_BYTES);\n if (p.challengeId.length !== CHALLENGE_ID_BYTES) {\n throw new ProtocolError(`challengeId must be exactly ${CHALLENGE_ID_BYTES} bytes, got ${p.challengeId.length}`);\n }\n if (p.nonce < 0n || p.nonce > UINT64_MAX) {\n throw new ProtocolError(\"nonce out of uint64 range\");\n }\n const total = 1 + 1 + 2 + host.length + 2 + path.length + 2 + commentId.length + CHALLENGE_ID_BYTES + NONCE_BYTES;\n const out = new Uint8Array(total);\n const view = new DataView(out.buffer);\n let o = 0;\n view.setUint8(o, p.version);\n o += 1;\n view.setUint8(o, actionByte);\n o += 1;\n view.setUint16(o, host.length, false);\n o += 2;\n out.set(host, o);\n o += host.length;\n view.setUint16(o, path.length, false);\n o += 2;\n out.set(path, o);\n o += path.length;\n view.setUint16(o, commentId.length, false);\n o += 2;\n out.set(commentId, o);\n o += commentId.length;\n out.set(p.challengeId, o);\n o += CHALLENGE_ID_BYTES;\n view.setBigUint64(o, p.nonce, false);\n o += NONCE_BYTES;\n if (o !== total) {\n throw new ProtocolError(`internal comment-action encoding error: wrote ${o} of ${total} bytes`);\n }\n return out;\n }\n function encodeCanonicalPollPayloadMulti(p) {\n if (p.version !== PROTOCOL_VERSION) {\n throw new ProtocolError(`Unsupported protocol version: ${p.version}`);\n }\n const host = utf8EncodeStrict(p.hostContext);\n const path = utf8EncodeStrict(p.articlePath);\n const pollId = utf8EncodeStrict(p.pollId);\n if (!Array.isArray(p.options) || p.options.length < 1 || p.options.length > MAX_POLL_OPTIONS) {\n throw new ProtocolError(`options must be 1..${MAX_POLL_OPTIONS} strings`);\n }\n const sorted = sortPollOptions(p.options);\n const opts = sorted.map((o2) => utf8EncodeStrict(o2));\n assertByteLen(\"hostContext\", host.length, MAX_HOST_CONTEXT_BYTES);\n assertByteLen(\"articlePath\", path.length, MAX_ARTICLE_PATH_BYTES);\n assertByteLen(\"pollId\", pollId.length, MAX_POLL_ID_BYTES);\n for (const o2 of opts)\n assertByteLen(\"option\", o2.length, MAX_OPTION_BYTES);\n if (p.challengeId.length !== CHALLENGE_ID_BYTES) {\n throw new ProtocolError(`challengeId must be exactly ${CHALLENGE_ID_BYTES} bytes, got ${p.challengeId.length}`);\n }\n if (p.nonce < 0n || p.nonce > UINT64_MAX) {\n throw new ProtocolError(\"nonce out of uint64 range\");\n }\n const total = 1 + 2 + host.length + 2 + path.length + 2 + pollId.length + 1 + opts.reduce((n, o2) => n + 2 + o2.length, 0) + CHALLENGE_ID_BYTES + NONCE_BYTES;\n const out = new Uint8Array(total);\n const view = new DataView(out.buffer);\n let o = 0;\n view.setUint8(o, p.version);\n o += 1;\n view.setUint16(o, host.length, false);\n o += 2;\n out.set(host, o);\n o += host.length;\n view.setUint16(o, path.length, false);\n o += 2;\n out.set(path, o);\n o += path.length;\n view.setUint16(o, pollId.length, false);\n o += 2;\n out.set(pollId, o);\n o += pollId.length;\n view.setUint8(o, opts.length);\n o += 1;\n for (const opt of opts) {\n view.setUint16(o, opt.length, false);\n o += 2;\n out.set(opt, o);\n o += opt.length;\n }\n out.set(p.challengeId, o);\n o += CHALLENGE_ID_BYTES;\n view.setBigUint64(o, p.nonce, false);\n o += NONCE_BYTES;\n if (o !== total) {\n throw new ProtocolError(`internal poll-multi encoding error: wrote ${o} of ${total} bytes`);\n }\n return out;\n }\n function serializeNonce(nonce) {\n if (nonce < 0n || nonce > UINT64_MAX) {\n throw new ProtocolError(\"nonce out of uint64 range\");\n }\n if (nonce <= BigInt(Number.MAX_SAFE_INTEGER)) {\n return Number(nonce);\n }\n return nonce.toString(10);\n }\n function assertByteLen(field, actual, max) {\n if (actual > max) {\n throw new ProtocolError(`${field} exceeds ${max} UTF-8 bytes (got ${actual})`);\n }\n }\n\n // ../protocol/dist/crypto.js\n var subtle = globalThis.crypto.subtle;\n async function sha256(data) {\n const digest = await subtle.digest(\"SHA-256\", data);\n return new Uint8Array(digest);\n }\n\n // ../protocol/dist/base64url.js\n var ALPHABET = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_\";\n var LOOKUP = buildLookup();\n function buildLookup() {\n const table = new Int16Array(128).fill(-1);\n for (let i = 0; i < ALPHABET.length; i++) {\n table[ALPHABET.charCodeAt(i)] = i;\n }\n return table;\n }\n function base64UrlToBytes(input) {\n if (input.length === 0)\n return new Uint8Array(0);\n if (input.includes(\"=\")) {\n throw new ProtocolError(\"base64url input must not contain padding\");\n }\n if (input.length % 4 === 1) {\n throw new ProtocolError(\"base64url input has invalid length\");\n }\n const out = [];\n let buffer = 0;\n let bits = 0;\n for (let i = 0; i < input.length; i++) {\n const code = input.charCodeAt(i);\n const value = code < 128 ? LOOKUP[code] : -1;\n if (value < 0) {\n throw new ProtocolError(`invalid base64url character at index ${i}`);\n }\n buffer = buffer << 6 | value;\n bits += 6;\n if (bits >= 8) {\n bits -= 8;\n out.push(buffer >> bits & 255);\n }\n }\n return new Uint8Array(out);\n }\n\n // ../protocol/dist/pow.js\n function leadingZeroBits(hash) {\n let bits = 0;\n for (const byte of hash) {\n if (byte === 0) {\n bits += 8;\n continue;\n }\n let b = byte;\n while ((b & 128) === 0) {\n bits += 1;\n b = b << 1 & 255;\n }\n break;\n }\n return bits;\n }\n function assertDifficulty(difficulty) {\n if (!Number.isInteger(difficulty) || difficulty < MIN_DIFFICULTY || difficulty > MAX_DIFFICULTY) {\n throw new ProtocolError(`difficulty must be an integer in [${MIN_DIFFICULTY}, ${MAX_DIFFICULTY}]`);\n }\n }\n async function verifyPow(canonicalPayload, difficulty) {\n assertDifficulty(difficulty);\n const digest = await sha256(canonicalPayload);\n return leadingZeroBits(digest) >= difficulty;\n }\n async function verifyPowFields(payload, difficulty) {\n return verifyPow(encodeCanonicalPayload(payload), difficulty);\n }\n async function mineNonce(base, difficulty, options = {}) {\n assertDifficulty(difficulty);\n const start = options.startNonce ?? 0n;\n const maxAttempts = options.maxAttempts ?? 1n << 32n;\n if (start < 0n || start > UINT64_MAX) {\n throw new ProtocolError(\"startNonce out of uint64 range\");\n }\n let nonce = start;\n for (let i = 0n; i < maxAttempts; i++) {\n if (nonce > UINT64_MAX)\n break;\n const payload = encodeCanonicalPayload({ ...base, nonce });\n const digest = await sha256(payload);\n if (leadingZeroBits(digest) >= difficulty) {\n return nonce;\n }\n nonce += 1n;\n }\n throw new ProtocolError(`mineNonce: no nonce satisfying difficulty ${difficulty} within ${maxAttempts} attempts`);\n }\n async function minePollNonce(base, difficulty, options = {}) {\n assertDifficulty(difficulty);\n const start = options.startNonce ?? 0n;\n const maxAttempts = options.maxAttempts ?? 1n << 32n;\n if (start < 0n || start > UINT64_MAX) {\n throw new ProtocolError(\"startNonce out of uint64 range\");\n }\n let nonce = start;\n for (let i = 0n; i < maxAttempts; i++) {\n if (nonce > UINT64_MAX)\n break;\n const payload = encodeCanonicalPollPayload({ ...base, nonce });\n const digest = await sha256(payload);\n if (leadingZeroBits(digest) >= difficulty) {\n return nonce;\n }\n nonce += 1n;\n }\n throw new ProtocolError(`minePollNonce: no nonce satisfying difficulty ${difficulty} within ${maxAttempts} attempts`);\n }\n async function minePollNonceMulti(base, difficulty, options = {}) {\n assertDifficulty(difficulty);\n const start = options.startNonce ?? 0n;\n const maxAttempts = options.maxAttempts ?? 1n << 32n;\n if (start < 0n || start > UINT64_MAX) {\n throw new ProtocolError(\"startNonce out of uint64 range\");\n }\n let nonce = start;\n for (let i = 0n; i < maxAttempts; i++) {\n if (nonce > UINT64_MAX)\n break;\n const payload = encodeCanonicalPollPayloadMulti({ ...base, nonce });\n const digest = await sha256(payload);\n if (leadingZeroBits(digest) >= difficulty) {\n return nonce;\n }\n nonce += 1n;\n }\n throw new ProtocolError(`minePollNonceMulti: no nonce satisfying difficulty ${difficulty} within ${maxAttempts} attempts`);\n }\n async function mineCommentActionNonce(base, difficulty, options = {}) {\n assertDifficulty(difficulty);\n const start = options.startNonce ?? 0n;\n const maxAttempts = options.maxAttempts ?? 1n << 32n;\n if (start < 0n || start > UINT64_MAX) {\n throw new ProtocolError(\"startNonce out of uint64 range\");\n }\n let nonce = start;\n for (let i = 0n; i < maxAttempts; i++) {\n if (nonce > UINT64_MAX)\n break;\n const payload = encodeCanonicalCommentActionPayload({ ...base, nonce });\n const digest = await sha256(payload);\n if (leadingZeroBits(digest) >= difficulty) {\n return nonce;\n }\n nonce += 1n;\n }\n throw new ProtocolError(`mineCommentActionNonce: no nonce satisfying difficulty ${difficulty} within ${maxAttempts} attempts`);\n }\n\n // src/pow-worker.js\n self.onmessage = async function(event) {\n var msg = event.data || {};\n var challenge = msg.challenge;\n try {\n if (!challenge || typeof challenge.challengeId !== \"string\") {\n throw new Error(\"malformed solve request\");\n }\n var nonce;\n if (typeof msg.pollId === \"string\" && Array.isArray(msg.options)) {\n var mbase = {\n version: PROTOCOL_VERSION,\n hostContext: challenge.hostContext,\n articlePath: challenge.articlePath,\n pollId: msg.pollId,\n options: sortPollOptions(msg.options),\n challengeId: base64UrlToBytes(challenge.challengeId)\n };\n nonce = await minePollNonceMulti(mbase, challenge.difficulty);\n if (!await verifyPow(encodeCanonicalPollPayloadMulti({ ...mbase, nonce }), challenge.difficulty)) {\n throw new Error(\"internal: mined nonce failed verification\");\n }\n } else if (typeof msg.pollId === \"string\" && typeof msg.option === \"string\") {\n var pbase = {\n version: PROTOCOL_VERSION,\n hostContext: challenge.hostContext,\n articlePath: challenge.articlePath,\n pollId: msg.pollId,\n option: msg.option,\n challengeId: base64UrlToBytes(challenge.challengeId)\n };\n nonce = await minePollNonce(pbase, challenge.difficulty);\n if (!await verifyPow(encodeCanonicalPollPayload({ ...pbase, nonce }), challenge.difficulty)) {\n throw new Error(\"internal: mined nonce failed verification\");\n }\n } else if (typeof msg.action === \"string\" && typeof msg.commentId === \"string\") {\n var cbase = {\n version: PROTOCOL_VERSION,\n hostContext: challenge.hostContext,\n articlePath: challenge.articlePath,\n action: msg.action === \"vote\" ? \"vote\" : \"flag\",\n commentId: msg.commentId,\n challengeId: base64UrlToBytes(challenge.challengeId)\n };\n nonce = await mineCommentActionNonce(cbase, challenge.difficulty);\n if (!await verifyPow(encodeCanonicalCommentActionPayload({ ...cbase, nonce }), challenge.difficulty)) {\n throw new Error(\"internal: mined nonce failed verification\");\n }\n } else {\n var nickname = msg.nickname;\n var body = msg.body;\n if (typeof nickname !== \"string\" || typeof body !== \"string\") {\n throw new Error(\"malformed solve request\");\n }\n var base = {\n version: PROTOCOL_VERSION,\n hostContext: challenge.hostContext,\n articlePath: challenge.articlePath,\n nickname,\n body,\n challengeId: base64UrlToBytes(challenge.challengeId)\n };\n nonce = await mineNonce(base, challenge.difficulty);\n if (!await verifyPowFields({ ...base, nonce }, challenge.difficulty)) {\n throw new Error(\"internal: mined nonce failed verification\");\n }\n }\n self.postMessage({ type: \"nonce\", nonce: serializeNonce(nonce) });\n } catch (err) {\n self.postMessage({ type: \"error\", message: err instanceof Error ? err.message : String(err) });\n }\n };\n})();\n"; -export const ADMIN_HTML: string = "\n\n\n\n\nStaticLayer — Admin\n\n\n\n
\n\n \n
\n
\n
\n \"\"\n
\n

StaticLayer

Moderation console

\n
\n
\n \n \n \n \n
\n
\n
or
\n \n
\n

Sign in with GitHub (password-less)

\n

Free, no credit card, no Zero Trust plan. Create a GitHub OAuth App, set 4 Worker vars once, and from then on this button signs you in without a password.

\n
    \n
  1. GitHub → Settings → Developer settings → OAuth Apps → New OAuth App.
  2. \n
  3. Homepage URL: …
  4. \n
  5. Authorization callback URL: …
  6. \n
  7. Set GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ADMIN_IDS (your GitHub numeric id) on the Worker and re-deploy.
  8. \n
\n

Full step-by-step guide: sign in with your password once → Settings → Admin access & sign-in.

\n
\n

\n
\n\n \n
\n
\n
\n
\n \"\"\n
\n

StaticLayer

Moderation console

\n
\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n \n
\n\n \n
\n

Moderation queue

\n

Comments awaiting approval — search, filter and moderate.

\n
\n
\n \n \n
\n
\n 0 selected\n \n \n
\n
    \n
    \n

    \n
    \n\n \n
    \n

    Published comments

    \n

    Already public — unapprove or delete.

    \n
    \n
    \n \n \n
    \n
    \n 0 selected\n \n \n
    \n
      \n
      \n

      \n
      \n\n \n
      \n

      Pages with comments

      \n

      Every page that received activity on this Worker — click to filter the queue.

      \n
        \n

        \n
        \n\n \n
        \n

        Allow & block lists

        \n

        Blocked nicknames cannot comment. Allowlisted nicknames skip the queue (and in allowlist mode only they can comment). Matched case-insensitively.

        \n
        \n
        \n

        Allowlist

        \n

        Auto-approved — skip moderation

        \n
        \n \n \n
        \n
        \n
        \n
        \n

        Blocklist

        \n

        Banned — cannot comment (ban a nickname)

        \n
        \n \n \n
        \n
        \n
        \n
        \n

        Blocked terms (word blacklist)

        \n

        Comments containing any of these words are auto-rejected and never stored

        \n
        \n \n \n
        \n
        \n
        \n
        \n

        \n
        \n\n \n
        \n

        Settings

        \n

        Live settings — applied immediately, no redeploy needed. Env-var defaults are used until you override them here.

        \n
        \n
        \n \n \n

        Higher = harder for bots (and slightly slower for visitors). Range 0–256 · default 16.

        \n
        \n
        \n \n \n

        Comma-separated. Empty disables reactions.

        \n
        \n
        \n \n \n

        Private/community sites can lock comments to approved members.

        \n
        \n
        \n \n \n

        Shown as the author of your admin replies, with an \"Author\" badge on the widget.

        \n
        \n
        \n \n \n
        \n
        \n
        \n
        \n

        Admin access & sign-in

        \n

        Password-less login with GitHub OAuth is built in and free — no Cloudflare Zero Trust plan, no credit card, and no password to remember after the first setup. Create a GitHub OAuth App once, set 4 Worker vars, and the login screen shows \"Sign in with GitHub\". Your admin password stays as a fallback and is never sent to GitHub.

        \n

        Lost the admin password? Once GitHub sign-in is configured, just click \"Sign in with GitHub\". Otherwise rotate it: npx wrangler secret put ADMIN_SECRET (you choose the new value) — or reinstall fresh. Re-running the installer preserves your existing secrets (including the current password) by design.

        \n
        \n

        Set up GitHub sign-in — step by step

        \n
          \n
        1. Open github.com → your avatar → Settings → Developer settings (bottom of the sidebar) → OAuth Apps → New OAuth App.
        2. \n
        3. Application name: anything, e.g. \"StaticLayer admin\".
        4. \n
        5. Homepage URL: …
        6. \n
        7. Authorization callback URL: … — this exact URL.
        8. \n
        9. Click Register application, then copy the Client ID shown on the page.
        10. \n
        11. Click Generate a new client secret, then copy the Client secret (shown only once).
        12. \n
        13. Find your GitHub numeric user id: open https://api.github.com/users/your-login — copy the \"id\" number (e.g. 1234567).
        14. \n
        15. Set the Worker vars: GITHUB_CLIENT_ID = the Client ID, GITHUB_ADMIN_IDS = your numeric id (or GITHUB_ADMIN_LOGINS = your login). Set GITHUB_CLIENT_SECRET as a secret: npx wrangler secret put GITHUB_CLIENT_SECRET (or in the dashboard: your Worker → Settings → Variables → Edit → add as a secret).
        16. \n
        17. Re-deploy (hosted installer or npx wrangler deploy), then refresh this page: the login shows \"Sign in with GitHub\". Only accounts in your allowlist can open the console.
        18. \n
        \n

        Privacy: GitHub sees only your login — the identity provider you chose. The token is used once to read your id and is then discarded, never stored. No visitor data ever leaves your Worker.

        \n
        \n
        \n
        \n\n
        \n
        \n

        Telegram alerts

        \n

        Get a private notification on Telegram when there is new activity on your site. Privacy-first: the message only says which page the activity happened on — comment text, nicknames and poll options are never sent to Telegram. Your bot token and chat id are stored only in your own private D1 database.

        \n
        \n \n \n
        \n
        \n \n
        \n \n \n \n
        \n

        Choose which activity triggers an alert. The message includes the page link — never the content itself.

        \n
        \n
        \n \n \n

        Create your bot: open Telegram → search @BotFather → /newbot → choose a name and a username → copy the token it sends you.

        \n
        \n
        \n \n \n

        Find your chat id: message your bot once (e.g. \"hi\"), then open https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates in a browser and copy the number in \"chat\":{\"id\":123456789,…} (channels/groups have a leading minus).

        \n
        \n
        \n \n \n
        \n

        Tip: press Save settings first, then Send test message to verify instantly. If it fails, Telegram shows the exact reason below.

        \n

        Then press Save settings and trigger one of the enabled activities on your site (a comment, poll vote or reaction): you'll get the notification.

        \n
        \n
        \n
        \n
        \n

        Export your data (GDPR portability)

        \n

        Download everything your visitors wrote — you are the data controller and can move your data anytime. Exports never include IPs, cookies or tracking data.

        \n
        \n \n \n
        \n

        \n
        \n
        \n

        \n
        \n
        \n\n \n
        \n

        Widget builder

        \n

        Design your comment / reaction widget and copy the embed snippet into any page of your site.

        \n\n
        \n
        \n

        How it works

        \n

        One snippet, any page. Paste it wherever you want the widget to appear (end of the article, before the footer…).

        \n
        1
        Pick the mode and options below — the snippet updates live.
        \n
        2
        Press Copy snippet.
        \n
        3
        In your site editor, insert an HTML / embed block and paste it. Save & publish — done.
        \n
        \n
        \n

        Integrating on a specific page

        \n
          \n
        1. Open the target page in your site builder and add an HTML block.
        2. \n
        3. Paste the snippet from the box below.
        4. \n
        5. Set data-article-path to that page's path (e.g. /blog/hello-world) — or leave it empty and the widget uses the page's own URL automatically.
        6. \n
        7. Publish. The widget auto-loads — no other setup.
        8. \n
        \n

        💡 The widget talks to …. If your site is on a different domain, that domain must be in the Worker's allowed origins (set during installation) or the browser will block the requests.

        \n
        \n
        \n\n
        \n
        \n

        Content

        \n
        \n \n
        \n \n \n \n
        \n
        \n
        \n \n \n
        \n
        \n \n \n

        Where the whole reactions bar sits relative to the comments.

        \n
        \n
        \n \n \n

        Tip: set this to the exact page path (e.g. /blog/hello-world) so each page has its own comment thread.

        \n
        \n
        \n \n \n

        Pick a poll to embed a standalone poll instead of comments. Create polls in the Polls tab.

        \n
        \n
        \n \n \n

        How the results are shown after voting (or when the poll is closed).

        \n
        \n
        \n \n \n

        \"Always\" lets visitors see live results before they vote.

        \n
        \n
        \n\n
        \n

        Look & feel

        \n
        \n \n \n

        Pick a preset to auto-fill the options below (your manual edits are kept unless you change template again).

        \n
        \n
        \n \n \n
        \n
        \n \n \n
        \n
        \n \n
        \n \n \n
        \n
        \n
        \n
        \n
        \n \n \n
        \n
        \n \n \n
        \n
        \n
        \n
        \n \n \n

        Keys: title, post, hint, empty, anonymous, pending, posted, emptyComment, nickPlaceholder, bodyPlaceholder, solving, slowDown, countOne, countMany ({n} = number).

        \n
        \n
        \n
        \n\n
        \n

        Live preview

        \n

        Updates as you change the options above — this is exactly what your visitors will see (themes and colors apply live).

        \n
        \n

        \n
        \n\n
        \n

        Snippet — copy & paste into your page

        \n
        \n \n \n
        \n

        \n
        \n
        \n\n \n
        \n

        Polls

        \n

        Create StrawPoll-style polls for any page. Votes are anonymous (no IP, no cookies) and protected by proof-of-work; one vote per browser uses an anonymous in-browser token when enabled.

        \n
        \n
        \n

        New poll

        \n
        \n \n \n

        Leave empty for a global poll — shown on any page where you embed it. Set it to show the poll only on that article's page.

        \n
        \n
        \n \n \n
        \n
        \n \n \n
        \n
        \n \n One vote per browser — stores an anonymous token in the visitor's browser (no cookies, no personal data)\n
        \n
        \n \n Multi-select — visitors can pick several options and vote once for the whole set (single Proof-of-Work, one vote per option)\n
        \n
        \n Privacy & GDPR — read before enabling.\n
          \n
        • Enabling this makes the widget store a random, anonymous token in each visitor's browser (localStorage). It is not a cookie and contains no personal data; only a hash of it is kept in your own D1 — never sent to us.
        • \n
        • It is nevertheless a persistent local identifier: the widget uses it to recognise \"this browser already voted\" across visits. Under GDPR that is a form of identification, so inform visitors in your privacy policy if you use it.
        • \n
        • It does not guarantee \"one person = one vote\": a visitor can clear browser storage and vote again. It only raises the bar against casual double-voting.
        • \n
        • You remain the data controller for your site; StaticLayer never receives, stores or sees the token.
        • \n
        \n
        \n
        \n
        \n

        Live preview

        \n

        Updates as you type — this is what visitors see before voting.

        \n
        \n
        \n
        \n
        \n

        Your polls

        \n
        \n

        \n
        \n
        \n
        \n\n \n
        \n

        Updates

        \n

        Check whether a newer StaticLayer release is available for this Worker.

        \n
        \n

        Checking…

        \n
        \n

        \n
        \n \n\n
        StaticLayer · BYOC · Source-available (Elastic License 2.0)
        \n
        \n\n\n\n\n"; +export const ADMIN_HTML: string = "\n\n\n\n\nStaticLayer — Admin\n\n\n\n
        \n\n \n
        \n
        \n
        \n \"\"\n
        \n

        StaticLayer

        Moderation console

        \n
        \n
        \n \n \n \n \n
        \n
        \n
        or
        \n \n
        \n

        Sign in with GitHub (password-less)

        \n

        Free, no credit card, no Zero Trust plan. Create a GitHub OAuth App, set 4 Worker vars once, and from then on this button signs you in without a password.

        \n
          \n
        1. GitHub → Settings → Developer settings → OAuth Apps → New OAuth App.
        2. \n
        3. Homepage URL: …
        4. \n
        5. Authorization callback URL: …
        6. \n
        7. Set GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ADMIN_IDS (your GitHub numeric id) on the Worker and re-deploy.
        8. \n
        \n

        Full step-by-step guide: sign in with your password once → Settings → Admin access & sign-in.

        \n
        \n

        \n
        \n\n \n
        \n
        \n
        \n
        \n \"\"\n
        \n

        StaticLayer

        Moderation console

        \n
        \n
        \n \n
        \n\n
        \n \n \n \n \n \n \n \n \n \n \n
        \n\n \n
        \n

        Moderation queue

        \n

        Comments awaiting approval — search, filter and moderate.

        \n
        \n
        \n \n \n
        \n
        \n 0 selected\n \n \n
        \n
          \n
          \n

          \n
          \n\n \n
          \n

          Published comments

          \n

          Already public — unapprove or delete.

          \n
          \n
          \n \n \n
          \n
          \n 0 selected\n \n \n
          \n
            \n
            \n

            \n
            \n\n \n
            \n

            Pages with comments

            \n

            Every page that received activity on this Worker — click to filter the queue.

            \n
              \n

              \n
              \n\n \n
              \n

              Allow & block lists

              \n

              Blocked nicknames cannot comment. Allowlisted nicknames skip the queue (and in allowlist mode only they can comment). Matched case-insensitively.

              \n
              \n
              \n

              Allowlist

              \n

              Auto-approved — skip moderation

              \n
              \n \n \n
              \n
              \n
              \n
              \n

              Blocklist

              \n

              Banned — cannot comment (ban a nickname)

              \n
              \n \n \n
              \n
              \n
              \n
              \n

              Blocked terms (word blacklist)

              \n

              Comments containing any of these words are auto-rejected and never stored

              \n
              \n \n \n
              \n
              \n
              \n
              \n

              \n
              \n\n \n
              \n

              Settings

              \n

              Live settings — applied immediately, no redeploy needed. Env-var defaults are used until you override them here.

              \n
              \n
              \n \n \n

              Higher = harder for bots (and slightly slower for visitors). Range 0–256 · default 16.

              \n
              \n
              \n \n \n

              Comma-separated. Empty disables reactions.

              \n
              \n
              \n \n \n

              Private/community sites can lock comments to approved members.

              \n
              \n
              \n \n \n

              Shown as the author of your admin replies, with an \"Author\" badge on the widget.

              \n
              \n
              \n \n \n
              \n
              \n
              \n
              \n

              Admin access & sign-in

              \n

              Password-less login with GitHub OAuth is built in and free — no Cloudflare Zero Trust plan, no credit card, and no password to remember after the first setup. Create a GitHub OAuth App once, set 4 Worker vars, and the login screen shows \"Sign in with GitHub\". Your admin password stays as a fallback and is never sent to GitHub.

              \n

              Lost the admin password? Once GitHub sign-in is configured, just click \"Sign in with GitHub\". Otherwise rotate it: npx wrangler secret put ADMIN_SECRET (you choose the new value) — or reinstall fresh. Re-running the installer preserves your existing secrets (including the current password) by design.

              \n
              \n

              Set up GitHub sign-in — step by step

              \n
                \n
              1. Open github.com → your avatar → Settings → Developer settings (bottom of the sidebar) → OAuth Apps → New OAuth App.
              2. \n
              3. Application name: anything, e.g. \"StaticLayer admin\".
              4. \n
              5. Homepage URL: …
              6. \n
              7. Authorization callback URL: … — this exact URL.
              8. \n
              9. Click Register application, then copy the Client ID shown on the page.
              10. \n
              11. Click Generate a new client secret, then copy the Client secret (shown only once).
              12. \n
              13. Find your GitHub numeric user id: open https://api.github.com/users/your-login — copy the \"id\" number (e.g. 1234567).
              14. \n
              15. Set the Worker vars: GITHUB_CLIENT_ID = the Client ID, GITHUB_ADMIN_IDS = your numeric id (or GITHUB_ADMIN_LOGINS = your login). Set GITHUB_CLIENT_SECRET as a secret: npx wrangler secret put GITHUB_CLIENT_SECRET (or in the dashboard: your Worker → Settings → Variables → Edit → add as a secret).
              16. \n
              17. Re-deploy (hosted installer or npx wrangler deploy), then refresh this page: the login shows \"Sign in with GitHub\". Only accounts in your allowlist can open the console.
              18. \n
              \n

              Privacy: GitHub sees only your login — the identity provider you chose. The token is used once to read your id and is then discarded, never stored. No visitor data ever leaves your Worker.

              \n
              \n
              \n
              \n\n
              \n
              \n

              Telegram alerts

              \n

              Get a private notification on Telegram when there is new activity on your site. Privacy-first: the message only says which page the activity happened on — comment text, nicknames and poll options are never sent to Telegram. Your bot token and chat id are stored only in your own private D1 database.

              \n
              \n \n \n
              \n
              \n \n
              \n \n \n \n
              \n

              Choose which activity triggers an alert. The message includes the page link — never the content itself.

              \n
              \n
              \n \n \n

              Create your bot: open Telegram → search @BotFather → /newbot → choose a name and a username → copy the token it sends you.

              \n
              \n
              \n \n \n

              Find your chat id: message your bot once (e.g. \"hi\"), then open https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates in a browser and copy the number in \"chat\":{\"id\":123456789,…} (channels/groups have a leading minus).

              \n
              \n
              \n \n \n
              \n

              Tip: press Save settings first, then Send test message to verify instantly. If it fails, Telegram shows the exact reason below.

              \n

              Then press Save settings and trigger one of the enabled activities on your site (a comment, poll vote or reaction): you'll get the notification.

              \n
              \n
              \n
              \n
              \n

              Export your data (GDPR portability)

              \n

              Download everything your visitors wrote — you are the data controller and can move your data anytime. Exports never include IPs, cookies or tracking data.

              \n
              \n \n \n
              \n

              \n
              \n
              \n

              \n
              \n
              \n\n \n
              \n

              Widget builder

              \n

              Design your comment / reaction widget and copy the embed snippet into any page of your site.

              \n\n
              \n
              \n

              How it works

              \n

              One snippet, any page. Paste it wherever you want the widget to appear (end of the article, before the footer…).

              \n
              1
              Pick the mode and options below — the snippet updates live.
              \n
              2
              Press Copy snippet.
              \n
              3
              In your site editor, insert an HTML / embed block and paste it. Save & publish — done.
              \n
              \n
              \n

              Integrating on a specific page

              \n
                \n
              1. Open the target page in your site builder and add an HTML block.
              2. \n
              3. Paste the snippet from the box below.
              4. \n
              5. Set data-article-path to that page's path (e.g. /blog/hello-world) — or leave it empty and the widget uses the page's own URL automatically.
              6. \n
              7. Publish. The widget auto-loads — no other setup.
              8. \n
              \n

              💡 The widget talks to …. If your site is on a different domain, that domain must be in the Worker's allowed origins (set during installation) or the browser will block the requests.

              \n
              \n
              \n\n
              \n
              \n

              Content

              \n
              \n \n
              \n \n \n \n
              \n
              \n
              \n \n \n
              \n
              \n \n \n

              Where the whole reactions bar sits relative to the comments.

              \n
              \n
              \n \n \n

              Tip: set this to the exact page path (e.g. /blog/hello-world) so each page has its own comment thread.

              \n
              \n
              \n \n \n

              Pick a poll to embed a standalone poll instead of comments. Create polls in the Polls tab.

              \n
              \n
              \n \n \n

              How the results are shown after voting (or when the poll is closed).

              \n
              \n
              \n \n \n

              \"Always\" lets visitors see live results before they vote.

              \n
              \n
              \n\n
              \n

              Look & feel

              \n
              \n \n \n

              Pick a preset to auto-fill the options below (your manual edits are kept unless you change template again).

              \n
              \n
              \n \n \n
              \n
              \n \n \n
              \n
              \n \n
              \n \n \n
              \n
              \n
              \n
              \n
              \n \n \n
              \n
              \n \n \n
              \n
              \n
              \n
              \n \n \n

              Keys: title, post, hint, empty, anonymous, pending, posted, emptyComment, nickPlaceholder, bodyPlaceholder, solving, slowDown, countOne, countMany ({n} = number).

              \n
              \n
              \n
              \n\n
              \n

              Live preview

              \n

              Updates as you change the options above — this is exactly what your visitors will see (themes and colors apply live).

              \n
              \n

              \n
              \n\n
              \n

              Snippet — copy & paste into your page

              \n
              \n \n \n
              \n

              \n
              \n
              \n\n \n
              \n

              Polls

              \n

              Create StrawPoll-style polls for any page. Votes are anonymous (no IP, no cookies) and protected by proof-of-work; one vote per browser uses an anonymous in-browser token when enabled.

              \n
              \n
              \n

              New poll

              \n
              \n \n \n

              Leave empty for a global poll — shown on any page where you embed it. Set it to show the poll only on that article's page.

              \n
              \n
              \n \n \n
              \n
              \n \n \n
              \n
              \n \n One vote per browser — stores an anonymous token in the visitor's browser (no cookies, no personal data)\n
              \n
              \n \n Multi-select — visitors can pick several options and vote once for the whole set (single Proof-of-Work, one vote per option)\n
              \n
              \n Privacy & GDPR — read before enabling.\n
                \n
              • Enabling this makes the widget store a random, anonymous token in each visitor's browser (localStorage). It is not a cookie and contains no personal data; only a hash of it is kept in your own D1 — never sent to us.
              • \n
              • It is nevertheless a persistent local identifier: the widget uses it to recognise \"this browser already voted\" across visits. Under GDPR that is a form of identification, so inform visitors in your privacy policy if you use it.
              • \n
              • It does not guarantee \"one person = one vote\": a visitor can clear browser storage and vote again. It only raises the bar against casual double-voting.
              • \n
              • You remain the data controller for your site; StaticLayer never receives, stores or sees the token.
              • \n
              \n
              \n
              \n
              \n

              Live preview

              \n

              Updates as you type — this is what visitors see before voting.

              \n
              \n
              \n
              \n
              \n

              Your polls

              \n
              \n

              \n
              \n
              \n
              \n\n \n
              \n

              Updates

              \n

              Check whether a newer StaticLayer release is available for this Worker.

              \n
              \n

              Checking…

              \n
              \n

              \n
              \n \n\n
              StaticLayer · BYOC · Source-available (Elastic License 2.0)
              \n
              \n\n\n\n\n"; export const ADMIN_JS: string = "/**\n * StaticLayer admin UI (external script — CSP script-src 'self' forbids inline\n * scripts, so all logic lives here).\n *\n * The CSRF token is kept in a JS variable in memory ONLY — never in\n * localStorage/sessionStorage/cookies. Comment rendering uses textContent only\n * (XSS-safe). No tracking, no analytics, no external requests.\n */\n(function () {\n 'use strict';\n\n var csrf = null;\n var PAGE_SIZE = 20;\n\n // Tab state\n var state = {\n queue: { q: '', article: '', page: 1, selected: {} },\n published: { q: '', article: '', page: 1, selected: {} }\n };\n\n // ---- helpers ----------------------------------------------------------\n function el(tag, className, text) {\n var node = document.createElement(tag);\n if (className) node.className = className;\n if (text !== undefined) node.textContent = text;\n return node;\n }\n function showStatus(node, message, isError) {\n node.textContent = message;\n node.classList.toggle('err', !!isError);\n node.classList.toggle('ok', !isError && !!message && message.indexOf('Error') === -1);\n }\n function debounce(fn, ms) {\n var t = 0;\n return function () { clearTimeout(t); t = setTimeout(fn, ms); };\n }\n function api(path, options) {\n var opts = options || {};\n opts.credentials = 'same-origin';\n if (csrf && opts.csrf !== false) {\n opts.headers = Object.assign({}, opts.headers, { 'X-CSRF-Token': csrf });\n }\n if (opts.body !== undefined && !opts.headers) opts.headers = { 'content-type': 'application/json' };\n else if (opts.body !== undefined) opts.headers['content-type'] = 'application/json';\n return fetch(path, opts).then(function (res) {\n return res.json().then(function (data) {\n if (!res.ok) { var e = new Error((data && data.error) || ('request failed (' + res.status + ')')); e.status = res.status; throw e; }\n return data;\n });\n });\n }\n function uid() { return Math.random().toString(36).slice(2) + Date.now().toString(36); }\n function nickOf(c) { return (c.nickname && c.nickname.trim()) || 'Anonymous'; }\n function timeStr(sec) { return new Date(sec * 1000).toLocaleString(); }\n function esc(s) { return s; } // placeholder — we use textContent, never innerHTML\n\n // ---- login ------------------------------------------------------------\n var loginView = document.getElementById('login-view');\n var adminView = document.getElementById('admin-view');\n var loginForm = document.getElementById('login-form');\n var loginStatus = document.getElementById('login-status');\n var githubBtn = document.getElementById('github-btn');\n var githubDivider = document.getElementById('github-divider');\n var githubSetup = document.getElementById('github-setup');\n\n function showApp(me) {\n loginView.classList.add('hidden');\n adminView.classList.remove('hidden');\n if (me) {\n if (me.method === 'github') {\n document.getElementById('whoami').textContent = 'Signed in via GitHub';\n } else if (me.via === 'cloudflare-access' && me.email) {\n document.getElementById('whoami').textContent = 'Signed in via Cloudflare as ' + me.email;\n }\n }\n loadPages();\n loadQueue();\n loadPublished();\n }\n\n // Restore an existing session on load (no need to re-type the password; also\n // lets the keychain save it after the POST reload below).\n api('/api/admin/session', { csrf: false })\n .then(function (data) {\n if (data && data.authed) { csrf = data.csrf; showApp(data); }\n })\n .catch(function () { /* not signed in */ });\n\n loginForm.addEventListener('submit', function (event) {\n event.preventDefault();\n var password = document.getElementById('password').value;\n showStatus(loginStatus, '');\n api('/api/admin/login', { method: 'POST', body: JSON.stringify({ password: password }), csrf: false })\n .then(function (data) {\n csrf = data.csrf;\n showApp(null);\n // Offer the browser keychain to save the admin password: submit a real\n // POST form to this same page — the server answers with admin.html, the\n // browser offers to store the credentials, and the reload restores the\n // session via /api/admin/session above.\n try {\n var save = document.createElement('form');\n save.method = 'POST';\n save.action = window.location.pathname;\n var u = document.createElement('input'); u.type = 'text'; u.name = 'username'; u.value = 'admin'; u.autocomplete = 'username';\n var p = document.createElement('input'); p.type = 'password'; p.name = 'password'; p.value = password; p.autocomplete = 'current-password';\n save.appendChild(u); save.appendChild(p);\n save.style.display = 'none';\n document.body.appendChild(save);\n save.submit();\n } catch (e) { /* keychain offer is best-effort */ }\n })\n .catch(function () { showStatus(loginStatus, 'Incorrect password.', true); });\n });\n\n // Show the GitHub button only when GitHub sign-in is configured; otherwise\n // show the short setup hint so the operator can enable it from here.\n api('/api/admin/github', { method: 'GET', csrf: false })\n .then(function (data) {\n var origin = location.origin;\n var el = document.getElementById('github-origin-login');\n if (el) el.textContent = origin;\n el = document.getElementById('github-callback-login');\n if (el) el.textContent = origin + '/api/admin/github/callback';\n if (data && data.configured) {\n githubSetup.classList.add('hidden');\n } else {\n githubBtn.classList.add('hidden');\n githubDivider.classList.add('hidden');\n githubSetup.classList.remove('hidden');\n }\n })\n .catch(function () {\n githubBtn.classList.add('hidden');\n githubDivider.classList.add('hidden');\n });\n\n // One-line notice when GitHub redirects back after the OAuth dance.\n (function () {\n var q = new URLSearchParams(window.location.search);\n var outcome = q.get('github');\n if (outcome === 'signed-in') { showStatus(loginStatus, 'Signed in with GitHub — welcome back.'); }\n else if (outcome === 'denied') { showStatus(loginStatus, 'This GitHub account is not in the admin allowlist.', true); }\n if (outcome) { history.replaceState(null, '', window.location.pathname); }\n })();\n\n document.getElementById('signout').addEventListener('click', function () {\n api('/api/admin/logout', { method: 'POST', csrf: false }).catch(function () {});\n csrf = null;\n adminView.classList.add('hidden');\n loginView.classList.remove('hidden');\n document.getElementById('password').value = '';\n });\n\n // ---- tabs -------------------------------------------------------------\n var tabButtons = document.querySelectorAll('.tab');\n var tabSections = {\n queue: document.getElementById('tab-queue'),\n published: document.getElementById('tab-published'),\n pages: document.getElementById('tab-pages'),\n lists: document.getElementById('tab-lists'),\n settings: document.getElementById('tab-settings'),\n widget: document.getElementById('tab-widget'),\n polls: document.getElementById('tab-polls'),\n updates: document.getElementById('tab-updates')\n };\n tabButtons.forEach(function (btn) {\n btn.addEventListener('click', function () {\n tabButtons.forEach(function (b) { b.classList.toggle('active', b === btn); });\n Object.keys(tabSections).forEach(function (k) { tabSections[k].classList.toggle('hidden', k !== btn.getAttribute('data-tab')); });\n var tab = btn.getAttribute('data-tab');\n if (tab === 'pages') loadPages();\n else if (tab === 'lists') loadLists();\n else if (tab === 'settings') loadSettings();\n else if (tab === 'widget') loadWidget();\n else if (tab === 'polls') loadPolls();\n else if (tab === 'updates') loadUpdates();\n else if (tab === 'queue') loadQueue();\n else if (tab === 'published') loadPublished();\n });\n });\n\n // ---- queue ------------------------------------------------------------\n function loadQueue() {\n var s = state.queue;\n var q = document.getElementById('q-input').value.trim();\n var article = document.getElementById('article-filter').value;\n s.q = q; s.article = article;\n var params = 'status=pending&page=' + s.page + '&perPage=' + PAGE_SIZE;\n if (q) params += '&q=' + encodeURIComponent(q);\n if (article) params += '&article=' + encodeURIComponent(article);\n api('/api/admin/comments?' + params)\n .then(function (data) {\n renderComments('queue', data.comments, 'pending');\n document.getElementById('queue-count').textContent = data.total + (data.total === 1 ? ' comment' : ' comments');\n renderPager('queue', data);\n })\n .catch(function (err) { showStatus(document.getElementById('queue-status'), 'Error: ' + err.message, true); });\n }\n\n document.getElementById('q-input').addEventListener('input', debounce(function () { state.queue.page = 1; loadQueue(); }, 250));\n document.getElementById('article-filter').addEventListener('change', function () { state.queue.page = 1; loadQueue(); });\n\n // ---- published --------------------------------------------------------\n function loadPublished() {\n var s = state.published;\n var q = document.getElementById('pub-q-input').value.trim();\n var article = document.getElementById('pub-article-filter').value;\n s.q = q; s.article = article;\n var params = 'status=approved&page=' + s.page + '&perPage=' + PAGE_SIZE;\n if (q) params += '&q=' + encodeURIComponent(q);\n if (article) params += '&article=' + encodeURIComponent(article);\n api('/api/admin/comments?' + params)\n .then(function (data) {\n renderComments('published', data.comments, 'approved');\n document.getElementById('pub-count').textContent = data.total + (data.total === 1 ? ' comment' : ' comments');\n renderPager('published', data);\n })\n .catch(function (err) { showStatus(document.getElementById('pub-status'), 'Error: ' + err.message, true); });\n }\n\n document.getElementById('pub-q-input').addEventListener('input', debounce(function () { state.published.page = 1; loadPublished(); }, 250));\n document.getElementById('pub-article-filter').addEventListener('change', function () { state.published.page = 1; loadPublished(); });\n\n // ---- comment rendering (shared) --------------------------------------\n function commentCard(c, kind) {\n var nick = nickOf(c);\n var li = el('li', 'comment');\n var cb = el('input', 'cb');\n cb.type = 'checkbox';\n var isSelected = state[kind].selected[c.id] === true;\n cb.checked = isSelected;\n cb.addEventListener('change', function () {\n if (cb.checked) state[kind].selected[c.id] = true;\n else delete state[kind].selected[c.id];\n updateBulkbar(kind);\n });\n var avatar = el('div', 'avatar', nick.charAt(0).toUpperCase());\n var main = el('div', 'main');\n var head = el('div', 'head');\n\n var nickWrap = el('span', 'nick', nick);\n if (nick !== 'Anonymous') {\n var ban = el('button', 'ban-link', '· ban');\n ban.title = 'Add to blocklist';\n ban.addEventListener('click', function () {\n addListValue('block', nick);\n });\n nickWrap.appendChild(ban);\n }\n head.append(nickWrap, el('span', 'time', timeStr(c.created_at)));\n if (c.article_path) head.appendChild(el('span', 'chip', c.article_path));\n if (c.parent_id) {\n var pn = (c.parent_nickname && c.parent_nickname.trim()) || '…';\n head.appendChild(el('span', 'chip allow', '↳ reply to ' + pn));\n }\n if (c.pinned) head.appendChild(el('span', 'chip allow', '📌 pinned'));\n if (c.flags > 0) head.appendChild(el('span', 'chip block', '⚠ ' + c.flags + (c.flags === 1 ? ' report' : ' reports')));\n var body = el('p', 'body', c.body); // textContent only — XSS-safe\n var actions = el('div', 'actions');\n // Pin / unpin to the top of the thread.\n var pinBtn = el('button', 'btn ghost sm', c.pinned ? 'Unpin' : 'Pin');\n pinBtn.title = 'Pin to the top of the thread';\n pinBtn.addEventListener('click', function () {\n api('/api/admin/comments/' + encodeURIComponent(c.id), {\n method: 'PATCH',\n body: JSON.stringify({ pinned: !c.pinned })\n })\n .then(function () { if (kind === 'queue') loadQueue(); else loadPublished(); })\n .catch(function (err) {\n showStatus(document.getElementById(kind === 'queue' ? 'queue-status' : 'pub-status'), 'Error: ' + err.message, true);\n });\n });\n actions.appendChild(pinBtn);\n // Reply as the site owner (marked with an \"Author\" badge on the widget).\n var replyBtn = el('button', 'btn ghost sm', 'Reply');\n replyBtn.addEventListener('click', function () {\n var existing = main.querySelector('.reply-box');\n if (existing) { existing.remove(); return; }\n var box = el('div', 'reply-box');\n var ta = el('textarea', 'reply-ta', '');\n ta.rows = 2;\n ta.maxLength = 3000;\n ta.placeholder = 'Write your reply as the site owner…';\n var row = el('div', 'row');\n var send = el('button', 'btn ok sm', 'Send');\n var cancel = el('button', 'btn ghost sm', 'Cancel');\n cancel.addEventListener('click', function () { box.remove(); });\n send.addEventListener('click', function () {\n var text = ta.value.trim();\n if (!text) return;\n send.disabled = true;\n api('/api/admin/comments/' + encodeURIComponent(c.id) + '/reply', { method: 'POST', body: JSON.stringify({ body: text }) })\n .then(function () {\n if (kind === 'queue') loadQueue();\n else loadPublished();\n })\n .catch(function (err) {\n showStatus(document.getElementById(kind === 'queue' ? 'queue-status' : 'pub-status'), 'Error: ' + err.message, true);\n send.disabled = false;\n });\n });\n row.append(cancel, send);\n box.append(ta, row);\n main.appendChild(box);\n ta.focus();\n });\n actions.appendChild(replyBtn);\n if (kind === 'queue') {\n var appr = el('button', 'btn ok sm', 'Approve');\n var del = el('button', 'btn danger sm', 'Delete');\n appr.addEventListener('click', function () { moderate(c.id, 'approve'); });\n del.addEventListener('click', function () { moderate(c.id, 'delete'); });\n actions.append(appr, del);\n } else {\n var unappr = el('button', 'btn ghost sm', 'Unapprove');\n var del2 = el('button', 'btn danger sm', 'Delete');\n unappr.addEventListener('click', function () { moderate(c.id, 'unapprove'); });\n del2.addEventListener('click', function () { moderate(c.id, 'delete'); });\n actions.append(unappr, del2);\n }\n main.append(head, body, actions);\n li.append(cb, avatar, main);\n return li;\n }\n\n function renderComments(kind, comments, _status) {\n var listEl = kind === 'queue' ? document.getElementById('queue-list') : document.getElementById('published-list');\n listEl.replaceChildren();\n if (!comments || comments.length === 0) {\n listEl.appendChild(el('li', 'empty', kind === 'queue'\n ? 'No comments awaiting approval. 🎉'\n : 'No published comments yet.'));\n return;\n }\n comments.forEach(function (c) { listEl.appendChild(commentCard(c, kind)); });\n updateBulkbar(kind);\n }\n\n function updateBulkbar(kind) {\n var count = Object.keys(state[kind].selected).length;\n var bar = kind === 'queue' ? document.getElementById('bulkbar') : document.getElementById('pub-bulkbar');\n bar.classList.toggle('show', count > 0);\n (kind === 'queue' ? document.getElementById('sel-count') : document.getElementById('pub-sel-count')).textContent = count + ' selected';\n }\n\n function moderate(id, action) {\n if (!csrf) return;\n var options = {\n headers: {}\n };\n if (action === 'approve' || action === 'unapprove') {\n options.method = 'PATCH';\n options.body = JSON.stringify({ status: action === 'approve' ? 'approved' : 'pending' });\n } else {\n options.method = 'DELETE';\n }\n api('/api/admin/comments/' + encodeURIComponent(id), options)\n .then(function () {\n loadQueue();\n loadPublished();\n loadPages();\n loadLists();\n })\n .catch(function (err) {\n showStatus(document.getElementById('queue-status'), 'Error: ' + err.message, true);\n });\n }\n\n // ---- bulk -------------------------------------------------------------\n function bulkAction(kind, action) {\n var ids = Object.keys(state[kind].selected);\n if (ids.length === 0) return;\n api('/api/admin/comments/bulk', { method: 'POST', body: JSON.stringify({ ids: ids, action: action }) })\n .then(function () {\n state[kind].selected = {};\n loadQueue();\n loadPublished();\n loadPages();\n loadLists();\n })\n .catch(function (err) {\n showStatus(document.getElementById(kind === 'queue' ? 'queue-status' : 'pub-status'), 'Error: ' + err.message, true);\n });\n }\n document.getElementById('bulk-approve').addEventListener('click', function () { bulkAction('queue', 'approve'); });\n document.getElementById('bulk-delete').addEventListener('click', function () { bulkAction('queue', 'delete'); });\n document.getElementById('bulk-unapprove').addEventListener('click', function () { bulkAction('published', 'unapprove'); });\n document.getElementById('pub-bulk-delete').addEventListener('click', function () { bulkAction('published', 'delete'); });\n\n // ---- pagination -------------------------------------------------------\n function renderPager(kind, data) {\n var pager = kind === 'queue' ? document.getElementById('queue-pager') : document.getElementById('pub-pager');\n pager.replaceChildren();\n if (!data || data.pages <= 1) {\n pager.appendChild(el('span', 'info', data.total + ' total'));\n return;\n }\n var prev = el('button', 'btn ghost sm', '← Prev');\n var info = el('span', 'info', 'Page ' + data.page + ' of ' + data.pages + ' · ' + data.total + ' total');\n var next = el('button', 'btn ghost sm', 'Next →');\n prev.disabled = data.page <= 1;\n next.disabled = data.page >= data.pages;\n prev.addEventListener('click', function () { state[kind].page -= 1; loadQueueOrPub(kind); });\n next.addEventListener('click', function () { state[kind].page += 1; loadQueueOrPub(kind); });\n pager.append(prev, info, next);\n }\n function loadQueueOrPub(kind) {\n if (kind === 'queue') loadQueue(); else loadPublished();\n }\n\n // ---- pages ------------------------------------------------------------\n function loadPages() {\n var list = document.getElementById('pages-list');\n var status = document.getElementById('pages-status');\n api('/api/admin/articles')\n .then(function (data) {\n var articles = Array.isArray(data.articles) ? data.articles : [];\n list.replaceChildren();\n if (articles.length === 0) {\n list.appendChild(el('li', 'empty', 'No comments yet on any page.'));\n showStatus(status, '');\n return;\n }\n articles.forEach(function (a) {\n var row = el('li', 'page-row');\n row.appendChild(el('span', 'path', a.article_path));\n var counts = el('div', 'counts');\n counts.append(\n el('span', 'count-pill total', a.total + ' total'),\n el('span', 'count-pill pending', a.pending + ' pending'),\n el('span', 'count-pill approved', a.approved + ' approved'),\n el('span', 'count-pill total', a.reactions + ' reactions')\n );\n row.appendChild(counts);\n row.addEventListener('click', function () {\n // Filter the queue to this page.\n document.getElementById('article-filter').value = a.article_path;\n state.queue.page = 1;\n state.queue.selected = {};\n tabButtons.forEach(function (b) { b.classList.toggle('active', b.getAttribute('data-tab') === 'queue'); });\n Object.keys(tabSections).forEach(function (k) { tabSections[k].classList.toggle('hidden', k !== 'queue'); });\n loadQueue();\n loadArticleFilters();\n });\n list.appendChild(row);\n });\n showStatus(status, '');\n })\n .catch(function (err) { showStatus(status, 'Error: ' + err.message, true); });\n loadArticleFilters();\n }\n\n function loadArticleFilters() {\n api('/api/admin/articles').then(function (data) {\n var articles = Array.isArray(data.articles) ? data.articles : [];\n ['article-filter', 'pub-article-filter'].forEach(function (id) {\n var sel = document.getElementById(id);\n var current = sel.value;\n sel.replaceChildren();\n var all = el('option', null, 'All pages');\n all.value = '';\n sel.appendChild(all);\n articles.forEach(function (a) {\n var opt = el('option', null, a.article_path);\n opt.value = a.article_path;\n sel.appendChild(opt);\n });\n if (current) sel.value = current;\n });\n }).catch(function () {});\n }\n\n // ---- lists ------------------------------------------------------------\n function loadLists() {\n api('/api/admin/lists')\n .then(function (data) {\n renderChips('allow-chips', data.allow, 'allow');\n renderChips('block-chips', data.block, 'block');\n return api('/api/admin/terms');\n })\n .then(function (data) {\n renderTermChips(data.terms || []);\n })\n .catch(function (err) { showStatus(document.getElementById('lists-status'), 'Error: ' + err.message, true); });\n }\n function renderChips(id, items, kind) {\n var box = document.getElementById(id);\n box.replaceChildren();\n items.forEach(function (item) {\n var chip = el('span', 'chip ' + kind, item.value);\n var x = el('button', 'x', '×');\n x.title = 'Remove';\n x.addEventListener('click', function () {\n api('/api/admin/lists/' + item.id, { method: 'DELETE' })\n .then(loadLists)\n .catch(function (err) { showStatus(document.getElementById('lists-status'), 'Error: ' + err.message, true); });\n });\n chip.appendChild(x);\n box.appendChild(chip);\n });\n if (items.length === 0) box.appendChild(el('span', 'chip', 'empty'));\n }\n function addListValue(kind, value) {\n api('/api/admin/lists', { method: 'POST', body: JSON.stringify({ kind: kind, value: value }) })\n .then(loadLists)\n .catch(function (err) { showStatus(document.getElementById('lists-status'), 'Error: ' + err.message, true); });\n }\n document.getElementById('allow-add').addEventListener('click', function () {\n var v = document.getElementById('allow-input').value;\n if (!v.trim()) return;\n addListValue('allow', v);\n document.getElementById('allow-input').value = '';\n });\n document.getElementById('block-add').addEventListener('click', function () {\n var v = document.getElementById('block-input').value;\n if (!v.trim()) return;\n addListValue('block', v);\n document.getElementById('block-input').value = '';\n });\n ['allow-input', 'block-input'].forEach(function (id) {\n document.getElementById(id).addEventListener('keydown', function (e) {\n if (e.key === 'Enter') {\n e.preventDefault();\n document.getElementById(id === 'allow-input' ? 'allow-add' : 'block-add').click();\n }\n });\n });\n\n // ---- blocked terms -----------------------------------------------------\n function renderTermChips(terms) {\n var box = document.getElementById('term-chips');\n box.replaceChildren();\n terms.forEach(function (item) {\n var chip = el('span', 'chip block', '\"' + item.term + '\"');\n var x = el('button', 'x', '×');\n x.title = 'Remove term';\n x.addEventListener('click', function () {\n api('/api/admin/terms/' + item.id, { method: 'DELETE' })\n .then(loadLists)\n .catch(function (err) { showStatus(document.getElementById('lists-status'), 'Error: ' + err.message, true); });\n });\n chip.appendChild(x);\n box.appendChild(chip);\n });\n if (terms.length === 0) box.appendChild(el('span', 'chip', 'no terms blocked'));\n }\n document.getElementById('term-add').addEventListener('click', function () {\n var v = document.getElementById('term-input').value;\n if (!v.trim()) return;\n api('/api/admin/terms', { method: 'POST', body: JSON.stringify({ term: v }) })\n .then(loadLists)\n .catch(function (err) { showStatus(document.getElementById('lists-status'), 'Error: ' + err.message, true); });\n document.getElementById('term-input').value = '';\n });\n document.getElementById('term-input').addEventListener('keydown', function (e) {\n if (e.key === 'Enter') { e.preventDefault(); document.getElementById('term-add').click(); }\n });\n\n // ---- updates -----------------------------------------------------------\n function loadUpdates() {\n var card = document.getElementById('updates-card');\n var status = document.getElementById('updates-status');\n var detail = document.getElementById('updates-detail');\n status.textContent = 'Checking…';\n detail.textContent = '';\n api('/api/admin/updates', { csrf: false })\n .then(function (data) {\n card.replaceChildren();\n if (data.error) {\n card.appendChild(el('p', 'lsub', 'You are on v' + data.current + '.'));\n status.textContent = 'Could not reach the update server (' + data.error + ').';\n status.classList.add('err');\n return;\n }\n if (data.updateAvailable) {\n card.appendChild(el('p', 'lsub', 'A newer release is available: v' + data.latest + ' (you are on v' + data.current + ').'));\n card.appendChild(el('p', 'lsub', 'Update with the hosted installer — it re-deploys the latest version into your account and preserves your secrets (including the admin password).'));\n var row = el('div', 'row');\n var open = el('a', 'btn', 'Open hosted installer →');\n open.href = data.installerUrl || 'https://staticlayer-installer.staticlayer.workers.dev';\n open.target = '_blank';\n open.rel = 'noopener';\n row.appendChild(open);\n card.appendChild(row);\n status.textContent = 'Update available — ' + (data.date || '');\n status.classList.remove('err');\n status.classList.add('ok');\n if (data.notes) detail.textContent = data.notes;\n } else {\n card.appendChild(el('p', 'lsub', 'You are on the latest release (v' + data.current + ').'));\n status.textContent = 'Up to date';\n status.classList.remove('err');\n status.classList.add('ok');\n if (data.notes) detail.textContent = data.notes;\n }\n })\n .catch(function (err) { status.textContent = 'Error: ' + err.message; status.classList.add('err'); });\n }\n\n // ---- settings ---------------------------------------------------------\n function telegramEventsValue() {\n var evts = [];\n ['set-telegram-evt-comment', 'set-telegram-evt-poll', 'set-telegram-evt-reaction'].forEach(function (id) {\n var el = document.getElementById(id);\n if (el && el.checked) evts.push(el.value);\n });\n return evts.join(',');\n }\n function setTelegramEventsValue(str) {\n var set = {};\n String(str || '').split(',').forEach(function (v) { if (v) set[v.trim()] = true; });\n ['set-telegram-evt-comment', 'set-telegram-evt-poll', 'set-telegram-evt-reaction'].forEach(function (id) {\n var el = document.getElementById(id);\n if (el) el.checked = !!set[el.value];\n });\n }\n function loadSettings() {\n api('/api/admin/settings')\n .then(function (data) {\n var s = data.settings || {};\n document.getElementById('set-difficulty').value = s.pow_difficulty;\n document.getElementById('set-reactions').value = s.reaction_options || '';\n document.getElementById('set-mode').value = s.moderation_mode === 'allowlist' ? 'allowlist' : 'open';\n document.getElementById('set-telegram-alerts').value = s.telegram_alerts === 'on' ? 'on' : 'off';\n document.getElementById('set-telegram-token').value = s.telegram_bot_token || '';\n document.getElementById('set-telegram-chat').value = s.telegram_chat_id || '';\n setTelegramEventsValue(s.telegram_events == null ? 'comment' : s.telegram_events);\n document.getElementById('set-owner-nick').value = s.owner_nickname || 'Site owner';\n })\n .catch(function (err) { showStatus(document.getElementById('settings-status'), 'Error: ' + err.message, true); });\n // Fill this Worker's URLs into the GitHub step-by-step guide and show a\n // small configured / not-configured status at the top.\n api('/api/admin/github', { csrf: false })\n .then(function (data) {\n var guide = document.getElementById('github-guide');\n if (guide) {\n document.getElementById('github-origin').textContent = location.origin;\n document.getElementById('github-callback').textContent = location.origin + '/api/admin/github/callback';\n var badge = document.createElement('p');\n badge.className = 'lsub';\n badge.style.cssText = 'margin:0 0 10px;padding:8px 10px;border-radius:10px;background:var(--surface);border:1px solid var(--line);font-weight:600';\n badge.textContent = data && data.configured\n ? '\\u2713 GitHub sign-in is configured \\u2014 refresh the login screen to use \"Sign in with GitHub\".'\n : 'Not configured yet \\u2014 follow the steps below, then re-deploy and refresh.';\n guide.insertBefore(badge, guide.firstChild);\n }\n })\n .catch(function () { /* keep the guide as-is on errors */ });\n }\n document.getElementById('settings-save').addEventListener('click', function () {\n var payload = {\n settings: {\n pow_difficulty: parseInt(document.getElementById('set-difficulty').value, 10),\n reaction_options: document.getElementById('set-reactions').value.trim(),\n moderation_mode: document.getElementById('set-mode').value,\n telegram_alerts: document.getElementById('set-telegram-alerts').value,\n telegram_bot_token: document.getElementById('set-telegram-token').value.trim(),\n telegram_chat_id: document.getElementById('set-telegram-chat').value.trim(),\n telegram_events: telegramEventsValue(),\n owner_nickname: document.getElementById('set-owner-nick').value.trim()\n }\n };\n showStatus(document.getElementById('settings-status'), '');\n api('/api/admin/settings', { method: 'PUT', body: JSON.stringify(payload) })\n .then(function () { showStatus(document.getElementById('settings-status'), 'Settings saved ✓'); })\n .catch(function (err) { showStatus(document.getElementById('settings-status'), 'Error: ' + err.message, true); });\n });\n document.getElementById('settings-reset').addEventListener('click', function () {\n api('/api/admin/settings', {\n method: 'PUT',\n body: JSON.stringify({ settings: { pow_difficulty: 16, reaction_options: '👍,❤️,🎉', moderation_mode: 'open', telegram_alerts: 'off', telegram_bot_token: '', telegram_chat_id: '', telegram_events: 'comment', owner_nickname: 'Site owner' } })\n })\n .then(function () { loadSettings(); showStatus(document.getElementById('settings-status'), 'Reset to defaults ✓'); })\n .catch(function (err) { showStatus(document.getElementById('settings-status'), 'Error: ' + err.message, true); });\n });\n // \"Send test message\" — verifies the saved Telegram token + chat id instantly\n // and surfaces the exact Telegram error instead of failing silently.\n document.getElementById('telegram-test').addEventListener('click', function () {\n var statusEl = document.getElementById('telegram-test-status');\n var btn = document.getElementById('telegram-test');\n btn.disabled = true;\n statusEl.textContent = 'Sending…';\n statusEl.style.color = '';\n api('/api/admin/settings/telegram-test', { method: 'POST', body: '{}' })\n .then(function () { statusEl.textContent = '✓ Test message sent — check Telegram.'; statusEl.style.color = 'var(--ok,#1a8f5a)'; })\n .catch(function (err) {\n statusEl.textContent = 'Failed: ' + err.message;\n statusEl.style.color = 'var(--err,#e23a52)';\n })\n .finally(function () { btn.disabled = false; });\n });\n\n // ---- widget builder --------------------------------------------------\n // Generates the copy-paste embed snippet. All options are stored locally in\n // the browser (localStorage) — nothing is sent to the server.\n var WIDGET_KEY = 'sl-widget-config';\n var widgetFields = ['w-reactions', 'w-reactions-position', 'w-path', 'w-lang', 'w-theme', 'w-accent-text', 'w-radius', 'w-maxwidth', 'w-texts', 'w-poll', 'w-poll-style', 'w-poll-results'];\n\n function widgetDefaults() {\n return { mode: 'both', reactions: '👍,❤️,🎉', reactionsPosition: 'bottom', path: '', lang: 'auto', theme: 'auto', accent: '#f57d1f', radius: '14', maxwidth: '640', texts: '', pollId: '', pollStyle: 'bars', pollResults: 'after' };\n }\n function widgetState() {\n var s = widgetDefaults();\n var checked = document.querySelector('input[name=\"w-mode\"]:checked');\n s.mode = checked ? checked.value : 'both';\n s.reactions = document.getElementById('w-reactions').value.trim();\n s.reactionsPosition = document.getElementById('w-reactions-position').value || 'bottom';\n s.path = document.getElementById('w-path').value.trim();\n s.lang = document.getElementById('w-lang').value;\n s.theme = document.getElementById('w-theme').value;\n var accent = document.getElementById('w-accent-text').value.trim();\n s.accent = accent || document.getElementById('w-accent').value;\n s.radius = document.getElementById('w-radius').value;\n s.maxwidth = document.getElementById('w-maxwidth').value;\n s.texts = document.getElementById('w-texts').value.trim();\n s.pollId = document.getElementById('w-poll').value;\n s.pollStyle = document.getElementById('w-poll-style').value || 'bars';\n s.pollResults = document.getElementById('w-poll-results').value || 'after';\n return s;\n }\n function escAttr(v) {\n return String(v).replace(/&/g, '&').replace(/\"/g, '"').replace(//g, '>');\n }\n function renderWidgetSnippet() {\n var s = widgetState();\n var origin = location.origin;\n // Standalone poll embed (no comments UI).\n if (s.pollId) {\n var plines = [''];\n plines.push('
              ');\n plines.push('');\n document.getElementById('w-snippet').value = plines.join('\\n');\n return;\n }\n var lines = [''];\n lines.push('
              ');\n lines.push('');\n document.getElementById('w-snippet').value = lines.join('\\n');\n }\n function saveWidgetState() {\n try { localStorage.setItem(WIDGET_KEY, JSON.stringify(widgetState())); } catch (e) { /* private mode — ignore */ }\n }\n function applyWidgetState(s) {\n var labels = document.querySelectorAll('#w-mode label');\n labels.forEach(function (l) { l.classList.toggle('sel', (l.querySelector('input') || {}).value === s.mode); });\n var radios = document.querySelectorAll('input[name=\"w-mode\"]');\n radios.forEach(function (r) { r.checked = r.value === s.mode; });\n document.getElementById('w-reactions').value = s.reactions;\n document.getElementById('w-reactions-position').value = s.reactionsPosition || 'bottom';\n document.getElementById('w-path').value = s.path;\n document.getElementById('w-lang').value = s.lang;\n document.getElementById('w-theme').value = s.theme;\n document.getElementById('w-accent').value = s.accent || '#f57d1f';\n document.getElementById('w-accent-text').value = s.accent || '';\n document.getElementById('w-radius').value = s.radius;\n document.getElementById('w-maxwidth').value = s.maxwidth;\n document.getElementById('w-texts').value = s.texts;\n document.getElementById('w-poll').value = s.pollId || '';\n document.getElementById('w-poll-style').value = s.pollStyle || 'bars';\n document.getElementById('w-poll-results').value = s.pollResults || 'after';\n var pollFields = ['w-poll-style-field', 'w-poll-results-field'];\n pollFields.forEach(function (fid) {\n var f = document.getElementById(fid);\n if (f) f.classList.toggle('hidden', !s.pollId);\n });\n var reactionsFields = ['w-reactions-field', 'w-reactions-position-field'];\n reactionsFields.forEach(function (fid) {\n var f = document.getElementById(fid);\n if (f) f.classList.toggle('hidden', s.mode === 'comments');\n });\n renderWidgetSnippet();\n renderPreview();\n }\n function loadWidget() {\n document.getElementById('widget-origin').textContent = location.origin;\n var saved = null;\n try { saved = JSON.parse(localStorage.getItem(WIDGET_KEY) || 'null'); } catch (e) { saved = null; }\n api('/api/admin/polls')\n .then(function (data) {\n pollsCache = Array.isArray(data.polls) ? data.polls : [];\n populatePollSelect();\n applyWidgetState(Object.assign(widgetDefaults(), saved || {}));\n })\n .catch(function () {\n applyWidgetState(Object.assign(widgetDefaults(), saved || {}));\n });\n }\n document.getElementById('w-copy').addEventListener('click', function () {\n var box = document.getElementById('w-snippet');\n function done(ok) { showStatus(document.getElementById('w-status'), ok ? 'Snippet copied to clipboard ✓' : 'Copy failed — select the text and copy manually.', !ok); }\n if (navigator.clipboard && navigator.clipboard.writeText) {\n navigator.clipboard.writeText(box.value).then(function () { done(true); }, function () { done(false); });\n } else {\n box.focus(); box.select();\n try { done(document.execCommand('copy')); } catch (e) { done(false); }\n }\n });\n widgetFields.forEach(function (id) {\n var node = document.getElementById(id);\n if (!node) return;\n node.addEventListener('input', function () { saveWidgetState(); renderWidgetSnippet(); });\n node.addEventListener('change', function () {\n if (id === 'w-poll') {\n var hasPoll = !!node.value;\n ['w-poll-style-field', 'w-poll-results-field'].forEach(function (fid) {\n var f = document.getElementById(fid);\n if (f) f.classList.toggle('hidden', !hasPoll);\n });\n }\n saveWidgetState();\n renderWidgetSnippet();\n });\n });\n document.querySelectorAll('input[name=\"w-mode\"]').forEach(function (r) {\n r.addEventListener('change', function () {\n var mode = widgetState().mode;\n document.querySelectorAll('#w-mode label').forEach(function (l) { l.classList.toggle('sel', (l.querySelector('input') || {}).value === mode); });\n ['w-reactions-field', 'w-reactions-position-field'].forEach(function (fid) {\n var f = document.getElementById(fid);\n if (f) f.classList.toggle('hidden', mode === 'comments');\n });\n saveWidgetState();\n renderWidgetSnippet();\n });\n });\n document.getElementById('w-accent').addEventListener('input', function () {\n document.getElementById('w-accent-text').value = this.value;\n saveWidgetState();\n renderWidgetSnippet();\n renderPreview();\n });\n\n // ---- widget templates + live preview ---------------------------------\n var WIDGET_TEMPLATES = {\n classic: { mode: 'both', reactions: '👍,❤️,🎉', path: '', lang: 'auto', theme: 'auto', accent: '#f57d1f', radius: '14', maxwidth: '640', texts: '' },\n minimal: { mode: 'comments', reactions: '', path: '', lang: 'auto', theme: 'light', accent: '#1a1a1a', radius: '8', maxwidth: '600', texts: '' },\n vibrant: { mode: 'both', reactions: '🔥,❤️,👏,😄', path: '', lang: 'auto', theme: 'light', accent: '#ff3d54', radius: '20', maxwidth: '700', texts: '' },\n dark: { mode: 'both', reactions: '👍,❤️,🎉', path: '', lang: 'auto', theme: 'dark', accent: '#ff8a2a', radius: '14', maxwidth: '640', texts: '' },\n elegant: { mode: 'comments', reactions: '', path: '', lang: 'auto', theme: 'light', accent: '#7c3aed', radius: '10', maxwidth: '560', texts: '' },\n newsletter: { mode: 'both', reactions: '👍,❤️', path: '', lang: 'it', theme: 'light', accent: '#0284c7', radius: '12', maxwidth: '520', texts: '' },\n reactions: { mode: 'reactions', reactions: '👍,❤️,🎉,🔥', path: '', lang: 'auto', theme: 'auto', accent: '#16a34a', radius: '999', maxwidth: '640', texts: '' },\n poll: { mode: 'comments', reactions: '', path: '', lang: 'auto', theme: 'auto', accent: '#f57d1f', radius: '14', maxwidth: '640', texts: '' }\n };\n function renderPreview() {\n var container = document.getElementById('w-preview');\n var status = document.getElementById('w-preview-status');\n if (!container) return;\n if (typeof window.StaticLayer !== 'object' || typeof window.StaticLayer.mount !== 'function') {\n showStatus(status, 'Live preview unavailable here — the widget script is not loaded.', true);\n return;\n }\n showStatus(status, '');\n var s = widgetState();\n try { window.StaticLayer.unmount(container); } catch (e) { /* not mounted */ }\n var opts = { endpoint: location.origin, articlePath: '/__staticlayer_preview__', hostContext: location.hostname };\n if (s.pollId) {\n opts.pollId = s.pollId;\n if (s.pollStyle && s.pollStyle !== 'bars') opts.pollStyle = s.pollStyle;\n if (s.pollResults && s.pollResults !== 'after') opts.pollResults = s.pollResults;\n pollsCache.forEach(function (p) { if (p.id === s.pollId && p.article_path) opts.articlePath = p.article_path; });\n }\n if (s.mode !== 'comments' && s.reactions) {\n opts.reactions = s.reactions.split(',').map(function (x) { return x.trim(); }).filter(Boolean);\n }\n if (s.mode === 'reactions') opts.reactionsOnly = true;\n if (s.lang && s.lang !== 'auto') opts.lang = s.lang;\n if (s.theme && s.theme !== 'auto') opts.theme = s.theme;\n if (s.accent) opts.accent = s.accent;\n var radius = parseInt(s.radius, 10);\n if (!isNaN(radius)) opts.radius = radius;\n var mw = parseInt(s.maxwidth, 10);\n if (!isNaN(mw)) opts.maxWidth = mw;\n if (s.texts) { try { opts.texts = JSON.parse(s.texts); } catch (e) { /* invalid JSON — use defaults */ } }\n try {\n window.StaticLayer.mount(container, opts);\n } catch (err) {\n showStatus(status, 'Preview error: ' + err.message, true);\n }\n }\n document.getElementById('w-template').addEventListener('change', function () {\n var t = WIDGET_TEMPLATES[this.value];\n if (!t) return;\n var keepPoll = document.getElementById('w-poll').value;\n var merged = Object.assign(widgetDefaults(), t);\n merged.pollId = keepPoll; // picking a template never clears the chosen poll\n applyWidgetState(merged);\n saveWidgetState();\n });\n\n // ---- polls (tab + widget builder) ------------------------------------\n var pollsCache = [];\n function pollSnippet(p) {\n return '\\n' +\n '
              \\n' +\n '';\n }\n function copyPollsText(text, okMsg) {\n function done(ok) { showStatus(document.getElementById('polls-status'), ok ? okMsg : 'Copy failed — select and copy manually.', !ok); }\n if (navigator.clipboard && navigator.clipboard.writeText) {\n navigator.clipboard.writeText(text).then(function () { done(true); }, function () { done(false); });\n } else { done(false); }\n }\n function populatePollSelect() {\n var sel = document.getElementById('w-poll');\n if (!sel) return;\n var current = sel.value;\n sel.replaceChildren();\n var none = document.createElement('option');\n none.value = '';\n none.textContent = '— none (comments / reactions) —';\n sel.appendChild(none);\n pollsCache.forEach(function (p) {\n var o = document.createElement('option');\n o.value = p.id;\n o.textContent = (p.question || '').slice(0, 50) + ' — ' + (p.article_path || '');\n sel.appendChild(o);\n });\n sel.value = current;\n }\n function renderPolls(polls) {\n var list = document.getElementById('polls-list');\n list.replaceChildren();\n if (!polls.length) {\n list.appendChild(el('p', 'lsub', 'No polls yet — create your first one on the left.'));\n return;\n }\n polls.forEach(function (p) {\n var card = el('div', 'poll-card');\n card.appendChild(el('h4', null, p.question));\n card.appendChild(el('div', 'path', p.article_path));\n var chips = el('div', 'poll-options-mini');\n (p.options || []).forEach(function (o) {\n var c = p.counts && p.counts[o] ? p.counts[o] : 0;\n var chip = el('span', 'poll-opt-mini', o + ' — ');\n var b = document.createElement('b');\n b.textContent = c;\n chip.appendChild(b);\n chips.appendChild(chip);\n });\n card.appendChild(chips);\n var row = el('div', 'poll-actions');\n row.appendChild(el('span', 'poll-status-pill ' + p.status, p.status));\n if (p.singleVote) row.appendChild(el('span', 'poll-status-pill open', '1 vote/browser'));\n if (p.multi) row.appendChild(el('span', 'poll-status-pill open', 'multi'));\n var toggle = el('button', 'btn ghost sm', p.status === 'open' ? 'Close' : 'Reopen');\n toggle.addEventListener('click', function () {\n api('/api/admin/polls/' + encodeURIComponent(p.id), {\n method: 'PATCH',\n body: JSON.stringify({ status: p.status === 'open' ? 'closed' : 'open' })\n })\n .then(function () { loadPolls(); })\n .catch(function (err) { showStatus(document.getElementById('polls-status'), 'Error: ' + err.message, true); });\n });\n row.appendChild(toggle);\n // Toggle the single-vote guard and multi-select AFTER creation (PATCH).\n var singleBtn = el('button', 'btn ghost sm', '1 vote/browser: ' + (p.singleVote ? 'on' : 'off'));\n singleBtn.addEventListener('click', function () {\n api('/api/admin/polls/' + encodeURIComponent(p.id), {\n method: 'PATCH',\n body: JSON.stringify({ singleVote: !p.singleVote })\n })\n .then(function () { loadPolls(); })\n .catch(function (err) { showStatus(document.getElementById('polls-status'), 'Error: ' + err.message, true); });\n });\n row.appendChild(singleBtn);\n var multiBtn = el('button', 'btn ghost sm', 'multi: ' + (p.multi ? 'on' : 'off'));\n multiBtn.addEventListener('click', function () {\n api('/api/admin/polls/' + encodeURIComponent(p.id), {\n method: 'PATCH',\n body: JSON.stringify({ multi: !p.multi })\n })\n .then(function () { loadPolls(); })\n .catch(function (err) { showStatus(document.getElementById('polls-status'), 'Error: ' + err.message, true); });\n });\n row.appendChild(multiBtn);\n var copy = el('button', 'btn ghost sm', 'Copy snippet');\n copy.addEventListener('click', function () { copyPollsText(pollSnippet(p), 'Poll snippet copied ✓'); });\n row.appendChild(copy);\n var del = el('button', 'btn danger sm', 'Delete');\n del.addEventListener('click', function () {\n if (!window.confirm('Delete this poll and all its votes?')) return;\n api('/api/admin/polls/' + encodeURIComponent(p.id), { method: 'DELETE' })\n .then(function () { loadPolls(); })\n .catch(function (err) { showStatus(document.getElementById('polls-status'), 'Error: ' + err.message, true); });\n });\n row.appendChild(del);\n card.appendChild(row);\n list.appendChild(card);\n });\n }\n function loadPolls() {\n api('/api/admin/polls')\n .then(function (data) {\n pollsCache = Array.isArray(data.polls) ? data.polls : [];\n renderPolls(pollsCache);\n populatePollSelect();\n })\n .catch(function (err) { showStatus(document.getElementById('polls-status'), 'Error: ' + err.message, true); });\n }\n document.getElementById('poll-create').addEventListener('click', function () {\n var article = document.getElementById('poll-article').value.trim();\n var question = document.getElementById('poll-question').value.trim();\n var options = document.getElementById('poll-options').value.split('\\n').map(function (s) { return s.trim(); }).filter(Boolean);\n showStatus(document.getElementById('polls-status'), '');\n api('/api/admin/polls', {\n method: 'POST',\n body: JSON.stringify({\n articlePath: article,\n question: question,\n options: options,\n singleVote: document.getElementById('poll-single-vote').checked,\n multi: document.getElementById('poll-multi').checked\n })\n })\n .then(function () {\n document.getElementById('poll-question').value = '';\n document.getElementById('poll-options').value = '';\n document.getElementById('poll-single-vote').checked = false;\n document.getElementById('poll-multi').checked = false;\n loadPolls();\n renderPollPreview();\n showStatus(document.getElementById('polls-status'), 'Poll created ✓');\n })\n .catch(function (err) { showStatus(document.getElementById('polls-status'), 'Error: ' + err.message, true); });\n });\n\n // Live preview while typing the poll question/options. Uses the widget's\n // .sl-poll-* styles (widget.js is already loaded in the admin page).\n function renderPollPreview() {\n var box = document.getElementById('poll-preview');\n if (!box) return;\n box.replaceChildren();\n var question = document.getElementById('poll-question').value.trim() || 'Your question…';\n var options = document.getElementById('poll-options').value.split('\\n').map(function (s) { return s.trim(); }).filter(Boolean);\n if (!options.length) options = ['Option A', 'Option B', 'Option C'];\n var head = document.createElement('h3');\n head.textContent = question;\n head.style.cssText = 'margin:0 0 14px;font-size:15px;font-weight:650;letter-spacing:-.1px;line-height:1.4';\n box.appendChild(head);\n var ul = document.createElement('ul');\n ul.style.cssText = 'list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px';\n options.forEach(function (o) {\n var li = document.createElement('li');\n var b = document.createElement('button');\n b.type = 'button';\n if (document.getElementById('poll-multi') && document.getElementById('poll-multi').checked) {\n // Multi-select preview: checkbox-like buttons + a Vote button below.\n b.className = 'sl-poll-btn sl-poll-check';\n b.setAttribute('aria-pressed', 'false');\n b.addEventListener('click', function () {\n b.classList.toggle('selected');\n b.setAttribute('aria-pressed', b.classList.contains('selected') ? 'true' : 'false');\n });\n } else {\n b.className = 'sl-poll-btn';\n }\n b.textContent = o;\n li.appendChild(b);\n ul.appendChild(li);\n });\n box.appendChild(ul);\n if (document.getElementById('poll-multi') && document.getElementById('poll-multi').checked) {\n var voteBtn = document.createElement('button');\n voteBtn.type = 'button';\n voteBtn.className = 'sl-poll-vote-btn';\n voteBtn.textContent = 'Vote (0)';\n box.appendChild(voteBtn);\n }\n var status = document.createElement('p');\n status.className = 'sl-poll-status';\n status.textContent = '…';\n box.appendChild(status);\n }\n ['poll-question', 'poll-options', 'poll-multi'].forEach(function (id) {\n var node = document.getElementById(id);\n if (node) node.addEventListener('input', renderPollPreview);\n if (node && node.type === 'checkbox') node.addEventListener('change', renderPollPreview);\n });\n renderPollPreview();\n\n // ---- data export (GDPR portability) --------------------------------\n function downloadExport(format) {\n var statusEl = document.getElementById('export-status');\n if (statusEl) showStatus(statusEl, 'Preparing export…');\n fetch('/api/admin/export?format=' + encodeURIComponent(format))\n .then(function (res) {\n if (!res.ok) throw new Error('export failed');\n if (format === 'csv') {\n return res.text().then(function (t) { return { blob: new Blob([t], { type: 'text/csv' }), name: 'staticlayer-comments.csv' }; });\n }\n return res.json().then(function (j) { return { blob: new Blob([JSON.stringify(j, null, 2)], { type: 'application/json' }), name: 'staticlayer-export.json' }; });\n })\n .then(function (file) {\n var a = document.createElement('a');\n a.href = URL.createObjectURL(file.blob);\n a.download = file.name;\n document.body.appendChild(a);\n a.click();\n setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 500);\n if (statusEl) showStatus(statusEl, 'Export downloaded ✓');\n })\n .catch(function (err) { if (statusEl) showStatus(statusEl, 'Error: ' + err.message, true); });\n }\n var exportCsv = document.getElementById('export-csv');\n if (exportCsv) exportCsv.addEventListener('click', function () { downloadExport('csv'); });\n var exportJson = document.getElementById('export-json');\n if (exportJson) exportJson.addEventListener('click', function () { downloadExport('json'); });\n})();\n\n"; diff --git a/packages/runtime/static/admin.html b/packages/runtime/static/admin.html index bfc2b22..f698790 100644 --- a/packages/runtime/static/admin.html +++ b/packages/runtime/static/admin.html @@ -575,6 +575,7 @@

              Look & feel

              +
              diff --git a/packages/widget/src/widget.js b/packages/widget/src/widget.js index ec7def8..4f54499 100644 --- a/packages/widget/src/widget.js +++ b/packages/widget/src/widget.js @@ -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 @@ -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. @@ -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;' + @@ -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; diff --git a/tests/security/widget-i18n.test.ts b/tests/security/widget-i18n.test.ts new file mode 100644 index 0000000..481e4f3 --- /dev/null +++ b/tests/security/widget-i18n.test.ts @@ -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(`${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 { + 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 = '') => + `
              `; + +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 `: { 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')); + }); +});