From f997c1eef913406a91d5969acd67362656b29d0e Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 27 Jul 2026 21:26:11 +0900 Subject: [PATCH 01/11] Build a Pagefind search index after the site builds Pagefind indexes the built HTML as a post-processing step, so the site keeps working as a set of static files with no search backend. It builds one index per language and the browser picks one by the page's `lang` attribute. A language with no index of its own falls back to whichever index holds the most pages, which is not a choice we control: Ukrainian currently has more pages than English. `SearchIndex` rewrites the bundle entry so those languages point at a fixed index instead. Co-Authored-By: Claude Fable 5 --- .github/workflows/jekyll.yml | 2 + Rakefile | 29 ++++++++- lib/search_index.rb | 57 +++++++++++++++++ package-lock.json | 118 +++++++++++++++++++++++++++++++++++ package.json | 4 +- test/test_search_index.rb | 96 ++++++++++++++++++++++++++++ 6 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 lib/search_index.rb create mode 100644 test/test_search_index.rb diff --git a/.github/workflows/jekyll.yml b/.github/workflows/jekyll.yml index 793a944384..2c288b6097 100644 --- a/.github/workflows/jekyll.yml +++ b/.github/workflows/jekyll.yml @@ -43,6 +43,8 @@ jobs: run: bundle exec jekyll build --baseurl "${{ steps.pages.outputs.base_path }}" env: JEKYLL_ENV: production + - name: Build search index + run: bundle exec rake search-index - name: Upload artifact uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 diff --git a/Rakefile b/Rakefile index 296fbe6d6e..f413cfc0d4 100644 --- a/Rakefile +++ b/Rakefile @@ -19,13 +19,23 @@ task :"build-css" do end desc "Run tests (test-linter, lint, build)" -task test: %i[test-news-plugin test-html-lang-plugin test-linter lint build] +task test: %i[test-news-plugin test-html-lang-plugin test-linter test-search-index lint build] desc "Build the Jekyll site" task build: :"build-css" do require "jekyll" Jekyll::Commands::Build.process({}) + + Rake::Task[:"search-index"].invoke +end + +desc "Build the Pagefind search index over the built site" +task :"search-index" do + require_relative "lib/search_index" + + sh "npm run build-search" + SearchIndex.new.apply_fallbacks end desc "Serve the Jekyll site locally" @@ -35,6 +45,15 @@ task serve: :"build-css" do Jekyll::Commands::Serve.process({}) end +desc "Serve the built site without rebuilding, so its search index survives" +task :"serve-built" do + require "webrick" + + server = WEBrick::HTTPServer.new(Port: 4000, DocumentRoot: "_site") + trap("INT") { server.shutdown } + server.start +end + namespace :new_post do def create_template(lang) @@ -142,3 +161,11 @@ Rake::TestTask.new(:"test-html-lang-plugin") do |t| t.test_files = FileList['test/test_plugin_html_lang.rb'] t.verbose = true end + +require "rake/testtask" +Rake::TestTask.new(:"test-search-index") do |t| + t.description = "Run tests for the search index library" + t.libs = ["test", "lib"] + t.test_files = FileList['test/test_search_index.rb'] + t.verbose = true +end diff --git a/lib/search_index.rb b/lib/search_index.rb new file mode 100644 index 0000000000..0067bc401f --- /dev/null +++ b/lib/search_index.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "json" + +# Post-processes the Pagefind bundle that `npm run build-search` writes into +# `_site/pagefind`. +# +# Pagefind builds one index per `lang` attribute it finds in the built site, and +# the browser picks an index by the `lang` of the page the visitor is on. For a +# language Pagefind cannot index well we skip indexing entirely (the layouts +# withhold `data-pagefind-body`), which leaves that language with no index of +# its own. Pagefind then falls back to whichever index holds the most pages, +# which is not a language we choose and shifts as translations are added. This +# rewrites the bundle entry so those languages point at a fixed index instead. +class SearchIndex + # Languages skipped at index time, mapped to the index they search instead. + # Pagefind has neither stemming nor UI translations for Bulgarian, so + # Bulgarian pages search English content. The layouts and + # `_includes/search.html` must agree with this mapping. + FALLBACKS = { "bg" => "en" }.freeze + + class Error < StandardError; end + + def initialize(bundle_dir = "_site/pagefind") + @entry_path = File.join(bundle_dir, "pagefind-entry.json") + end + + def apply_fallbacks + entry = read_entry + languages = entry["languages"] + raise Error, "No languages in #{@entry_path}, did the site build?" unless languages.is_a?(Hash) && !languages.empty? + + FALLBACKS.each do |from, to| + target = languages[to] + raise Error, "Cannot alias #{from.inspect} to missing index #{to.inspect}" unless target + + if languages.key?(from) + warn "#{from.inspect} was indexed but is meant to fall back to #{to.inspect}; check that the layouts skip it." + end + + languages[from] = target + end + + File.write(@entry_path, JSON.generate(entry)) + entry + end + + private + + def read_entry + JSON.parse(File.read(@entry_path)) + rescue Errno::ENOENT + raise Error, "#{@entry_path} is missing, run `npm run build-search` first" + rescue JSON::ParserError => e + raise Error, "#{@entry_path} is not valid JSON: #{e.message}" + end +end diff --git a/package-lock.json b/package-lock.json index c04f205804..98f58354d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "devDependencies": { "@tailwindcss/typography": "^0.5.19", + "pagefind": "^1.5.2", "tailwindcss": "^3.4.14" } }, @@ -120,6 +121,104 @@ "node": ">= 8" } }, + "node_modules/@pagefind/darwin-arm64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/darwin-arm64/-/darwin-arm64-1.5.2.tgz", + "integrity": "sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@pagefind/darwin-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/darwin-x64/-/darwin-x64-1.5.2.tgz", + "integrity": "sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@pagefind/freebsd-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/freebsd-x64/-/freebsd-x64-1.5.2.tgz", + "integrity": "sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@pagefind/linux-arm64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/linux-arm64/-/linux-arm64-1.5.2.tgz", + "integrity": "sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@pagefind/linux-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/linux-x64/-/linux-x64-1.5.2.tgz", + "integrity": "sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@pagefind/windows-arm64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/windows-arm64/-/windows-arm64-1.5.2.tgz", + "integrity": "sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@pagefind/windows-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/windows-x64/-/windows-x64-1.5.2.tgz", + "integrity": "sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -782,6 +881,25 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pagefind": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/pagefind/-/pagefind-1.5.2.tgz", + "integrity": "sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q==", + "dev": true, + "license": "MIT", + "bin": { + "pagefind": "lib/runner/bin.cjs" + }, + "optionalDependencies": { + "@pagefind/darwin-arm64": "1.5.2", + "@pagefind/darwin-x64": "1.5.2", + "@pagefind/freebsd-x64": "1.5.2", + "@pagefind/linux-arm64": "1.5.2", + "@pagefind/linux-x64": "1.5.2", + "@pagefind/windows-arm64": "1.5.2", + "@pagefind/windows-x64": "1.5.2" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", diff --git a/package.json b/package.json index 8e2ddf3503..4e837d88a9 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,12 @@ "description": "Ruby Language Website with Tailwind CSS", "scripts": { "build-css": "tailwindcss -i ./stylesheets/tailwind.css -o ./stylesheets/compiled.css", - "watch-css": "tailwindcss -i ./stylesheets/tailwind.css -o ./stylesheets/compiled.css --watch" + "watch-css": "tailwindcss -i ./stylesheets/tailwind.css -o ./stylesheets/compiled.css --watch", + "build-search": "pagefind --site _site" }, "devDependencies": { "@tailwindcss/typography": "^0.5.19", + "pagefind": "^1.5.2", "tailwindcss": "^3.4.14" } } diff --git a/test/test_search_index.rb b/test/test_search_index.rb new file mode 100644 index 0000000000..ba869b9088 --- /dev/null +++ b/test/test_search_index.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "helper" +require "json" +require_relative "../lib/search_index" + +describe SearchIndex do + before do + setup_tempdir + @bundle_dir = File.join(TEMP_DIR, "pagefind") + FileUtils.mkdir_p(@bundle_dir) + @entry_path = File.join(@bundle_dir, "pagefind-entry.json") + end + + after do + teardown_tempdir + end + + def write_entry(languages) + File.write(@entry_path, JSON.generate({ "version" => "1.5.2", "languages" => languages })) + end + + def read_entry + JSON.parse(File.read(@entry_path)) + end + + describe "#apply_fallbacks" do + it "points a skipped language at the index it falls back to" do + write_entry({ + "en" => { "hash" => "en_abc", "wasm" => "en", "page_count" => 563 }, + "ja" => { "hash" => "ja_def", "wasm" => nil, "page_count" => 541 }, + }) + + SearchIndex.new(@bundle_dir).apply_fallbacks + + languages = read_entry["languages"] + _(languages["bg"]).must_equal languages["en"] + end + + it "does not fall back to the largest index" do + write_entry({ + "en" => { "hash" => "en_abc", "wasm" => "en", "page_count" => 563 }, + "uk" => { "hash" => "uk_def", "wasm" => nil, "page_count" => 564 }, + }) + + SearchIndex.new(@bundle_dir).apply_fallbacks + + languages = read_entry["languages"] + _(languages["bg"]["hash"]).must_equal "en_abc" + end + + it "leaves the other languages untouched" do + write_entry({ + "en" => { "hash" => "en_abc", "wasm" => "en", "page_count" => 563 }, + "zh-cn" => { "hash" => "zh-cn_ghi", "wasm" => nil, "page_count" => 281 }, + }) + + SearchIndex.new(@bundle_dir).apply_fallbacks + + languages = read_entry["languages"] + _(languages["zh-cn"]["hash"]).must_equal "zh-cn_ghi" + _(languages["en"]["hash"]).must_equal "en_abc" + end + + it "warns when a language meant to be skipped was indexed anyway" do + write_entry({ + "en" => { "hash" => "en_abc", "wasm" => "en", "page_count" => 563 }, + "bg" => { "hash" => "bg_xyz", "wasm" => nil, "page_count" => 60 }, + }) + + _, stderr = capture_io { SearchIndex.new(@bundle_dir).apply_fallbacks } + + _(stderr).must_match(/"bg" was indexed/) + _(read_entry["languages"]["bg"]["hash"]).must_equal "en_abc" + end + + it "raises when the index it falls back to is missing" do + write_entry({ "ja" => { "hash" => "ja_def", "wasm" => nil, "page_count" => 541 } }) + + error = _(-> { SearchIndex.new(@bundle_dir).apply_fallbacks }).must_raise SearchIndex::Error + _(error.message).must_match(/missing index "en"/) + end + + it "raises when the bundle has no languages" do + write_entry({}) + + error = _(-> { SearchIndex.new(@bundle_dir).apply_fallbacks }).must_raise SearchIndex::Error + _(error.message).must_match(/did the site build/) + end + + it "raises when the bundle entry does not exist" do + error = _(-> { SearchIndex.new(@bundle_dir).apply_fallbacks }).must_raise SearchIndex::Error + _(error.message).must_match(/npm run build-search/) + end + end +end From 86d5612e30016ba648470a04b71dffb6a1736dc2 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 27 Jul 2026 21:31:04 +0900 Subject: [PATCH 02/11] Index page and news bodies, and skip Bulgarian Pagefind indexes a whole page unless it is told otherwise, which would put the navigation, the table of contents and the footer into every excerpt. Marking the article element limits it to the content, and the recent-news and pagination blocks inside that element are marked as ignored for the same reason. Bulgarian is left out: Pagefind has neither stemming nor interface translations for it, so its pages get no `data-pagefind-body` and search there runs against English. Which languages those are now lives in _config.yml, so the layouts and `SearchIndex` cannot disagree about it. Co-Authored-By: Claude Fable 5 --- _config.yml | 10 +++++++ _includes/pagination.html | 2 +- _includes/recent_news.html | 2 +- _includes/search_body.html | 13 +++++++++ _layouts/news_post.html | 2 +- _layouts/page.html | 4 +-- lib/search_index.rb | 46 +++++++++++++++++++++----------- test/test_search_index.rb | 54 ++++++++++++++++++++++++++++++-------- 8 files changed, 101 insertions(+), 32 deletions(-) create mode 100644 _includes/search_body.html diff --git a/_config.yml b/_config.yml index 8c2c3738ba..91f0540df4 100644 --- a/_config.yml +++ b/_config.yml @@ -21,6 +21,16 @@ exclude: - tsconfig.json - CLAUDE.md +# Pagefind builds one search index per language and the browser picks one by the +# `lang` of the page it is on. Pagefind has neither word stemming nor interface +# translations for the languages under `unsupported`, so their pages are left +# out of the index and their search runs against `fallback` instead. Both the +# layouts and lib/search_index.rb read these keys. +search: + fallback: en + unsupported: + - bg + url: https://www.ruby-lang.org license: diff --git a/_includes/pagination.html b/_includes/pagination.html index 2df29e7546..d60fd639d8 100644 --- a/_includes/pagination.html +++ b/_includes/pagination.html @@ -8,7 +8,7 @@ {% assign header_parts = page.header | split: '' %} {% for part in header_parts %} {% if part contains 'class="multi-page"' %} - {% break %} diff --git a/_includes/recent_news.html b/_includes/recent_news.html index dcf8bedf4f..a207b8e654 100644 --- a/_includes/recent_news.html +++ b/_includes/recent_news.html @@ -26,7 +26,7 @@ {% endfor %} {% if recent_posts.size > 0 %} -
+

{{ news_locale.recent_news }} diff --git a/_includes/search_body.html b/_includes/search_body.html new file mode 100644 index 0000000000..2a472c70d3 --- /dev/null +++ b/_includes/search_body.html @@ -0,0 +1,13 @@ +{%- comment -%} +Marks the element this is placed on as the only region Pagefind indexes, which +keeps navigation, the table of contents and the footer out of search results. + +Pages in a language listed under `search.unsupported` in _config.yml emit +nothing here. Once Pagefind has seen a `data-pagefind-body` anywhere on the site +it skips every page without one, so leaving the attribute off is what keeps +those pages out of the index. lib/search_index.rb then points the language at +`search.fallback` so its visitors still get results. +{%- endcomment -%} +{%- unless site.search.unsupported contains page.lang -%} +data-pagefind-body data-pagefind-meta="title:{{ page.title | strip_html | escape }}" +{%- endunless -%} diff --git a/_layouts/news_post.html b/_layouts/news_post.html index 41be51e00f..f7f418d40c 100644 --- a/_layouts/news_post.html +++ b/_layouts/news_post.html @@ -45,7 +45,7 @@ -
+
{% include title.html %}
diff --git a/_layouts/page.html b/_layouts/page.html index 2673bd1633..d798a773c2 100644 --- a/_layouts/page.html +++ b/_layouts/page.html @@ -34,7 +34,7 @@ {% if is_homepage %}
-
+
{% include title.html %}
@@ -61,7 +61,7 @@
-
+
{% include title.html %}
diff --git a/lib/search_index.rb b/lib/search_index.rb index 0067bc401f..cf08cf13af 100644 --- a/lib/search_index.rb +++ b/lib/search_index.rb @@ -1,28 +1,33 @@ # frozen_string_literal: true require "json" +require "yaml" # Post-processes the Pagefind bundle that `npm run build-search` writes into # `_site/pagefind`. # # Pagefind builds one index per `lang` attribute it finds in the built site, and -# the browser picks an index by the `lang` of the page the visitor is on. For a -# language Pagefind cannot index well we skip indexing entirely (the layouts -# withhold `data-pagefind-body`), which leaves that language with no index of -# its own. Pagefind then falls back to whichever index holds the most pages, -# which is not a language we choose and shifts as translations are added. This -# rewrites the bundle entry so those languages point at a fixed index instead. +# the browser picks an index by the `lang` of the page the visitor is on. The +# languages under `search.unsupported` in _config.yml are left out of the index +# by the layouts, which leaves them with no index of their own. Pagefind then +# falls back to whichever index holds the most pages, a language we do not pick +# and that shifts as translations are added. This rewrites the bundle entry so +# those languages resolve to `search.fallback` instead. class SearchIndex - # Languages skipped at index time, mapped to the index they search instead. - # Pagefind has neither stemming nor UI translations for Bulgarian, so - # Bulgarian pages search English content. The layouts and - # `_includes/search.html` must agree with this mapping. - FALLBACKS = { "bg" => "en" }.freeze - class Error < StandardError; end - def initialize(bundle_dir = "_site/pagefind") + def initialize(bundle_dir: "_site/pagefind", config_path: "_config.yml") @entry_path = File.join(bundle_dir, "pagefind-entry.json") + @config_path = config_path + end + + # Maps each unsupported language to the language it searches instead. + def fallbacks + config = read_config + fallback = config["fallback"] + raise Error, "search.fallback is not set in #{@config_path}" unless fallback.is_a?(String) + + Array(config["unsupported"]).to_h { |lang| [lang, fallback] } end def apply_fallbacks @@ -30,12 +35,12 @@ def apply_fallbacks languages = entry["languages"] raise Error, "No languages in #{@entry_path}, did the site build?" unless languages.is_a?(Hash) && !languages.empty? - FALLBACKS.each do |from, to| + fallbacks.each do |from, to| target = languages[to] - raise Error, "Cannot alias #{from.inspect} to missing index #{to.inspect}" unless target + raise Error, "Cannot point #{from.inspect} at missing index #{to.inspect}" unless target if languages.key?(from) - warn "#{from.inspect} was indexed but is meant to fall back to #{to.inspect}; check that the layouts skip it." + warn "#{from.inspect} was indexed but is meant to search #{to.inspect}; check that the layouts skip it." end languages[from] = target @@ -47,6 +52,15 @@ def apply_fallbacks private + def read_config + config = YAML.load_file(@config_path)["search"] + raise Error, "No search section in #{@config_path}" unless config.is_a?(Hash) + + config + rescue Errno::ENOENT + raise Error, "#{@config_path} is missing" + end + def read_entry JSON.parse(File.read(@entry_path)) rescue Errno::ENOENT diff --git a/test/test_search_index.rb b/test/test_search_index.rb index ba869b9088..070c75ee73 100644 --- a/test/test_search_index.rb +++ b/test/test_search_index.rb @@ -2,6 +2,7 @@ require "helper" require "json" +require "yaml" require_relative "../lib/search_index" describe SearchIndex do @@ -10,12 +11,18 @@ @bundle_dir = File.join(TEMP_DIR, "pagefind") FileUtils.mkdir_p(@bundle_dir) @entry_path = File.join(@bundle_dir, "pagefind-entry.json") + @config_path = File.join(TEMP_DIR, "_config.yml") + write_config(fallback: "en", unsupported: ["bg"]) end after do teardown_tempdir end + def write_config(search) + File.write(@config_path, YAML.dump({ "search" => search.transform_keys(&:to_s) })) + end + def write_entry(languages) File.write(@entry_path, JSON.generate({ "version" => "1.5.2", "languages" => languages })) end @@ -24,29 +31,46 @@ def read_entry JSON.parse(File.read(@entry_path)) end + def search_index + SearchIndex.new(bundle_dir: @bundle_dir, config_path: @config_path) + end + + describe "#fallbacks" do + it "maps every unsupported language to the fallback language" do + write_config(fallback: "en", unsupported: %w[bg xx]) + + _(search_index.fallbacks).must_equal({ "bg" => "en", "xx" => "en" }) + end + + it "is empty when no language is unsupported" do + write_config(fallback: "en", unsupported: []) + + _(search_index.fallbacks).must_be_empty + end + end + describe "#apply_fallbacks" do - it "points a skipped language at the index it falls back to" do + it "points an unsupported language at the index it falls back to" do write_entry({ "en" => { "hash" => "en_abc", "wasm" => "en", "page_count" => 563 }, "ja" => { "hash" => "ja_def", "wasm" => nil, "page_count" => 541 }, }) - SearchIndex.new(@bundle_dir).apply_fallbacks + search_index.apply_fallbacks languages = read_entry["languages"] _(languages["bg"]).must_equal languages["en"] end - it "does not fall back to the largest index" do + it "does not leave the fallback to the largest index" do write_entry({ "en" => { "hash" => "en_abc", "wasm" => "en", "page_count" => 563 }, "uk" => { "hash" => "uk_def", "wasm" => nil, "page_count" => 564 }, }) - SearchIndex.new(@bundle_dir).apply_fallbacks + search_index.apply_fallbacks - languages = read_entry["languages"] - _(languages["bg"]["hash"]).must_equal "en_abc" + _(read_entry["languages"]["bg"]["hash"]).must_equal "en_abc" end it "leaves the other languages untouched" do @@ -55,7 +79,7 @@ def read_entry "zh-cn" => { "hash" => "zh-cn_ghi", "wasm" => nil, "page_count" => 281 }, }) - SearchIndex.new(@bundle_dir).apply_fallbacks + search_index.apply_fallbacks languages = read_entry["languages"] _(languages["zh-cn"]["hash"]).must_equal "zh-cn_ghi" @@ -68,7 +92,7 @@ def read_entry "bg" => { "hash" => "bg_xyz", "wasm" => nil, "page_count" => 60 }, }) - _, stderr = capture_io { SearchIndex.new(@bundle_dir).apply_fallbacks } + _, stderr = capture_io { search_index.apply_fallbacks } _(stderr).must_match(/"bg" was indexed/) _(read_entry["languages"]["bg"]["hash"]).must_equal "en_abc" @@ -77,20 +101,28 @@ def read_entry it "raises when the index it falls back to is missing" do write_entry({ "ja" => { "hash" => "ja_def", "wasm" => nil, "page_count" => 541 } }) - error = _(-> { SearchIndex.new(@bundle_dir).apply_fallbacks }).must_raise SearchIndex::Error + error = _(-> { search_index.apply_fallbacks }).must_raise SearchIndex::Error _(error.message).must_match(/missing index "en"/) end it "raises when the bundle has no languages" do write_entry({}) - error = _(-> { SearchIndex.new(@bundle_dir).apply_fallbacks }).must_raise SearchIndex::Error + error = _(-> { search_index.apply_fallbacks }).must_raise SearchIndex::Error _(error.message).must_match(/did the site build/) end it "raises when the bundle entry does not exist" do - error = _(-> { SearchIndex.new(@bundle_dir).apply_fallbacks }).must_raise SearchIndex::Error + error = _(-> { search_index.apply_fallbacks }).must_raise SearchIndex::Error _(error.message).must_match(/npm run build-search/) end + + it "raises when the config has no search section" do + write_entry({ "en" => { "hash" => "en_abc", "wasm" => "en", "page_count" => 563 } }) + File.write(@config_path, YAML.dump({ "url" => "https://www.ruby-lang.org" })) + + error = _(-> { search_index.apply_fallbacks }).must_raise SearchIndex::Error + _(error.message).must_match(/No search section/) + end end end From c6b3bdd04d690318938e3834218327163b2027d4 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 27 Jul 2026 21:53:12 +0900 Subject: [PATCH 03/11] Add the search button and modal to the header The button sits next to the language selector and opens a modal; Cmd+K or Ctrl+K opens it from anywhere. Below 640px the top bar has no room for it alongside the longer language names, so it moves into the mobile menu and the two buttons share one modal. Pagefind reads `` for its own interface as well as for the index, so every translation gets its labels and result counts in its own language with no strings to add here. Bulgarian is pointed at English on both counts. Pagefind's dark palette hangs off `data-pf-theme`, which the theme toggle now sets from the same decision that sets `.dark`, and the brand sits on top of it through the `--pf-*` variables. Co-Authored-By: Claude Fable 5 --- _includes/header.html | 10 ++++- _includes/search.html | 25 +++++++++++ _includes/search_assets.html | 19 ++++++++ _layouts/default.html | 1 + _layouts/homepage.html | 1 + javascripts/theme-toggle.js | 8 ++++ stylesheets/components/search.css | 73 +++++++++++++++++++++++++++++++ stylesheets/tailwind.css | 1 + 8 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 _includes/search.html create mode 100644 _includes/search_assets.html create mode 100644 stylesheets/components/search.css diff --git a/_includes/header.html b/_includes/header.html index 58d14236fd..efee5a90c2 100644 --- a/_includes/header.html +++ b/_includes/header.html @@ -26,8 +26,11 @@ {% endfor %} - +
+ + {% include search.html %} + {% include language_selector.html %} @@ -51,6 +54,11 @@