diff --git a/Rakefile b/Rakefile index 74a84954d6..5fa60aaab8 100644 --- a/Rakefile +++ b/Rakefile @@ -14,7 +14,7 @@ CONFIG = "_config.yml" task default: [:build] desc "Run tests (test-linter, lint, build)" -task test: %i[test-news-plugin test-linter lint build] +task test: %i[test-news-plugin test-fallback-generator test-translation-status test-linter lint build] desc "Build the Jekyll site" task :build do @@ -129,3 +129,19 @@ Rake::TestTask.new(:"test-news-plugin") do |t| t.test_files = FileList['test/test_plugin_news.rb'] t.verbose = true end + +require "rake/testtask" +Rake::TestTask.new(:"test-fallback-generator") do |t| + t.description = "Run tests for the fallback generator plugin" + t.libs = ["test"] + t.test_files = FileList['test/test_fallback_generator.rb'] + t.verbose = true +end + +require "rake/testtask" +Rake::TestTask.new(:"test-translation-status") do |t| + t.description = "Run tests for the translation status plugin" + t.libs = ["test"] + t.test_files = FileList['test/test_translation_status.rb'] + t.verbose = true +end diff --git a/_data/locales/en.yml b/_data/locales/en.yml index 36cd816829..80730109ff 100644 --- a/_data/locales/en.yml +++ b/_data/locales/en.yml @@ -182,6 +182,7 @@ month_names: posted_by: Posted by AUTHOR on %-d %b %Y translated_by: Translated by +fallback_notice: "This content is not currently available in the language you selected" feed: title: Ruby News diff --git a/_data/locales/ko.yml b/_data/locales/ko.yml index 63e2e14b56..16b77dd723 100644 --- a/_data/locales/ko.yml +++ b/_data/locales/ko.yml @@ -161,6 +161,7 @@ month_names: posted_by: '작성자: AUTHOR (%Y-%m-%d)' translated_by: '번역자:' +fallback_notice: "이 콘텐츠는 아직 한국어 번역이 제공되지 않아 영어로 표시됩니다" feed: title: Ruby 뉴스 diff --git a/_includes/home/news_security.html b/_includes/home/news_security.html index f44ebb86a9..503d838fa9 100644 --- a/_includes/home/news_security.html +++ b/_includes/home/news_security.html @@ -46,13 +46,13 @@

{% for post in news_posts %}
-

+

{{ post.title }}

-
+
{{ post.excerpt | strip_html | truncatewords: 30 }}
@@ -86,7 +86,7 @@

{% for post in security_posts %}
-

+

{{ post.title }} diff --git a/_includes/recent_news.html b/_includes/recent_news.html index dcf8bedf4f..a27fbd6225 100644 --- a/_includes/recent_news.html +++ b/_includes/recent_news.html @@ -36,13 +36,13 @@

{% for post in recent_posts %}
-

+

{{ post.title }}

-

+

{{ post.excerpt | strip_html | truncatewords: 25 }}

diff --git a/_includes/title.html b/_includes/title.html index 038e38503a..68a597ec39 100644 --- a/_includes/title.html +++ b/_includes/title.html @@ -1,5 +1,5 @@ {% if page.header != null %} {{ page.header | markdownify }} {% else %} -

{{ page.title }}

+{{ page.title }}

{% endif %} diff --git a/_layouts/news.html b/_layouts/news.html index c00e1c68cc..e5e1f4121e 100644 --- a/_layouts/news.html +++ b/_layouts/news.html @@ -45,13 +45,13 @@
{% for post in page.posts %}
-

+

{{ post.title }}

-
+
{{ post.excerpt | markdownify }}
diff --git a/_plugins/fallback_generator.rb b/_plugins/fallback_generator.rb new file mode 100644 index 0000000000..3a1ff05f9e --- /dev/null +++ b/_plugins/fallback_generator.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require 'set' + +module Jekyll + class FallbackGenerator < Generator + priority :high + + def generate(site) + @site = site + @languages = site.data['languages'].map { |l| l['code'] } + + fallback_posts + fallback_pages + end + + def fallback_posts + en_posts = @site.posts.docs.select { |p| p.data['lang'] == 'en' } + + existing_posts_by_lang = {} + @site.posts.docs.each do |post| + lang = post.data['lang'] + next unless lang + existing_posts_by_lang[lang] ||= Set.new + existing_posts_by_lang[lang] << File.basename(post.path) + end + + new_posts = [] + en_posts.each do |en_post| + filename = File.basename(en_post.path) + + @languages.each do |lang| + next if lang == 'en' + next if existing_posts_by_lang[lang]&.include?(filename) + + new_posts << create_fallback_doc(en_post, lang) + end + end + + @site.posts.docs.concat(new_posts) + @site.posts.docs.sort! + @site.instance_variable_set(:@categories, nil) + @site.instance_variable_set(:@tags, nil) + end + + def fallback_pages + en_pages = @site.pages.select { |p| p.data['lang'] == 'en' } + + existing_pages_by_lang = {} + @site.pages.each do |page| + lang = page.data['lang'] + next unless lang + existing_pages_by_lang[lang] ||= Set.new + + rel_path = page.path.sub(%r{^#{lang}/}, "") + existing_pages_by_lang[lang] << rel_path + end + + new_pages = [] + en_pages.each do |en_page| + rel_path = en_page.path.sub(%r{^en/}, "") + next if rel_path == en_page.path + + @languages.each do |lang| + next if lang == 'en' + next if existing_pages_by_lang[lang]&.include?(rel_path) + next if rel_path.end_with?(".xml") || rel_path.end_with?(".rss") + + new_pages << create_fallback_page(en_page, lang, rel_path) + end + end + @site.pages.concat(new_pages) + end + + def create_fallback_doc(en_doc, lang) + new_doc = en_doc.clone + new_doc.instance_variable_set(:@data, en_doc.data.dup) + + new_doc.data['lang'] = lang + new_doc.data['fallback'] = true + new_doc.data['content_lang'] = 'en' + new_doc.data['categories'] = [lang] + (en_doc.data['categories'] || []) - ['en'] + + new_path = en_doc.path.sub('/en/', "/#{lang}/") + new_doc.instance_variable_set(:@path, new_path) + + wrap_content(new_doc, en_doc, lang) + new_doc + end + + def create_fallback_page(en_page, lang, rel_path) + new_page = en_page.clone + new_page.instance_variable_set(:@data, en_page.data.dup) + + new_dir = File.join(lang, File.dirname(rel_path)) + new_page.instance_variable_set(:@dir, new_dir) + new_page.instance_variable_set(:@path, File.join(lang, rel_path)) + + new_page.data['lang'] = lang + new_page.data['fallback'] = true + new_page.data['content_lang'] = 'en' + + wrap_content(new_page, en_page, lang) + new_page + end + + def wrap_content(new_obj, en_obj, lang) + notice = @site.data['locales'][lang]['fallback_notice'] rescue nil + notice ||= @site.data['locales']['en']['fallback_notice'] rescue "Translated version not available" + + # Using a combination of Tailwind classes and inline styles to ensure visibility + new_obj.content = <<~HTML +
+ #{notice} +
+
+ #{en_obj.content} +
+ HTML + end + end +end diff --git a/_plugins/translation_status.rb b/_plugins/translation_status.rb index 43a456ce01..120ed34746 100644 --- a/_plugins/translation_status.rb +++ b/_plugins/translation_status.rb @@ -8,7 +8,7 @@ module Jekyll # Outputs HTML. module TranslationStatus - LANGS = %w[en bg de es fr id it ja ko pl pt ru tr ua vi zh_cn zh_tw].freeze + LANGS = %w[en bg de es fr id it ja ko pl pt ru tr uk vi zh_cn zh_tw].freeze START_DATE = "2013-04-01" OK_CHAR = "✓" @@ -107,16 +107,21 @@ def table_row(post) end def render(context) + @posts = Hash.new {|posts, name| posts[name] = Post.new(name) } categories = context.registers[:site].categories ignored_langs = categories.keys - LANGS - ["news"] LANGS.each do |lang| - categories[lang].each do |post| + (categories[lang] || []).each do |post| next if too_old(post.date) + if post.data["fallback"] + # puts "DEBUG: Skipping fallback post #{post.url} for lang #{lang}" + next + end name = post.url.gsub(%r{\A/#{lang}/news/}, "") @posts[name].translations << lang - @posts[name].security = true if post.data["tags"].include?("security") + @posts[name].security = true if post.data["tags"] && post.data["tags"].include?("security") end end diff --git a/javascripts/toc.js b/javascripts/toc.js index 56e13d9adc..83c4d2c848 100644 --- a/javascripts/toc.js +++ b/javascripts/toc.js @@ -31,6 +31,7 @@ } function buildTOCHTML(headings) { + const pageLang = document.documentElement.lang; let html = '
    '; let currentLevel = 2; @@ -39,13 +40,17 @@ const text = heading.textContent; const id = heading.id; + // Check for lang attribute on heading or its ancestors + const lang = heading.getAttribute('lang') || heading.closest('[lang]')?.getAttribute('lang'); + const langAttr = (lang && lang !== pageLang) ? ` lang="${lang}"` : ''; + if (level > currentLevel) { html += '
      '; } else if (level < currentLevel) { html += '
    '; } - html += `
  • ${text}
  • `; + html += `
  • ${text}
  • `; currentLevel = level; }); diff --git a/lib/linter.rb b/lib/linter.rb index 7b7baa49e5..0d9224eb1b 100644 --- a/lib/linter.rb +++ b/lib/linter.rb @@ -18,7 +18,9 @@ class Linter %r{\Aadmin/index\.md}, %r{\A[^/]*/examples/}, %r{\A_includes/}, - %r{\Atest/} + %r{\Atest/}, + %r{\Anode_modules/}, + %r{\A_site/} ].freeze WHITESPACE_EXCLUSIONS = [ diff --git a/stylesheets/compiled.css b/stylesheets/compiled.css index 0539c6d70b..e338fa2301 100644 --- a/stylesheets/compiled.css +++ b/stylesheets/compiled.css @@ -1984,6 +1984,14 @@ body:is(.dark *){ color: rgb(250 250 249 / var(--tw-text-opacity, 1)); } +[lang]:lang(ja),[lang]:lang(ko),[lang]:lang(zh-CN),[lang]:lang(zh-TW) { + font-family: "Plus Jakarta Sans", var(--noto-sans-subset), -apple-system, BlinkMacSystemFont, sans-serif; +} + +[lang]{ + font-family: "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, sans-serif; +} + /* CJK fonts */ html:lang(ja), @@ -4191,6 +4199,11 @@ html:lang(ja), border-color: var(--color-gold-600); } +.border-sky-200{ + --tw-border-opacity: 1; + border-color: rgb(186 230 253 / var(--tw-border-opacity, 1)); +} + .border-stone-200{ --tw-border-opacity: 1; border-color: rgb(231 229 228 / var(--tw-border-opacity, 1)); @@ -4213,6 +4226,11 @@ html:lang(ja), background-color: var(--color-ruby-100); } +.bg-sky-50{ + --tw-bg-opacity: 1; + background-color: rgb(240 249 255 / var(--tw-bg-opacity, 1)); +} + .bg-stone-100{ --tw-bg-opacity: 1; background-color: rgb(245 245 244 / var(--tw-bg-opacity, 1)); @@ -4597,6 +4615,11 @@ html:lang(ja), color: var(--color-text-link); } +.text-sky-800{ + --tw-text-opacity: 1; + color: rgb(7 89 133 / var(--tw-text-opacity, 1)); +} + .text-stone-400{ --tw-text-opacity: 1; color: rgb(168 162 158 / var(--tw-text-opacity, 1)); @@ -5026,6 +5049,11 @@ html:lang(ja), border-color: var(--color-gold-500); } +.dark\:border-sky-800:is(.dark *){ + --tw-border-opacity: 1; + border-color: rgb(7 89 133 / var(--tw-border-opacity, 1)); +} + .dark\:border-stone-600:is(.dark *){ --tw-border-opacity: 1; border-color: rgb(87 83 78 / var(--tw-border-opacity, 1)); @@ -5044,6 +5072,10 @@ html:lang(ja), background-color: var(--color-ruby-800); } +.dark\:bg-sky-900\/30:is(.dark *){ + background-color: rgb(12 74 110 / 0.3); +} + .dark\:bg-stone-700:is(.dark *){ --tw-bg-opacity: 1; background-color: rgb(68 64 60 / var(--tw-bg-opacity, 1)); @@ -5085,6 +5117,11 @@ html:lang(ja), color: var(--color-ruby-500); } +.dark\:text-sky-200:is(.dark *){ + --tw-text-opacity: 1; + color: rgb(186 230 253 / var(--tw-text-opacity, 1)); +} + .dark\:text-stone-100:is(.dark *){ --tw-text-opacity: 1; color: rgb(245 245 244 / var(--tw-text-opacity, 1)); diff --git a/stylesheets/tailwind.css b/stylesheets/tailwind.css index 40bd2d9522..d2a19a7b87 100644 --- a/stylesheets/tailwind.css +++ b/stylesheets/tailwind.css @@ -24,6 +24,10 @@ @apply dark:bg-stone-900 dark:text-stone-50; } + [lang] { + @apply font-default; + } + /* CJK fonts */ html:lang(ja), body:lang(ja), diff --git a/tailwind.config.js b/tailwind.config.js index 72cfbbbf04..0a6cdfafea 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -25,6 +25,12 @@ module.exports = { 'mt-2', 'text-stone-700', 'dark:text-stone-300', + 'bg-sky-50', + 'dark:bg-sky-900/30', + 'border-sky-200', + 'dark:border-sky-800', + 'text-sky-800', + 'dark:text-sky-200', 'transition-colors', // SVG fill for custom stone-770 color 'fill-stone-770', diff --git a/test/test_fallback_generator.rb b/test/test_fallback_generator.rb new file mode 100644 index 0000000000..fb59187b27 --- /dev/null +++ b/test/test_fallback_generator.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require "helper" +require "jekyll" +require "yaml" +require_relative "../_plugins/fallback_generator" + +describe Jekyll::FallbackGenerator do + let(:actual_languages_path) { File.expand_path("../_data/languages.yml", __dir__) } + + before do + chdir_tempdir + + # 1. Setup config + create_file("source/_config.yml", <<~CONFIG) + markdown: kramdown + permalink: pretty + CONFIG + + # 2. Setup languages data from actual file + add a test language 'xz' + # 'xz' is a virtual language guaranteed to have no translation or locale file, + # ensuring the test for fallback to English notice remains robust. + actual_langs = YAML.load_file(actual_languages_path) + actual_langs << { 'code' => 'xz', 'name' => 'Test Language', 'native_name' => 'Test' } + create_file("source/_data/languages.yml", YAML.dump(actual_langs)) + + # 3. Setup locales from actual files + locales_dir = File.expand_path("../_data/locales", __dir__) + Dir.glob("#{locales_dir}/*.yml").each do |actual_locale_path| + filename = File.basename(actual_locale_path) + create_file("source/_data/locales/#{filename}", File.read(actual_locale_path)) + end + + # 4. Setup layouts + create_file("source/_layouts/default.html", "{{ content }}") + create_file("source/_layouts/news.html", "{{ content }}") + + # 5. Create an English post + create_file("source/en/news/_posts/2025-12-25-ruby-4-0-0.md", <<~MARKDOWN) + --- + layout: news + title: "Ruby 4.0.0 Released" + lang: en + author: "matz" + --- + Ruby 4.0.0 is finally here! + MARKDOWN + + # 6. Create an English page + create_file("source/en/about.md", <<~MARKDOWN) + --- + layout: default + title: "About Ruby" + lang: en + --- + Ruby is a dynamic, open source programming language. + MARKDOWN + + # 7. Create existing posts/pages to test exclusion + # Korean post + create_file("source/ko/news/_posts/2025-12-25-ruby-4-0-0.md", <<~MARKDOWN) + --- + layout: news + title: "루비 4.0.0 출시" + lang: ko + author: "matz" + --- + 기존 한국어 콘텐츠입니다. + MARKDOWN + + # Japanese content (Plausibility: Ruby is a Japanese language) + create_file("source/ja/news/_posts/2025-12-25-ruby-4-0-0.md", <<~MARKDOWN) + --- + layout: news + title: "Ruby 4.0.0 リリース" + lang: ja + author: "matz" + --- + 既存の日本語コンテンツです。 + MARKDOWN + + create_file("source/ja/about.md", <<~MARKDOWN) + --- + layout: default + title: "Rubyについて" + lang: ja + --- + Rubyは、オープンソースの動的なプログラミング言語です。 + MARKDOWN + + @config = Jekyll.configuration( + "source" => "source", + "destination" => "_site", + "quiet" => true + ) + @site = Jekyll::Site.new(@config) + @site.process + end + + after do + teardown_tempdir + end + + it "should generate fallback posts for all languages defined in languages.yml" do + @site.data['languages'].map { |l| l['code'] }.each do |lang| + next if lang == 'en' + next if lang == 'ko' # Existing one + next if lang == 'ja' # Existing one + + # Verify that the fallback post document exists in Jekyll's internal state + post = @site.posts.docs.find { |d| d.data['lang'] == lang && d.data['fallback'] } + _(post).wont_be_nil "Fallback post for #{lang} should be generated" + + # Verify the path and URL + _(post.url).must_match %r{/#{lang}/news/2025/12/25/ruby-4-0-0/} + + # Verify output file exists + file_must_exist("_site/#{lang}/news/2025/12/25/ruby-4-0-0/index.html") + end + end + + it "should NOT overwrite existing translated posts" do + # Korean + ko_post = @site.posts.docs.find { |d| d.data['lang'] == 'ko' && File.basename(d.path) == '2025-12-25-ruby-4-0-0.md' } + _(ko_post).wont_be_nil + _(ko_post.data['fallback']).must_be_nil + _(File.read("_site/ko/news/2025/12/25/ruby-4-0-0/index.html")).must_match "기존 한국어 콘텐츠입니다" + + # Japanese + ja_post = @site.posts.docs.find { |d| d.data['lang'] == 'ja' && File.basename(d.path) == '2025-12-25-ruby-4-0-0.md' } + _(ja_post).wont_be_nil + _(ja_post.data['fallback']).must_be_nil + _(File.read("_site/ja/news/2025/12/25/ruby-4-0-0/index.html")).must_match "既存の日本語コンテンツです" + end + + it "should generate fallback pages for all languages defined in languages.yml" do + @site.data['languages'].map { |l| l['code'] }.each do |lang| + next if lang == 'en' + next if lang == 'ja' # Existing one + + page = @site.pages.find { |p| p.data['lang'] == lang && p.data['fallback'] && p.path.include?("about.md") } + _(page).wont_be_nil "Fallback page for #{lang} should be generated" + file_must_exist("_site/#{lang}/about/index.html") + end + end + + it "should wrap content with notice box and lang='en' tag" do + # Check Korean fallback page (to test localization) + # ko/about.md doesn't exist in source, so it should be generated as a fallback + ko_page = File.read("_site/ko/about/index.html") + _(ko_page).must_match "fallback-notice" + _(ko_page).must_match "bg-sky-50" + # Actual content from ko.yml + _(ko_page).must_match "이 콘텐츠는 아직 한국어 번역이 제공되지 않아 영어로 표시됩니다" + _(ko_page).must_match "
    "source", + "destination" => "_site", + "quiet" => true + ) + @site = Jekyll::Site.new(@config) + @site.process + end + + after do + teardown_tempdir + end + + it "should NOT count fallback posts as translated" do + # Verify fallback post for French was created + fr_post = @site.posts.docs.find { |d| d.data['lang'] == 'fr' && d.data['fallback'] } + _(fr_post).wont_be_nil + + # Render twice to check for data leakage + template = Liquid::Template.parse("{% translation_status %}") + template.render!({}, registers: { site: @site }) + result = template.render!({}, registers: { site: @site }) + + # 'fr' should NOT have the OK_CHAR (✓) for this post + + # Let's see the LANGS in the plugin + langs = Jekyll::TranslationStatus::LANGS + _(langs).must_include "fr" + _(langs).must_include "ja" + + # Find the row for our post + rows = result.scan(/.*?<\/td><\/tr>/m) + post_row = rows.find { |r| r.include?("test-post") } + _(post_row).wont_be_nil + + # Check for ✓ in ja and fr columns + ja_idx = langs.index("ja") + fr_idx = langs.index("fr") + uk_idx = langs.index("uk") + + cells = post_row.scan(/(.*?)<\/td>/).flatten + + _(cells[ja_idx + 1]).must_include "✓" # Japanese is translated + _(cells[fr_idx + 1]).wont_include "✓" # French is a fallback, should be empty + _(uk_idx).wont_be_nil # Ukrainian should be present as 'uk' + end +end