Skip to content

Commit b5cff3d

Browse files
authored
Merge pull request #3989 from ruby/claude/pagefind-inline-search-i18n-49fa5f
Add Pagefind search to the site
2 parents 88cae3c + c6abb73 commit b5cff3d

25 files changed

Lines changed: 692 additions & 11 deletions

CLAUDE.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ This is the Jekyll-based source for the official Ruby programming language websi
1111
### Jekyll Site Operations
1212

1313
```bash
14-
# Build the site (takes several minutes)
14+
# Build the site and its search index (takes several minutes)
1515
bundle exec rake build
1616

17-
# Serve locally at http://localhost:4000/
17+
# Build, watch and serve at http://localhost:4000/, search index included
1818
bundle exec rake serve
1919

2020
# Alternative: Jekyll direct serve with incremental builds
@@ -40,6 +40,7 @@ bundle exec rake test
4040
# Run individual test suites
4141
bundle exec rake test-news-plugin # News archive plugin tests
4242
bundle exec rake test-linter # Linter library tests
43+
bundle exec rake test-search-index # Search index library tests
4344

4445
# Linting
4546
bundle exec rake lint # Markdown linter
@@ -109,6 +110,31 @@ The news system is powered by a custom Jekyll plugin (`_plugins/news.rb`):
109110
- **Archive pages**: Index, yearly archives, monthly archives
110111
- **RSS feeds**: Generated per language via `news_feed.rss` layout
111112

113+
### Search (Pagefind)
114+
115+
Search is client-side and needs no backend. `_plugins/search_index.rb` hooks
116+
Jekyll's `post_write` and calls `lib/search_index.rb`, so every build and every
117+
regeneration under `rake serve` ends with a matching index. It has to run after
118+
the write because Pagefind reads the generated HTML, and it has to run every
119+
time because Jekyll deletes destination files with no source counterpart, the
120+
previous bundle included. Without Node installed the hook warns and skips.
121+
122+
- **Indexed region**: `_includes/search_body.html` emits `data-pagefind-body` on
123+
the `<article>` of `page.html` and `news_post.html`. Everything outside it, and
124+
anything marked `data-pagefind-ignore` inside it, stays out of results.
125+
- **Per-language indexes**: Pagefind splits the index by the `lang` attribute on
126+
`<html>` and the browser loads the one matching the page, so a translation only
127+
ever finds its own pages. The same attribute picks the interface language, so
128+
no UI strings live in `_data/locales`.
129+
- **Unsupported languages**: `search.unsupported` in `_config.yml` lists the
130+
languages Pagefind cannot index well (currently `bg`). Their pages get no
131+
`data-pagefind-body`, and `lib/search_index.rb` rewrites `pagefind-entry.json`
132+
so they resolve to `search.fallback`. Without that rewrite Pagefind would fall
133+
back to whichever index has the most pages, which is not a language we choose.
134+
- **UI**: `_includes/search.html` (button, modal, config) and
135+
`_includes/search_assets.html` (bundle, loaded before `compiled.css` so the
136+
site's `--pf-*` overrides win).
137+
112138
### Linter (`lib/linter.rb`)
113139

114140
Enforces strict content quality rules:
@@ -178,6 +204,7 @@ lang: en
178204
**Node (package.json):**
179205
- `tailwindcss` - CSS framework
180206
- `@tailwindcss/typography` - Prose styling plugin
207+
- `pagefind` - Static search index and UI
181208

182209
## Important Conventions
183210

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,28 @@ npm run watch-css # watch and rebuild CSS automatically
138138
**Note:** You need to have Node.js installed to run these commands.
139139

140140

141+
## Search
142+
143+
Search is [Pagefind](https://pagefind.app/), which indexes the HTML Jekyll
144+
produced rather than the Markdown behind it, so it needs no server of its own.
145+
`_plugins/search_index.rb` runs it whenever Jekyll finishes writing the site,
146+
which means `rake build` and `rake serve` both leave you with a search index
147+
matching the site they just produced. Nothing extra to run.
148+
149+
Indexing adds a few seconds to a build and needs Node, so run `npm install`
150+
first. Without it `bundle exec jekyll build` still finishes and only reports that
151+
the index was skipped, which is enough for editing content. The `rake` tasks
152+
stop earlier than that, since they build the CSS with Tailwind first.
153+
154+
Pagefind builds one index per language and picks one by the `lang` attribute of
155+
the page the visitor is on, so each translation searches its own pages and reads
156+
its own interface labels. Bulgarian is the exception: Pagefind has neither word
157+
stemming nor interface translations for it, so `search.unsupported` in
158+
`_config.yml` keeps its pages out of the index and its search runs against
159+
English instead. Adding a language there is all it takes to do the same for
160+
another one.
161+
162+
141163
## Testing
142164

143165
Besides generating and previewing the site

Rakefile

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ task :"build-css" do
1818
sh "npm run build-css"
1919
end
2020

21-
desc "Run tests (test-linter, lint, build)"
22-
task test: %i[test-news-plugin test-html-lang-plugin test-linter lint build]
21+
desc "Run every test suite, the markdown linter and a full build"
22+
task test: %i[test-news-plugin test-html-lang-plugin test-linter test-search-index lint build]
2323

2424
desc "Build the Jekyll site"
2525
task build: :"build-css" do
@@ -32,7 +32,13 @@ desc "Serve the Jekyll site locally"
3232
task serve: :"build-css" do
3333
require "jekyll"
3434

35-
Jekyll::Commands::Serve.process({})
35+
# Same pair `jekyll serve` runs. Serving on its own only hands out whatever
36+
# _site already holds, which leaves a fresh clone with nothing to serve and
37+
# never picks up an edit.
38+
options = { "serving" => true, "watch" => true }
39+
40+
Jekyll::Commands::Build.process(options)
41+
Jekyll::Commands::Serve.process(options)
3642
end
3743

3844
namespace :new_post do
@@ -142,3 +148,11 @@ Rake::TestTask.new(:"test-html-lang-plugin") do |t|
142148
t.test_files = FileList['test/test_plugin_html_lang.rb']
143149
t.verbose = true
144150
end
151+
152+
require "rake/testtask"
153+
Rake::TestTask.new(:"test-search-index") do |t|
154+
t.description = "Run tests for the search index library"
155+
t.libs = ["test", "lib"]
156+
t.test_files = FileList['test/test_search_index.rb']
157+
t.verbose = true
158+
end

_config.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ exclude:
2020
- vendor
2121
- CLAUDE.md
2222

23+
# Pagefind builds one search index per language and the browser picks one by the
24+
# `lang` of the page it is on. Pagefind has neither word stemming nor interface
25+
# translations for the languages under `unsupported`, so their pages are left
26+
# out of the index and their search runs against `fallback` instead. Both the
27+
# layouts and lib/search_index.rb read these keys.
28+
search:
29+
fallback: en
30+
unsupported:
31+
- bg
32+
2333
url: https://www.ruby-lang.org
2434

2535
license:

_data/locales/bg.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,11 @@ feed:
166166
description: Последните новини от ruby-lang.org.
167167
lang_code: bg
168168

169+
search:
170+
fallback_notice: >-
171+
Търсенето не поддържа български, затова обхваща страниците на английски
172+
език. Въведете заявката си на английски.
173+
169174
news:
170175
other_news: Други новини
171176
more_news: Още новини...

_data/locales/en.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,13 @@ feed:
188188
description: The latest news from ruby-lang.org.
189189
lang_code: en-US
190190

191+
# Shown inside the search modal on pages whose language is listed under
192+
# `search.unsupported` in _config.yml. Other languages never see it.
193+
search:
194+
fallback_notice: >-
195+
Search is not available in this language, so it looks through the English
196+
pages. Type your search in English.
197+
191198
news:
192199
other_news: Other News
193200
more_news: More News...

_includes/header.html

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,11 @@
2626
{% endfor %}
2727
</nav>
2828

29-
<!-- Right section: Language selector + Theme toggle + Mobile menu button -->
29+
<!-- Right section: Search + Language selector + Theme toggle + Mobile menu button -->
3030
<div class="flex items-center gap-0.5 sm:gap-2">
31+
<!-- Search -->
32+
{% include search.html %}
33+
3134
<!-- Language Selector -->
3235
{% include language_selector.html %}
3336

@@ -51,6 +54,11 @@
5154
<!-- Mobile Navigation Menu -->
5255
<div id="mobile-menu" class="hidden lg:hidden border-t border-stone-200 dark:border-stone-700">
5356
<nav class="container mx-auto px-4 py-4">
57+
<!-- Search, for the widths where the top bar has no room for it -->
58+
<div class="search-trigger-menu mb-3">
59+
<pagefind-modal-trigger></pagefind-modal-trigger>
60+
</div>
61+
5462
{% for item in nav_items %}
5563
<a href="{{ item.url }}"
5664
class="block px-5 py-2 text-sm font-bold text-stone-900 dark:text-stone-300 hover:text-semantic-text-hovered dark:hover:text-ruby-400 hover:bg-stone-100 dark:hover:bg-stone-800 rounded-md transition-colors mb-2">

_includes/pagination.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
{% assign header_parts = page.header | split: '</div>' %}
99
{% for part in header_parts %}
1010
{% if part contains 'class="multi-page"' %}
11-
<div class="pagination-footer">
11+
<div class="pagination-footer" data-pagefind-ignore>
1212
{{ part }}</div>
1313
</div>
1414
{% break %}

_includes/recent_news.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
{% endfor %}
2727

2828
{% if recent_posts.size > 0 %}
29-
<section class="not-prose mt-16 p-6 md:p-8 border border-stone-200 dark:border-stone-700 rounded-xl">
29+
<section class="not-prose mt-16 p-6 md:p-8 border border-stone-200 dark:border-stone-700 rounded-xl" data-pagefind-ignore>
3030
<h2 class="text-2xl font-bold mb-6">
3131
<a href="/{{ page.lang }}/news/" class="text-stone-900 dark:text-stone-100 hover:text-ruby-600 dark:hover:text-ruby-400 transition-colors inline-flex items-center">
3232
{{ news_locale.recent_news }}<span class="icon-chevron-right text-2xl" aria-hidden="true"></span>

_includes/search.html

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{%- comment -%}
2+
Site search. The button opens a modal; Cmd+K or Ctrl+K opens it from anywhere.
3+
4+
Pagefind picks both the index it searches and the language of its own interface
5+
from the `lang` attribute on `<html>`, so every translation searches its own
6+
pages and reads its own labels without anything to maintain here. The `lang`
7+
override below is for the languages under `search.unsupported` in _config.yml,
8+
whose pages are not indexed: lib/search_index.rb points their index at
9+
`search.fallback` and this points their interface at the same language, so the
10+
two cannot drift apart.
11+
12+
`pagefind-config` has to come first. Its attributes are read once, when the
13+
first component of the instance connects. The trigger here is the one in the top
14+
bar; below 640px it gives way to the one in the mobile menu, which shares this
15+
modal through the default instance.
16+
17+
An empty `pagefind-modal` builds its own contents. Those languages that fall
18+
back get the same parts written out so a notice can sit above the results,
19+
because otherwise a search in their own language just returns nothing and reads
20+
as a broken feature.
21+
{%- endcomment -%}
22+
{%- assign search_lang = page.lang -%}
23+
{%- assign falls_back = false -%}
24+
{%- if site.search.unsupported contains page.lang -%}
25+
{%- assign search_lang = site.search.fallback -%}
26+
{%- assign falls_back = true -%}
27+
{%- endif -%}
28+
<pagefind-config highlight-param="highlight" lang="{{ search_lang | to_html_lang }}"></pagefind-config>
29+
<div class="search-trigger-bar">
30+
<pagefind-modal-trigger compact></pagefind-modal-trigger>
31+
</div>
32+
{%- if falls_back -%}
33+
{%- assign search_locale = site.data.locales[page.lang].search | default: site.data.locales.en.search -%}
34+
<pagefind-modal reset-on-close>
35+
<pagefind-modal-header>
36+
<pagefind-input></pagefind-input>
37+
</pagefind-modal-header>
38+
<pagefind-modal-body>
39+
<p class="search-notice">{{ search_locale.fallback_notice }}</p>
40+
<pagefind-summary></pagefind-summary>
41+
<pagefind-results></pagefind-results>
42+
</pagefind-modal-body>
43+
<pagefind-modal-footer>
44+
<pagefind-keyboard-hints></pagefind-keyboard-hints>
45+
</pagefind-modal-footer>
46+
</pagefind-modal>
47+
{%- else -%}
48+
<pagefind-modal reset-on-close></pagefind-modal>
49+
{%- endif -%}

0 commit comments

Comments
 (0)