|
| 1 | +module ViewComponents |
| 2 | + # Temporary, pre-catalog promo copy for the Big Jiki & Exercism Translatathon. |
| 3 | + # |
| 4 | + # This is the server-side sibling of the Jiki front-end's TranslatathonBanner. |
| 5 | + # The whole point is to reach speakers of languages we do NOT ship in the UI, |
| 6 | + # so its copy can't come from an i18n catalog: it lives here. The site-header |
| 7 | + # helper renders it as an announcement bar for signed-in users. |
| 8 | + # |
| 9 | + # It targets the first non-catalog language in the viewer's own preference list. |
| 10 | + # On the front-end that list is `navigator.languages`; here it's the ordered |
| 11 | + # `Accept-Language` request header (which encodes the same preference via q-values): |
| 12 | + # - a language we run in the Translatathon program (COPY below) gets the whole |
| 13 | + # banner IN that language; |
| 14 | + # - any other non-English language we can name gets the English banner with its |
| 15 | + # name filled in. |
| 16 | + # |
| 17 | + # The banner self-removes after the event (see END_TIME). |
| 18 | + # |
| 19 | + # NOTE: the translated strings below were drafted for the event and should be |
| 20 | + # sanity-checked by native speakers / the Translatathon translators. |
| 21 | + class TranslatathonBanner |
| 22 | + # The resolved banner to render: pre/link/post are the sentence around the |
| 23 | + # linked call-to-action, url is where it points, dir is "ltr" or "rtl". |
| 24 | + Banner = Struct.new(:pre, :link, :post, :url, :dir, keyword_init: true) |
| 25 | + |
| 26 | + # Where the call-to-action goes (opens in a new tab). |
| 27 | + TRANSLATATHON_URL = "https://i18n.jiki.io".freeze |
| 28 | + |
| 29 | + # The event weekend is 31 Jul - 2 Aug 2026; keep the banner up through 3 Aug. |
| 30 | + # After this instant it never renders. Delete the whole class once the event |
| 31 | + # is well past. |
| 32 | + END_TIME = Time.utc(2026, 8, 4) |
| 33 | + |
| 34 | + # Base languages we already ship in the UI: their speakers have nothing to |
| 35 | + # translate to, so we never show them the banner. Exercism's UI is English, |
| 36 | + # so English is the only catalog language. |
| 37 | + CATALOG_LANGUAGES = Set.new(%w[en]).freeze |
| 38 | + |
| 39 | + # Program languages that read right-to-left; the banner element gets dir="rtl". |
| 40 | + RTL_TARGETS = Set.new(%w[ar fa ur]).freeze |
| 41 | + |
| 42 | + # Fully-translated copy for each language in the Translatathon program, keyed by |
| 43 | + # the program's own locale ids. Multi-variant languages have one entry per |
| 44 | + # variant (es-419/es-ES, pt-BR/pt-pt, zh-CN/zh-TW); #program_locale maps a |
| 45 | + # browser tag to the right one. Each string is written in that language, with a |
| 46 | + # translated phrase for the translation session; only "Exercism" stays as a |
| 47 | + # proper noun. |
| 48 | + COPY = { |
| 49 | + "ar" => { pre: "هل تريد مساعدتنا في ترجمة Exercism إلى العربية؟ ", link: "انضمّ إلى جلسة الترجمة", post: "" }, |
| 50 | + "bn" => { pre: "Exercism-কে বাংলায় অনুবাদ করতে আমাদের সাহায্য করতে চান? ", link: "অনুবাদ সেশনে যোগ দিন", post: "" }, |
| 51 | + "ca" => { pre: "Vols ajudar-nos a traduir Exercism al català? ", link: "Uneix-te a la sessió de traducció", post: "" }, |
| 52 | + "de" => { pre: "Möchtest du uns helfen, Exercism ins Deutsche zu übersetzen? ", link: "Mach bei der Übersetzungssession mit", post: "" }, # rubocop:disable Layout/LineLength |
| 53 | + "el" => { pre: "Θέλεις να μας βοηθήσεις να μεταφράσουμε το Exercism στα Ελληνικά; ", link: "Λάβε μέρος στη μεταφραστική συνεδρία", post: "" }, # rubocop:disable Layout/LineLength |
| 54 | + "es-419" => { pre: "¿Quieres ayudarnos a traducir Exercism al español? ", link: "Únete a la sesión de traducción", post: "" }, |
| 55 | + "es-ES" => { pre: "¿Quieres ayudarnos a traducir Exercism al español? ", link: "Únete a la sesión de traducción", post: "" }, |
| 56 | + "fa" => { pre: "میخواهید در ترجمهٔ Exercism به فارسی به ما کمک کنید؟ ", link: "به نشست ترجمه بپیوندید", post: "" }, |
| 57 | + "fr" => { pre: "Vous voulez nous aider à traduire Exercism en français ? ", link: "Rejoignez la session de traduction", post: "" }, # rubocop:disable Layout/LineLength |
| 58 | + "hi" => { pre: "क्या आप Exercism का हिन्दी में अनुवाद करने में हमारी मदद करना चाहते हैं? ", link: "अनुवाद सत्र में शामिल हों", post: "" }, # rubocop:disable Layout/LineLength |
| 59 | + "id" => { pre: "Ingin membantu kami menerjemahkan Exercism ke Bahasa Indonesia? ", link: "Ikuti sesi terjemahan", post: "" }, |
| 60 | + "it" => { pre: "Vuoi aiutarci a tradurre Exercism in italiano? ", link: "Partecipa alla sessione di traduzione", post: "" }, |
| 61 | + "ja" => { pre: "Exercism を日本語に翻訳するのを手伝いませんか?", link: "翻訳セッションに参加する", post: "" }, |
| 62 | + "ko" => { pre: "Exercism을 한국어로 번역하는 데 도움을 주시겠어요? ", link: "번역 세션에 참여하세요", post: "" }, |
| 63 | + "nl" => { pre: "Wil je ons helpen Exercism naar het Nederlands te vertalen? ", link: "Doe mee aan de vertaalsessie", post: "" }, |
| 64 | + "pl" => { pre: "Chcesz pomóc nam przetłumaczyć Exercism na polski? ", link: "Dołącz do sesji tłumaczeniowej", post: "" }, |
| 65 | + "pt-BR" => { pre: "Quer nos ajudar a traduzir o Exercism para o português? ", link: "Participe da sessão de tradução", post: "" }, # rubocop:disable Layout/LineLength |
| 66 | + "pt-pt" => { pre: "Quer ajudar-nos a traduzir o Exercism para português? ", link: "Junte-se à sessão de tradução", post: "" }, |
| 67 | + "ru" => { pre: "Хотите помочь нам перевести Exercism на русский? ", link: "Присоединяйтесь к сессии перевода", post: "" }, |
| 68 | + "sr" => { pre: "Желите да нам помогнете да преведемо Exercism на српски? ", link: "Придружите се преводилачкој сесији", post: "" }, # rubocop:disable Layout/LineLength |
| 69 | + "sw" => { pre: "Ungependa kutusaidia kutafsiri Exercism kwa Kiswahili? ", link: "Jiunge na kikao cha tafsiri", post: "" }, |
| 70 | + "tr" => { pre: "Exercism'i Türkçeye çevirmemize yardım etmek ister misin? ", link: "Çeviri oturumuna katıl", post: "" }, |
| 71 | + "uk" => { pre: "Хочете допомогти нам перекласти Exercism українською? ", link: "Долучайтеся до сесії перекладу", post: "" }, |
| 72 | + "ur" => { pre: "کیا آپ Exercism کا اردو میں ترجمہ کرنے میں ہماری مدد کرنا چاہتے ہیں؟ ", link: "ترجمے کے سیشن میں شامل ہوں", post: "" }, # rubocop:disable Layout/LineLength |
| 73 | + "vi" => { pre: "Bạn muốn giúp chúng tôi dịch Exercism sang Tiếng Việt? ", link: "Tham gia buổi dịch thuật", post: "" }, |
| 74 | + "zh-CN" => { pre: "想帮我们把 Exercism 翻译成中文吗?", link: "加入翻译活动", post: "" }, |
| 75 | + "zh-TW" => { pre: "想幫我們把 Exercism 翻譯成中文嗎?", link: "加入翻譯活動", post: "" } |
| 76 | + }.freeze |
| 77 | + |
| 78 | + # English display names for base language codes, used to fill in the English |
| 79 | + # fallback banner ("Want to help us translate Exercism to <name>?"). Ruby has no |
| 80 | + # Intl.DisplayNames, so we carry the names we care about. A code we can't name |
| 81 | + # gets no banner (we never render "translate Exercism to xyz"). |
| 82 | + LANGUAGE_NAMES = { |
| 83 | + "af" => "Afrikaans", "am" => "Amharic", "ar" => "Arabic", "az" => "Azerbaijani", |
| 84 | + "be" => "Belarusian", "bg" => "Bulgarian", "bn" => "Bengali", "bs" => "Bosnian", |
| 85 | + "ca" => "Catalan", "cs" => "Czech", "cy" => "Welsh", "da" => "Danish", |
| 86 | + "de" => "German", "el" => "Greek", "es" => "Spanish", "et" => "Estonian", |
| 87 | + "eu" => "Basque", "fa" => "Persian", "fi" => "Finnish", "fil" => "Filipino", |
| 88 | + "fr" => "French", "ga" => "Irish", "gl" => "Galician", "gu" => "Gujarati", |
| 89 | + "he" => "Hebrew", "hi" => "Hindi", "hr" => "Croatian", "ht" => "Haitian Creole", |
| 90 | + "hy" => "Armenian", "id" => "Indonesian", "is" => "Icelandic", "it" => "Italian", |
| 91 | + "ja" => "Japanese", "ka" => "Georgian", "kk" => "Kazakh", "km" => "Khmer", |
| 92 | + "kn" => "Kannada", "ko" => "Korean", "lo" => "Lao", "lt" => "Lithuanian", |
| 93 | + "lv" => "Latvian", "mk" => "Macedonian", "ml" => "Malayalam", "mn" => "Mongolian", |
| 94 | + "mr" => "Marathi", "ms" => "Malay", "my" => "Burmese", "nb" => "Norwegian", |
| 95 | + "ne" => "Nepali", "nl" => "Dutch", "nn" => "Norwegian", "no" => "Norwegian", |
| 96 | + "pa" => "Punjabi", "pl" => "Polish", "pt" => "Portuguese", "ro" => "Romanian", |
| 97 | + "ru" => "Russian", "si" => "Sinhala", "sk" => "Slovak", "sl" => "Slovenian", |
| 98 | + "sq" => "Albanian", "sr" => "Serbian", "sv" => "Swedish", "sw" => "Swahili", |
| 99 | + "ta" => "Tamil", "te" => "Telugu", "th" => "Thai", "tl" => "Filipino", |
| 100 | + "tr" => "Turkish", "uk" => "Ukrainian", "ur" => "Urdu", "uz" => "Uzbek", |
| 101 | + "vi" => "Vietnamese", "zh" => "Chinese", "zu" => "Zulu" |
| 102 | + }.freeze |
| 103 | + |
| 104 | + def self.call(...) = new(...).() |
| 105 | + |
| 106 | + def initialize(accept_language_header, now: Time.current) |
| 107 | + @accept_language_header = accept_language_header.to_s |
| 108 | + @now = now |
| 109 | + end |
| 110 | + |
| 111 | + # Returns a Banner to render, or nil when nothing applies. |
| 112 | + def call |
| 113 | + return nil if now >= END_TIME |
| 114 | + |
| 115 | + resolve(preferred_languages) |
| 116 | + end |
| 117 | + |
| 118 | + private |
| 119 | + attr_reader :accept_language_header, :now |
| 120 | + |
| 121 | + # Walk the viewer's language list in order, take the first language we don't |
| 122 | + # already ship, and return either its in-program translated banner or the |
| 123 | + # English fallback with its name filled in. The first non-catalog language |
| 124 | + # decides: if we can't name it, we show nothing. |
| 125 | + def resolve(languages) |
| 126 | + languages.each do |tag| |
| 127 | + base = tag.split("-").first&.downcase |
| 128 | + next if base.nil? || CATALOG_LANGUAGES.include?(base) |
| 129 | + |
| 130 | + key = program_locale(tag, base) |
| 131 | + return program_banner(key, base) if key |
| 132 | + |
| 133 | + return english_banner(base) |
| 134 | + end |
| 135 | + nil |
| 136 | + end |
| 137 | + |
| 138 | + def program_banner(key, base) |
| 139 | + copy = COPY.fetch(key) |
| 140 | + Banner.new( |
| 141 | + pre: copy[:pre], link: copy[:link], post: copy[:post], |
| 142 | + url: TRANSLATATHON_URL, |
| 143 | + dir: RTL_TARGETS.include?(base) ? "rtl" : "ltr" |
| 144 | + ) |
| 145 | + end |
| 146 | + |
| 147 | + # The English fallback banner for a non-program language, with the language's |
| 148 | + # English name filled in. Returns nil when we have no name for the code. |
| 149 | + def english_banner(base) |
| 150 | + name = LANGUAGE_NAMES[base] |
| 151 | + return nil unless name |
| 152 | + |
| 153 | + Banner.new( |
| 154 | + pre: "Want to help us translate Exercism to #{name}? ", |
| 155 | + link: "Join the Translatathon", |
| 156 | + post: "", |
| 157 | + url: TRANSLATATHON_URL, |
| 158 | + dir: "ltr" |
| 159 | + ) |
| 160 | + end |
| 161 | + |
| 162 | + # Map a browser language tag to the program locale id whose copy we show, or |
| 163 | + # nil if the language isn't in the program. Multi-variant languages collapse by |
| 164 | + # region/script; every other program language is keyed by its bare base code. |
| 165 | + def program_locale(tag, base) |
| 166 | + case base |
| 167 | + when "es" |
| 168 | + region(tag) == "ES" ? "es-ES" : "es-419" |
| 169 | + when "pt" |
| 170 | + region(tag) == "PT" ? "pt-pt" : "pt-BR" |
| 171 | + when "zh" |
| 172 | + r = region(tag) |
| 173 | + tag.match?(/hant/i) || %w[TW HK MO].include?(r) ? "zh-TW" : "zh-CN" |
| 174 | + else |
| 175 | + COPY.key?(base) ? base : nil |
| 176 | + end |
| 177 | + end |
| 178 | + |
| 179 | + # The region subtag (first 2-alpha or 3-digit subtag), uppercased, or nil. |
| 180 | + def region(tag) |
| 181 | + tag.split("-").drop(1).find { |part| part.match?(/\A([A-Za-z]{2}|\d{3})\z/) }&.upcase |
| 182 | + end |
| 183 | + |
| 184 | + # The Accept-Language header parsed into an ordered list of language tags, |
| 185 | + # highest q-value first (ties keep header order), dropping "*" and empties. |
| 186 | + def preferred_languages |
| 187 | + weighted = accept_language_header.split(",").filter_map.with_index do |part, index| |
| 188 | + tag, *params = part.strip.split(";") |
| 189 | + tag = tag.to_s.strip |
| 190 | + next if tag.empty? || tag == "*" |
| 191 | + |
| 192 | + quality = 1.0 |
| 193 | + params.each do |param| |
| 194 | + key, value = param.split("=", 2) |
| 195 | + quality = value.to_f if key.to_s.strip == "q" |
| 196 | + end |
| 197 | + [tag, quality, index] |
| 198 | + end |
| 199 | + |
| 200 | + weighted.sort_by { |_tag, quality, index| [-quality, index] }.map(&:first) |
| 201 | + end |
| 202 | + end |
| 203 | +end |
0 commit comments