diff --git a/docs/roadmap.md b/docs/roadmap.md index 51ab24c..167c781 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -257,6 +257,13 @@ should follow the board's trust model. noted so it is not mistaken for a contract. - **Favicon — ✅ done.** A small inline SVG (`site/favicon.svg`) is linked from both pages, so browsers no longer request (and 404 on) `/favicon.ico`. +- **i18n key-parity guard — ✅ done.** `e2e/i18n.spec.js` fails if any + `NOSTR_UI` / `BOARD_UI` / locales dictionary is missing (or has an extra) key + in some language. It immediately caught `field_too_long`, which had been + English-only across all 13 other languages. +- **Publish transparency — ✅ done.** The publish confirmation modal now reports + how many relays accepted the event ("Relays accepted: N / M") and flags a + zero-acceptance publish, so a weak publish is visible immediately. - **WebKit/Safari coverage in CI** needs `libavif16` on Linux runners (`sudo npx playwright install-deps`); headless WebKit is also not a perfect stand-in for real Safari/iOS print + clipboard behavior, so a real-device diff --git a/e2e/flyer.spec.js b/e2e/flyer.spec.js index a27df6a..ae547d2 100644 --- a/e2e/flyer.spec.js +++ b/e2e/flyer.spec.js @@ -624,6 +624,8 @@ test.describe("VoxVera static client", () => { await expect(page.locator("#publish-modal-message")).toContainText("Copy this URL"); // The URL is a poster URL (naddr in the fragment). await expect(page.locator("#publish-modal-url")).toHaveValue(/#naddr1[0-9a-z]+/); + // The relays-accepted line reports how many of the relays took the event. + await expect(page.locator("#publish-modal-relays")).toContainText(/3\s*\/\s*3/); await page.locator("#publish-modal-close").click(); await expect(modal).toBeHidden(); }); diff --git a/e2e/i18n.spec.js b/e2e/i18n.spec.js new file mode 100644 index 0000000..daf2e02 --- /dev/null +++ b/e2e/i18n.spec.js @@ -0,0 +1,83 @@ +// @ts-check +// Pure-Node guard (no browser): fails if any localized dictionary is missing a +// key in some language, or has an extra one. We add user-facing strings across +// 14 languages by hand, so this catches drift before it ships as a fallback to +// English (or a raw key) for some users. +const { test, expect } = require("@playwright/test"); +const fs = require("fs"); +const path = require("path"); + +function readSite(file) { + return fs.readFileSync(path.join(__dirname, "..", "site", file), "utf8"); +} + +// Extract the first balanced { ... } object literal after a marker, string-aware +// so braces inside strings don't throw off the depth count. +function extractObject(src, marker) { + const start = src.indexOf(marker); + if (start === -1) throw new Error(`marker not found: ${marker}`); + const open = src.indexOf("{", start); + let depth = 0; + let str = null; + let esc = false; + for (let j = open; j < src.length; j += 1) { + const c = src[j]; + if (str) { + if (esc) esc = false; + else if (c === "\\") esc = true; + else if (c === str) str = null; + } else if (c === '"' || c === "'" || c === "`") { + str = c; + } else if (c === "{") { + depth += 1; + } else if (c === "}") { + depth -= 1; + if (depth === 0) return src.slice(open, j + 1); + } + } + throw new Error(`unbalanced braces for ${marker}`); +} + +function evalObject(objectText) { + // The literals are pure data (label strings); safe to evaluate. + return Function(`return (${objectText});`)(); +} + +function checkParity(label, dict, refLang = "en") { + const langs = Object.keys(dict); + expect(langs, `${label}: no ${refLang} reference`).toContain(refLang); + const ref = Object.keys(dict[refLang]).sort(); + for (const lang of langs) { + const keys = Object.keys(dict[lang]).sort(); + const missing = ref.filter((k) => !keys.includes(k)); + const extra = keys.filter((k) => !ref.includes(k)); + expect(missing, `${label}[${lang}] is missing: ${missing.join(", ")}`).toEqual([]); + expect(extra, `${label}[${lang}] has unexpected keys: ${extra.join(", ")}`).toEqual([]); + } +} + +test.describe("i18n key parity", () => { + test("NOSTR_UI defines every key in all languages", () => { + const dict = evalObject(extractObject(readSite("nostr-client.js"), "const NOSTR_UI =")); + expect(Object.keys(dict).length).toBe(14); + checkParity("NOSTR_UI", dict); + }); + + test("BOARD_UI defines every key in all languages", () => { + const dict = evalObject(extractObject(readSite("board.js"), "const BOARD_UI =")); + expect(Object.keys(dict).length).toBe(14); + checkParity("BOARD_UI", dict); + }); + + test("locales labels and landing match across all languages", () => { + const locales = Function(`var window={}; ${readSite("locales.js")}; return window.VoxVeraLocales;`)(); + const labels = {}; + const landing = {}; + for (const [lang, v] of Object.entries(locales)) { + labels[lang] = v.labels || {}; + landing[lang] = v.landing || {}; + } + checkParity("locales.labels", labels); + checkParity("locales.landing", landing); + }); +}); diff --git a/site/index.html b/site/index.html index b31a9fa..0d6bb8a 100644 --- a/site/index.html +++ b/site/index.html @@ -209,6 +209,7 @@ + diff --git a/site/nostr-client.css b/site/nostr-client.css index dbff1bb..a245e5e 100644 --- a/site/nostr-client.css +++ b/site/nostr-client.css @@ -1583,6 +1583,17 @@ body.mode-viewer #viewer-panel.drawer-open .result-panel { white-space: nowrap; } +.modal-relays { + margin: 10px 0 0; + font-size: 13px; + color: var(--muted); +} + +.modal-relays.modal-relays-warn { + color: var(--accent); + font-weight: 700; +} + .modal-close { display: block; margin-left: auto; diff --git a/site/nostr-client.js b/site/nostr-client.js index 07c8d6c..0d5914e 100644 --- a/site/nostr-client.js +++ b/site/nostr-client.js @@ -229,6 +229,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui deleted_done: "تم طلب الحذف.", republished_done: "تمت إعادة النشر إلى المرحلات.", not_author: "يمكنك فقط حذف أو إعادة نشر الملصقات التي نشرتها.", + field_too_long: "هذا الحقل طويل جدًا على الملصق القابل للطباعة:", + relays_accepted: "المرحلات التي قبلت", no_anon_npub: "لا يوجد npub مجهول بعد", anon_npub_ready: "npub مجهول جاهز", npub_generated: "تم إنشاء npub مجهول:", @@ -319,6 +321,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui deleted_done: "Löschung angefordert.", republished_done: "Erneut auf Relays veröffentlicht.", not_author: "Du kannst nur Plakate löschen oder erneut veröffentlichen, die du veröffentlicht hast.", + field_too_long: "Dieses Feld ist zu lang für das druckbare Plakat:", + relays_accepted: "Relays akzeptiert", no_anon_npub: "Noch kein anonymes npub", anon_npub_ready: "Anonymes npub bereit", npub_generated: "Anonymes npub erzeugt:", @@ -432,7 +436,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui failed: "failed", timeout: "timeout", connection_error: "connection error", - field_too_long: "That field is too long for the printable flyer:" + field_too_long: "That field is too long for the printable flyer:", + relays_accepted: "Relays accepted" }, es: { back: "Volver", @@ -500,6 +505,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui deleted_done: "Eliminación solicitada.", republished_done: "Vuelto a publicar en los relés.", not_author: "Solo puedes eliminar o volver a publicar carteles que tú publicaste.", + field_too_long: "Ese campo es demasiado largo para el cartel imprimible:", + relays_accepted: "Relés que aceptaron", no_anon_npub: "Aún no hay npub anónimo", anon_npub_ready: "npub anónimo listo", npub_generated: "npub anónimo generado:", @@ -590,6 +597,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui deleted_done: "درخواست حذف ارسال شد.", republished_done: "دوباره در رله‌ها منتشر شد.", not_author: "فقط می‌توانید پوسترهایی را که خودتان منتشر کرده‌اید حذف یا دوباره منتشر کنید.", + field_too_long: "این فیلد برای پوستر قابل چاپ خیلی طولانی است:", + relays_accepted: "رله‌هایی که پذیرفتند", no_anon_npub: "هنوز npub ناشناس وجود ندارد", anon_npub_ready: "npub ناشناس آماده است", npub_generated: "npub ناشناس ساخته شد:", @@ -680,6 +689,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui deleted_done: "Suppression demandée.", republished_done: "Republiée sur les relais.", not_author: "Vous ne pouvez supprimer ou republier que les affiches que vous avez publiées.", + field_too_long: "Ce champ est trop long pour l'affiche imprimable :", + relays_accepted: "Relais ayant accepté", no_anon_npub: "Aucun npub anonyme pour le moment", anon_npub_ready: "npub anonyme pret", npub_generated: "npub anonyme genere :", @@ -770,6 +781,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui deleted_done: "התבקשה מחיקה.", republished_done: "פורסם מחדש לממסרים.", not_author: "ניתן למחוק או לפרסם מחדש רק כרזות שפרסמת.", + field_too_long: "השדה הזה ארוך מדי עבור הכרזה הניתנת להדפסה:", + relays_accepted: "ממסרים שקיבלו", no_anon_npub: "עדיין אין npub אנונימי", anon_npub_ready: "npub אנונימי מוכן", npub_generated: "npub אנונימי נוצר:", @@ -860,6 +873,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui deleted_done: "विलोपन का अनुरोध किया गया।", republished_done: "रिले पर फिर से प्रकाशित किया गया।", not_author: "आप केवल अपने द्वारा प्रकाशित पोस्टर ही हटा या फिर से प्रकाशित कर सकते हैं।", + field_too_long: "यह फ़ील्ड प्रिंट करने योग्य पोस्टर के लिए बहुत लंबा है:", + relays_accepted: "स्वीकृत रिले", no_anon_npub: "अभी कोई अनाम npub नहीं", anon_npub_ready: "अनाम npub तैयार", npub_generated: "अनाम npub बनाया गया:", @@ -950,6 +965,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui deleted_done: "削除を要求しました。", republished_done: "リレーに再公開しました。", not_author: "自分が公開したちらしのみ削除または再公開できます。", + field_too_long: "この項目は印刷用ちらしには長すぎます:", + relays_accepted: "受け入れたリレー", no_anon_npub: "匿名npubはまだありません", anon_npub_ready: "匿名npub準備完了", npub_generated: "匿名npubを生成しました:", @@ -1040,6 +1057,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui deleted_done: "Exclusão solicitada.", republished_done: "Republicado nos relés.", not_author: "Você só pode excluir ou republicar panfletos que você publicou.", + field_too_long: "Esse campo é longo demais para o panfleto imprimível:", + relays_accepted: "Relés que aceitaram", no_anon_npub: "Ainda nao ha npub anonimo", anon_npub_ready: "npub anonimo pronto", npub_generated: "npub anonimo gerado:", @@ -1130,6 +1149,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui deleted_done: "Запрошено удаление.", republished_done: "Снова опубликовано на реле.", not_author: "Вы можете удалять или повторно публиковать только свои листовки.", + field_too_long: "Это поле слишком длинное для печатной листовки:", + relays_accepted: "Реле приняли", no_anon_npub: "Анонимного npub пока нет", anon_npub_ready: "Анонимный npub готов", npub_generated: "Анонимный npub создан:", @@ -1220,6 +1241,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui deleted_done: "Kufuta kumeombwa.", republished_done: "Limechapishwa tena kwenye relay.", not_author: "Unaweza tu kufuta au kuchapisha tena mabango uliyochapisha.", + field_too_long: "Sehemu hii ni ndefu mno kwa bango linaloweza kuchapishwa:", + relays_accepted: "Relay zilizokubali", no_anon_npub: "Hakuna npub isiyojulikana bado", anon_npub_ready: "npub isiyojulikana iko tayari", npub_generated: "npub isiyojulikana imetengenezwa:", @@ -1310,6 +1333,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui deleted_done: "Silme istendi.", republished_done: "Rölelere yeniden yayımlandı.", not_author: "Yalnızca kendi yayımladığın el ilanlarını silebilir veya yeniden yayımlayabilirsin.", + field_too_long: "Bu alan yazdırılabilir el ilanı için çok uzun:", + relays_accepted: "Kabul eden röleler", no_anon_npub: "Henüz anonim npub yok", anon_npub_ready: "Anonim npub hazır", npub_generated: "Anonim npub oluşturuldu:", @@ -1400,6 +1425,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui deleted_done: "已请求删除。", republished_done: "已重新发布到中继。", not_author: "你只能删除或重新发布你自己发布的传单。", + field_too_long: "该字段对于可打印传单来说太长了:", + relays_accepted: "已接受的中继", no_anon_npub: "还没有匿名 npub", anon_npub_ready: "匿名 npub 已就绪", npub_generated: "已生成匿名 npub:", @@ -2963,13 +2990,27 @@ Join us in a revolution that values truth and transparency. Together, we can bui // Confirmation modal shown after a successful publish, so the author knows // the flyer is live and can copy the URL to view/share it. - function openPublishModal(url) { + function openPublishModal(url, results) { const modal = el("publish-modal"); const input = el("publish-modal-url"); if (!modal || !input) return; input.value = url || ""; const copyBtn = el("publish-modal-copy"); if (copyBtn) copyBtn.textContent = nostrLabel("copy", currentUiLang()); + // Show how many relays actually accepted the event so a weak publish is + // visible immediately (and flagged if zero/low). + const relaysNode = el("publish-modal-relays"); + if (relaysNode) { + const list = Array.isArray(results) ? results : []; + const ok = list.filter((r) => r && r.ok).length; + const total = list.length; + if (total) { + relaysNode.textContent = `${nostrLabel("relays_accepted", currentUiLang())}: ${ok} / ${total}`; + relaysNode.classList.toggle("modal-relays-warn", ok === 0); + } else { + relaysNode.textContent = ""; + } + } modal.hidden = false; input.focus(); input.select(); @@ -3261,7 +3302,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui loadedEventConfig = normalizePayload(result.payload); flyerSource = "event"; refreshLoadedFlyerActions(); - openPublishModal(result.posterUrl); + openPublishModal(result.posterUrl, result.results); } catch (error) { el("publish-status").textContent = error.message; }