diff --git a/CLAUDE.md b/CLAUDE.md index a2c76529b2..dad734ef8a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,10 +11,10 @@ This is the Jekyll-based source for the official Ruby programming language websi ### Jekyll Site Operations ```bash -# Build the site (takes several minutes) +# Build the site and its search index (takes several minutes) bundle exec rake build -# Serve locally at http://localhost:4000/ +# Build, watch and serve at http://localhost:4000/, search index included bundle exec rake serve # Alternative: Jekyll direct serve with incremental builds @@ -40,6 +40,7 @@ bundle exec rake test # Run individual test suites bundle exec rake test-news-plugin # News archive plugin tests bundle exec rake test-linter # Linter library tests +bundle exec rake test-search-index # Search index library tests # Linting bundle exec rake lint # Markdown linter @@ -110,6 +111,31 @@ The news system is powered by a custom Jekyll plugin (`_plugins/news.rb`): - **Archive pages**: Index, yearly archives, monthly archives - **RSS feeds**: Generated per language via `news_feed.rss` layout +### Search (Pagefind) + +Search is client-side and needs no backend. `_plugins/search_index.rb` hooks +Jekyll's `post_write` and calls `lib/search_index.rb`, so every build and every +regeneration under `rake serve` ends with a matching index. It has to run after +the write because Pagefind reads the generated HTML, and it has to run every +time because Jekyll deletes destination files with no source counterpart, the +previous bundle included. Without Node installed the hook warns and skips. + +- **Indexed region**: `_includes/search_body.html` emits `data-pagefind-body` on + the `
` of `page.html` and `news_post.html`. Everything outside it, and + anything marked `data-pagefind-ignore` inside it, stays out of results. +- **Per-language indexes**: Pagefind splits the index by the `lang` attribute on + `` and the browser loads the one matching the page, so a translation only + ever finds its own pages. The same attribute picks the interface language, so + no UI strings live in `_data/locales`. +- **Unsupported languages**: `search.unsupported` in `_config.yml` lists the + languages Pagefind cannot index well (currently `bg`). Their pages get no + `data-pagefind-body`, and `lib/search_index.rb` rewrites `pagefind-entry.json` + so they resolve to `search.fallback`. Without that rewrite Pagefind would fall + back to whichever index has the most pages, which is not a language we choose. +- **UI**: `_includes/search.html` (button, modal, config) and + `_includes/search_assets.html` (bundle, loaded before `compiled.css` so the + site's `--pf-*` overrides win). + ### Linter (`lib/linter.rb`) Enforces strict content quality rules: @@ -179,6 +205,7 @@ lang: en **Node (package.json):** - `tailwindcss` - CSS framework - `@tailwindcss/typography` - Prose styling plugin +- `pagefind` - Static search index and UI ## Important Conventions diff --git a/README.md b/README.md index 95fc226a6c..82ed2d9675 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,28 @@ npm run watch-css # watch and rebuild CSS automatically **Note:** You need to have Node.js installed to run these commands. +## Search + +Search is [Pagefind](https://pagefind.app/), which indexes the HTML Jekyll +produced rather than the Markdown behind it, so it needs no server of its own. +`_plugins/search_index.rb` runs it whenever Jekyll finishes writing the site, +which means `rake build` and `rake serve` both leave you with a search index +matching the site they just produced. Nothing extra to run. + +Indexing adds a few seconds to a build and needs Node, so run `npm install` +first. Without it `bundle exec jekyll build` still finishes and only reports that +the index was skipped, which is enough for editing content. The `rake` tasks +stop earlier than that, since they build the CSS with Tailwind first. + +Pagefind builds one index per language and picks one by the `lang` attribute of +the page the visitor is on, so each translation searches its own pages and reads +its own interface labels. Bulgarian is the exception: Pagefind has neither word +stemming nor interface translations for it, so `search.unsupported` in +`_config.yml` keeps its pages out of the index and its search runs against +English instead. Adding a language there is all it takes to do the same for +another one. + + ## Testing Besides generating and previewing the site diff --git a/Rakefile b/Rakefile index 296fbe6d6e..f75e7289fe 100644 --- a/Rakefile +++ b/Rakefile @@ -18,8 +18,8 @@ task :"build-css" do sh "npm run build-css" end -desc "Run tests (test-linter, lint, build)" -task test: %i[test-news-plugin test-html-lang-plugin test-linter lint build] +desc "Run every test suite, the markdown linter and a full 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 @@ -32,7 +32,13 @@ desc "Serve the Jekyll site locally" task serve: :"build-css" do require "jekyll" - Jekyll::Commands::Serve.process({}) + # Same pair `jekyll serve` runs. Serving on its own only hands out whatever + # _site already holds, which leaves a fresh clone with nothing to serve and + # never picks up an edit. + options = { "serving" => true, "watch" => true } + + Jekyll::Commands::Build.process(options) + Jekyll::Commands::Serve.process(options) end namespace :new_post do @@ -142,3 +148,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/_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/_data/locales/bg.yml b/_data/locales/bg.yml index 2df7a19041..a00b2940a3 100644 --- a/_data/locales/bg.yml +++ b/_data/locales/bg.yml @@ -166,6 +166,11 @@ feed: description: Последните новини от ruby-lang.org. lang_code: bg +search: + fallback_notice: >- + Търсенето не поддържа български, затова обхваща страниците на английски + език. Въведете заявката си на английски. + news: other_news: Други новини more_news: Още новини... diff --git a/_data/locales/en.yml b/_data/locales/en.yml index cac6285c4d..cf32a2c1aa 100644 --- a/_data/locales/en.yml +++ b/_data/locales/en.yml @@ -188,6 +188,13 @@ feed: description: The latest news from ruby-lang.org. lang_code: en-US +# Shown inside the search modal on pages whose language is listed under +# `search.unsupported` in _config.yml. Other languages never see it. +search: + fallback_notice: >- + Search is not available in this language, so it looks through the English + pages. Type your search in English. + news: other_news: Other News more_news: More News... 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 @@ ' %} {% 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.html b/_includes/search.html new file mode 100644 index 0000000000..3c7c879c43 --- /dev/null +++ b/_includes/search.html @@ -0,0 +1,49 @@ +{%- comment -%} +Site search. The button opens a modal; Cmd+K or Ctrl+K opens it from anywhere. + +Pagefind picks both the index it searches and the language of its own interface +from the `lang` attribute on ``, so every translation searches its own +pages and reads its own labels without anything to maintain here. The `lang` +override below is for the languages under `search.unsupported` in _config.yml, +whose pages are not indexed: lib/search_index.rb points their index at +`search.fallback` and this points their interface at the same language, so the +two cannot drift apart. + +`pagefind-config` has to come first. Its attributes are read once, when the +first component of the instance connects. The trigger here is the one in the top +bar; below 640px it gives way to the one in the mobile menu, which shares this +modal through the default instance. + +An empty `pagefind-modal` builds its own contents. Those languages that fall +back get the same parts written out so a notice can sit above the results, +because otherwise a search in their own language just returns nothing and reads +as a broken feature. +{%- endcomment -%} +{%- assign search_lang = page.lang -%} +{%- assign falls_back = false -%} +{%- if site.search.unsupported contains page.lang -%} + {%- assign search_lang = site.search.fallback -%} + {%- assign falls_back = true -%} +{%- endif -%} + +
+ +
+{%- if falls_back -%} + {%- assign search_locale = site.data.locales[page.lang].search | default: site.data.locales.en.search -%} + + + + + +

{{ search_locale.fallback_notice }}

+ + +
+ + + +
+{%- else -%} + +{%- endif -%} diff --git a/_includes/search_assets.html b/_includes/search_assets.html new file mode 100644 index 0000000000..bb99ea4dc3 --- /dev/null +++ b/_includes/search_assets.html @@ -0,0 +1,23 @@ +{%- comment -%} +Pagefind's search bundle, written to /pagefind/ by _plugins/search_index.rb once +Jekyll has finished writing the site. Every build produces it, `rake serve` and +its regenerations included, so these files are missing only when the index was +skipped for want of Node. + +This has to come before compiled.css: Pagefind ships its `--pf-*` defaults on +`:root`, the same specificity stylesheets/components/search.css uses to override +them, so whichever stylesheet loads last would win. +{%- endcomment -%} + + + diff --git a/_includes/search_body.html b/_includes/search_body.html new file mode 100644 index 0000000000..8a8278a8e3 --- /dev/null +++ b/_includes/search_body.html @@ -0,0 +1,17 @@ +{%- 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. + +A page can opt out on its own with `searchable: false` in its front matter. +{%- endcomment -%} +{%- unless site.search.unsupported contains page.lang -%} + {%- if page.searchable != false -%} +data-pagefind-body data-pagefind-meta="title:{{ page.title | strip_html | escape }}" + {%- endif -%} +{%- endunless -%} diff --git a/_layouts/default.html b/_layouts/default.html index 6e5cca7553..23b9581ac3 100644 --- a/_layouts/default.html +++ b/_layouts/default.html @@ -62,6 +62,7 @@ + {% include search_assets.html %} diff --git a/_layouts/homepage.html b/_layouts/homepage.html index 333586cabd..659bf2f70d 100644 --- a/_layouts/homepage.html +++ b/_layouts/homepage.html @@ -39,6 +39,7 @@ + {% include search_assets.html %} 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/_plugins/search_index.rb b/_plugins/search_index.rb new file mode 100644 index 0000000000..1375676132 --- /dev/null +++ b/_plugins/search_index.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require_relative "../lib/search_index" + +# Pagefind indexes the HTML Jekyll wrote, so it hangs off post_write rather than +# off a Rake task. That way `rake build`, `rake serve` and a bare `jekyll build` +# all end with a search index that matches the site they just produced. +Jekyll::Hooks.register :site, :post_write do |site| + entry = SearchIndex.new( + bundle_dir: File.join(site.dest, "pagefind"), + search_config: site.config["search"], + ).build + + next unless entry + + # The languages that fall back share another language's index, so count the + # indexes rather than the keys pointing at them. + indexes = entry["languages"].values.uniq + pages = indexes.sum { |index| index["page_count"] } + Jekyll.logger.info "Search index:", "#{indexes.size} languages, #{pages} pages" +end diff --git a/admin/index.md b/admin/index.md index 8871008325..3aee857062 100644 --- a/admin/index.md +++ b/admin/index.md @@ -2,6 +2,7 @@ layout: page title: "Admin Area" lang: en +searchable: false --- - [News Post Translation Status](translation-status) diff --git a/javascripts/theme-toggle.js b/javascripts/theme-toggle.js index 0305465fa1..47a726a45e 100644 --- a/javascripts/theme-toggle.js +++ b/javascripts/theme-toggle.js @@ -35,6 +35,14 @@ root.classList.remove('dark'); } } + + // Pagefind's search components read `data-pf-theme` and nothing else, so + // derive it from the class rather than repeating the decision above. + if (root.classList.contains('dark')) { + root.setAttribute('data-pf-theme', 'dark'); + } else { + root.removeAttribute('data-pf-theme'); + } } // Update toggle button icon diff --git a/lib/search_index.rb b/lib/search_index.rb new file mode 100644 index 0000000000..5d8be4276d --- /dev/null +++ b/lib/search_index.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "json" + +# Builds the Pagefind search index over a written site and points the languages +# Pagefind cannot index at the one they fall back to. +# +# Pagefind reads the HTML Jekyll produced, so it can only run once the site has +# been written. Jekyll deletes anything in the destination that has no source +# counterpart, which includes the bundle from the previous build, so this runs +# after every write rather than once. _plugins/search_index.rb does the calling. +# +# Pagefind builds one index per `lang` attribute it finds and the browser picks +# an index by the `lang` of the page the visitor is on. The languages under +# `search.unsupported` in _config.yml are kept 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, so the bundle entry is rewritten to resolve +# them to `search.fallback` instead. +class SearchIndex + class Error < StandardError; end + + PAGEFIND = File.join(File.expand_path("..", __dir__), "node_modules", ".bin", "pagefind").freeze + + def initialize(bundle_dir:, search_config:, pagefind: PAGEFIND) + @bundle_dir = bundle_dir + @entry_path = File.join(bundle_dir, "pagefind-entry.json") + @search_config = search_config || {} + @pagefind = pagefind + end + + # Returns the bundle entry, or false without indexing when Pagefind is not + # installed, so that editing content only needs Ruby. Everywhere the site is + # published runs `npm ci` first. + # + # Pagefind runs silently because this fires on every rebuild under + # `rake serve`; it still exits non-zero when it fails. + def build + unless File.executable?(@pagefind) + warn "Search index skipped: #{@pagefind} is missing. Run `npm install` to search locally." + return false + end + + site_dir = File.dirname(@bundle_dir) + raise Error, "Pagefind failed to index #{site_dir}" unless system(@pagefind, "--site", site_dir, "--silent") + + apply_fallbacks + end + + # Maps each unsupported language to the language it searches instead, keyed the + # way Pagefind keys its indexes. + def fallbacks + fallback = @search_config["fallback"] + raise Error, "search.fallback is not configured" unless fallback.is_a?(String) + + Array(@search_config["unsupported"]).to_h { |lang| [index_key(lang), index_key(fallback)] } + 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 point #{from.inspect} at missing index #{to.inspect}" unless target + + if languages.key?(from) + warn "#{from.inspect} was indexed but is meant to search #{to.inspect}; check that the layouts skip it." + end + + languages[from] = target + end + + File.write(@entry_path, JSON.generate(entry)) + entry + end + + private + + # _config.yml and the site directories spell locales `zh_cn`, while Pagefind + # keys its indexes off the `lang` attribute that _plugins/html_lang.rb writes + # and lowercases it, giving `zh-cn`. Without this, a locale with a region + # would be written under a key the browser never looks up. + def index_key(lang) + lang.tr("_", "-").downcase + end + + def read_entry + JSON.parse(File.read(@entry_path)) + rescue Errno::ENOENT + raise Error, "#{@entry_path} is missing, Pagefind wrote no index" + 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..7ebbb7a06c 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ }, "devDependencies": { "@tailwindcss/typography": "^0.5.19", + "pagefind": "^1.5.2", "tailwindcss": "^3.4.14" } } diff --git a/stylesheets/components/search.css b/stylesheets/components/search.css new file mode 100644 index 0000000000..c919884a21 --- /dev/null +++ b/stylesheets/components/search.css @@ -0,0 +1,88 @@ +/* Pagefind search. + * + * Pagefind's components set `all: initial` on themselves, so none of the site's + * styles reach into them and these custom properties are the whole theming + * surface. Their dark palette comes from `[data-pf-theme="dark"]`, which + * javascripts/theme-toggle.js sets alongside the site's own `.dark`; what is + * left here is the brand on top of both. */ +:root { + --pf-font: "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, sans-serif; + --pf-border-radius: 6px; + + --pf-text: #1c1917; /* stone-900 */ + --pf-text-secondary: #57534e; /* stone-600 */ + --pf-text-muted: #78716c; /* stone-500 */ + --pf-border: #e7e5e4; /* stone-200 */ + --pf-hover: #f5f5f4; /* stone-100 */ + + --pf-mark: #b8211c; /* ruby-700 */ + --pf-border-focus: #e62923; /* ruby-600 */ + --pf-outline-focus: #e62923; /* ruby-600 */ +} + +.dark { + --pf-text: #fafaf9; /* stone-50 */ + --pf-text-secondary: #a8a29e; /* stone-400 */ + --pf-text-muted: #a8a29e; /* stone-400 */ + --pf-background: #1c1917; /* stone-900, matches the page */ + --pf-border: #44403c; /* stone-700 */ + --pf-hover: #292524; /* stone-800 */ + + --pf-mark: #f5a9a7; /* ruby-300 */ + --pf-border-focus: #f07f7b; /* ruby-400 */ + --pf-outline-focus: #f07f7b; /* ruby-400 */ +} + +/* Below 640px the top bar cannot hold the logo alongside the search button, the + * language selector and the menu button: the longer language names squeeze the + * logo until it clips. Search moves into the mobile menu at those widths and + * the two triggers share one modal through Pagefind's default instance. + * + * Both `display` values stay here rather than on Tailwind utility classes, + * which are emitted after this file and would win the cascade. */ +.search-trigger-bar { + display: flex; + align-items: center; +} + +.search-trigger-menu { + display: none; +} + +@media (max-width: 639px) { + .search-trigger-bar { + display: none; + } + + .search-trigger-menu { + display: block; + } +} + +/* Tells visitors in a language Pagefind cannot index that their search runs + * against another one. The class must stay out of Pagefind's `pf-` namespace: + * it reverts every `[class^="pf-"]` inside its components to UA styles, which + * would strip this rule. Nothing here is inherited from the site either, since + * the modal body sits under `all: initial`. */ +.search-notice { + margin: 0 0 12px; + padding: 8px 10px; + border-radius: var(--pf-border-radius); + background: var(--pf-hover); + color: var(--pf-text-secondary); + font-size: 13px; + line-height: 1.5; +} + +/* Where a result landed on the page it linked to. */ +.pagefind-highlight { + background-color: #fadad3; /* ruby-100 */ + color: #1c1917; /* stone-900 */ + border-radius: 2px; + padding: 0 0.1em; +} + +.dark .pagefind-highlight { + background-color: #8a1915; /* ruby-800 */ + color: #fafaf9; /* stone-50 */ +} diff --git a/stylesheets/tailwind.css b/stylesheets/tailwind.css index d2a19a7b87..dc3ae9f549 100644 --- a/stylesheets/tailwind.css +++ b/stylesheets/tailwind.css @@ -11,6 +11,7 @@ @import './components/tables.css'; @import './components/icons.css'; @import './components/print.css'; +@import './components/search.css'; @import './components/syntax-highlighting.css'; /* Tailwind layers */ diff --git a/test/test_search_index.rb b/test/test_search_index.rb new file mode 100644 index 0000000000..f37e386726 --- /dev/null +++ b/test/test_search_index.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require "helper" +require "json" +require "search_index" + +describe SearchIndex do + before do + setup_tempdir + @bundle_dir = File.join(TEMP_DIR, "_site", "pagefind") + FileUtils.mkdir_p(@bundle_dir) + @entry_path = File.join(@bundle_dir, "pagefind-entry.json") + @search_config = { "fallback" => "en", "unsupported" => ["bg"] } + 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 + + def search_index(pagefind: SearchIndex::PAGEFIND) + SearchIndex.new(bundle_dir: @bundle_dir, search_config: @search_config, pagefind: pagefind) + end + + # Stands in for Pagefind. The bundle it would have written is set up by + # `write_entry`, so the stub only has to reproduce the exit status. + def stub_pagefind(status) + path = File.join(TEMP_DIR, "pagefind-exit-#{status}") + File.write(path, "#!/bin/sh\nexit #{status}\n") + File.chmod(0o755, path) + path + end + + describe "#build" do + it "skips indexing when Pagefind is not installed" do + index = search_index(pagefind: File.join(TEMP_DIR, "no-such-pagefind")) + + result = nil + _, stderr = capture_io { result = index.build } + + _(result).must_equal false + _(stderr).must_match(/npm install/) + end + + it "applies the fallbacks and returns the entry once Pagefind has run" do + write_entry({ "en" => { "hash" => "en_abc", "wasm" => "en", "page_count" => 560 } }) + + entry = search_index(pagefind: stub_pagefind(0)).build + + _(entry["languages"]["bg"]["hash"]).must_equal "en_abc" + _(read_entry["languages"]["bg"]["hash"]).must_equal "en_abc" + end + + it "raises when Pagefind exits non-zero" do + error = _(-> { search_index(pagefind: stub_pagefind(1)).build }).must_raise SearchIndex::Error + + _(error.message).must_match(/Pagefind failed to index/) + end + end + + describe "#fallbacks" do + it "maps every unsupported language to the fallback language" do + @search_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 + @search_config = { "fallback" => "en", "unsupported" => [] } + + _(search_index.fallbacks).must_be_empty + end + + it "keys locales the way Pagefind keys its indexes" do + @search_config = { "fallback" => "zh_TW", "unsupported" => ["zh_cn"] } + + _(search_index.fallbacks).must_equal({ "zh-cn" => "zh-tw" }) + end + + it "raises when no fallback language is configured" do + @search_config = { "unsupported" => ["bg"] } + + error = _(-> { search_index.fallbacks }).must_raise SearchIndex::Error + _(error.message).must_match(/search.fallback is not configured/) + end + end + + describe "#apply_fallbacks" do + it "points an unsupported language at the index it falls back to" do + write_entry({ + "en" => { "hash" => "en_abc", "wasm" => "en", "page_count" => 560 }, + "ja" => { "hash" => "ja_def", "wasm" => nil, "page_count" => 536 }, + }) + + search_index.apply_fallbacks + + languages = read_entry["languages"] + _(languages["bg"]).must_equal languages["en"] + end + + it "does not leave the fallback to the largest index" do + write_entry({ + "en" => { "hash" => "en_abc", "wasm" => "en", "page_count" => 560 }, + "uk" => { "hash" => "uk_def", "wasm" => nil, "page_count" => 900 }, + }) + + search_index.apply_fallbacks + + _(read_entry["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" => 560 }, + "zh-cn" => { "hash" => "zh-cn_ghi", "wasm" => nil, "page_count" => 276 }, + }) + + search_index.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" => 560 }, + "bg" => { "hash" => "bg_xyz", "wasm" => nil, "page_count" => 60 }, + }) + + _, stderr = capture_io { search_index.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" => 536 } }) + + 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 = _(-> { search_index.apply_fallbacks }).must_raise SearchIndex::Error + _(error.message).must_match(/did the site build/) + end + + it "raises when Pagefind wrote no index" do + error = _(-> { search_index.apply_fallbacks }).must_raise SearchIndex::Error + _(error.message).must_match(/wrote no index/) + end + end +end