Skip to content

Commit f1c82f6

Browse files
iHiDclaudedem4ron
authored
Add temporary pre-catalog Translatathon promo banner (#9307)
* Add temporary pre-catalog Translatathon promo banner Recruit translators for the Big Jiki & Exercism Translatathon via the site-header announcement bar, shown to signed-in users only. This is the server-side sibling of the Jiki front-end's TranslatathonBanner (jiki-education/front-end#990). Rather than reaching for navigator.languages in JS, ViewComponents::TranslatathonBanner resolves the ordered Accept-Language request header: it takes the first non-catalog language (English is the only catalog language on Exercism) and returns either the banner fully translated into a Translatathon-program language, or an English fallback with the language's name filled in. It self-removes after the event (END_TIME). Wired into SiteHeader#announcement_bar following the existing bar pattern; no dismissal or client-side rendering since the announcement bar is server-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WxnG32jG7Zdy2Uu6qFCPjp * Use "Exercism" not "Jiki" in Translatathon banner copy The banner lives on the Exercism site, so the recruiting copy should name Exercism as the thing being translated. Swap "Jiki" for "Exercism" in every translated string and the English fallback, adjusting the Korean object particle (를 -> 을) and Turkish accusative ('yi -> 'i) for the new consonant ending. The event name and front-end reference in the comments are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WxnG32jG7Zdy2Uu6qFCPjp * Fix Translatathon banner emoji spacing for RTL Use margin-inline-end instead of physical mr-6 so the gap sits between the globe emoji and the text in both LTR and RTL banners (previously the emoji jammed against the text in RTL). Tightened to 8px. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: dem4ron <demaaron88@gmail.com>
1 parent 9c49367 commit f1c82f6

3 files changed

Lines changed: 313 additions & 0 deletions

File tree

app/helpers/view_components/site_header.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ def html
4242
def announcement_bar
4343
# return downtime_announcement_bar if user_signed_in?
4444

45+
return translatathon_announcement_bar if user_signed_in? && translatathon_banner
46+
4547
return coding_fundamentals_announcement_bar unless user_signed_in?
4648
return front_end_fundamentals_announcement_bar if javascript_track? && current_user.seniority != :absolute_beginner
4749
return coding_fundamentals_announcement_bar if current_user.junior?
@@ -58,6 +60,25 @@ def announcement_bar
5860
end
5961
end
6062

63+
memoize
64+
def translatathon_banner
65+
ViewComponents::TranslatathonBanner.(request.headers["Accept-Language"])
66+
end
67+
68+
def translatathon_announcement_bar
69+
banner = translatathon_banner
70+
link_to(banner.url, class: "announcement-bar md:block hidden", target: "_blank", rel: "noopener", dir: banner.dir) do
71+
tag.div(class: "lg-container") do
72+
# margin-inline-end (not mr-6) so the gap sits between the emoji and the
73+
# text in both LTR and RTL banners.
74+
tag.span("🌍", class: 'emoji', style: 'margin-inline-end:8px') +
75+
tag.span(banner.pre) +
76+
tag.strong(banner.link) +
77+
tag.span(banner.post)
78+
end
79+
end
80+
end
81+
6182
def downtime_announcement_bar
6283
link_to("https://forum.exercism.org/t/scheduled-maintenance/18062", class: "announcement-bar md:block hidden") do
6384
tag.div(class: "lg-container") do
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
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
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
require "test_helper"
2+
3+
class ViewComponents::TranslatathonBannerTest < ActiveSupport::TestCase
4+
# Before the event ends, so the banner is live.
5+
BEFORE_END = Time.utc(2026, 8, 1)
6+
7+
def resolve(header, now: BEFORE_END)
8+
ViewComponents::TranslatathonBanner.(header, now:)
9+
end
10+
11+
test "returns nil for English speakers (catalog language)" do
12+
assert_nil resolve("en-US,en;q=0.9")
13+
end
14+
15+
test "returns nil for a blank header" do
16+
assert_nil resolve("")
17+
assert_nil resolve(nil)
18+
end
19+
20+
test "returns nil once the event is over" do
21+
assert_nil resolve("fr-FR", now: Time.utc(2026, 8, 4))
22+
assert_nil resolve("fr-FR", now: Time.utc(2026, 9, 1))
23+
end
24+
25+
test "shows the program banner in the target language" do
26+
banner = resolve("fr-FR,fr;q=0.9,en;q=0.8")
27+
28+
assert_equal "Vous voulez nous aider à traduire Exercism en français ? ", banner.pre
29+
assert_equal "Rejoignez la session de traduction", banner.link
30+
assert_equal "https://i18n.jiki.io", banner.url
31+
assert_equal "ltr", banner.dir
32+
end
33+
34+
test "sets rtl for right-to-left program languages" do
35+
assert_equal "rtl", resolve("ar").dir
36+
assert_equal "rtl", resolve("fa-IR").dir
37+
assert_equal "rtl", resolve("ur").dir
38+
end
39+
40+
test "picks the first non-catalog language, skipping English" do
41+
banner = resolve("en-GB,en;q=0.9,de;q=0.8")
42+
43+
assert_equal "Möchtest du uns helfen, Exercism ins Deutsche zu übersetzen? ", banner.pre
44+
end
45+
46+
test "orders by q-value, not header order" do
47+
# de has the higher quality despite appearing after fr.
48+
banner = resolve("fr;q=0.5,de;q=0.9")
49+
50+
assert_equal "Mach bei der Übersetzungssession mit", banner.link
51+
end
52+
53+
test "falls back to English copy with the language name for non-program languages" do
54+
banner = resolve("sv-SE,sv;q=0.9")
55+
56+
assert_equal "Want to help us translate Exercism to Swedish? ", banner.pre
57+
assert_equal "Join the Translatathon", banner.link
58+
assert_equal "ltr", banner.dir
59+
end
60+
61+
test "returns nil when the first non-catalog language has no name" do
62+
assert_nil resolve("xx-YY")
63+
end
64+
65+
test "maps Spanish variants to es-ES and es-419" do
66+
assert_equal "es-ES", program_locale("es-ES", "es")
67+
assert_equal "es-419", program_locale("es-MX", "es")
68+
assert_equal "es-419", program_locale("es", "es")
69+
end
70+
71+
test "maps Portuguese variants to pt-pt and pt-BR" do
72+
assert_equal "pt-pt", program_locale("pt-PT", "pt")
73+
assert_equal "pt-BR", program_locale("pt-BR", "pt")
74+
assert_equal "pt-BR", program_locale("pt", "pt")
75+
end
76+
77+
test "maps Chinese variants to zh-TW and zh-CN" do
78+
assert_equal "zh-TW", program_locale("zh-TW", "zh")
79+
assert_equal "zh-TW", program_locale("zh-HK", "zh")
80+
assert_equal "zh-TW", program_locale("zh-Hant", "zh")
81+
assert_equal "zh-CN", program_locale("zh-CN", "zh")
82+
assert_equal "zh-CN", program_locale("zh", "zh")
83+
end
84+
85+
private
86+
def program_locale(tag, base)
87+
ViewComponents::TranslatathonBanner.new("").send(:program_locale, tag, base)
88+
end
89+
end

0 commit comments

Comments
 (0)