+
{{ 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
{% 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
+
{{ 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
{% 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
+
{{ 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 @@
+
{{ 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
- ';
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 += '
- ${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 "
- ';
} else if (level < currentLevel) {
html += '