Skip to content

Commit cecd3cd

Browse files
hsbtclaude
andcommitted
Build the search index from Jekyll instead of a Rake task
Indexing hung off `rake build`, so `rake serve` served a site with no search at all and needed a second task to preview one. Moving it to a `post_write` hook means any path that writes the site ends with a matching index: `rake build`, `rake serve` and its regenerations, and the bare `jekyll build` the deploy workflow runs. `rake serve` only ever handed out whatever `_site` already held, since it called `Serve.process` without the `Build.process` the `jekyll serve` command pairs it with. It now runs both, the way the README has always described it. Pagefind indexes silently because this fires on every regeneration, and the hook reports one line instead. Without Node it says so and lets the build through, so editing content still only needs Ruby. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 895e38d commit cecd3cd

8 files changed

Lines changed: 121 additions & 98 deletions

File tree

.github/workflows/jekyll.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,6 @@ jobs:
4343
run: bundle exec jekyll build --baseurl "${{ steps.pages.outputs.base_path }}"
4444
env:
4545
JEKYLL_ENV: production
46-
- name: Build search index
47-
run: bundle exec rake search-index
4846
- name: Upload artifact
4947
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
5048

CLAUDE.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,9 @@ This is the Jekyll-based source for the official Ruby programming language websi
1414
# Build the site and its search index (takes several minutes)
1515
bundle exec rake build
1616

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

20-
# Serve an already-built site, search included, without live reload
21-
bundle exec rake serve-built
22-
2320
# Alternative: Jekyll direct serve with incremental builds
2421
bundle exec jekyll serve --watch --future --incremental
2522
```
@@ -116,9 +113,12 @@ The news system is powered by a custom Jekyll plugin (`_plugins/news.rb`):
116113

117114
### Search (Pagefind)
118115

119-
Search is client-side and needs no backend. `rake build` runs `npm run
120-
build-search` over `_site` once Jekyll is done, so the index only exists after a
121-
full build.
116+
Search is client-side and needs no backend. `_plugins/search_index.rb` hooks
117+
Jekyll's `post_write` and calls `lib/search_index.rb`, so every build and every
118+
regeneration under `rake serve` ends with a matching index. It has to run after
119+
the write because Pagefind reads the generated HTML, and it has to run every
120+
time because Jekyll deletes destination files with no source counterpart, the
121+
previous bundle included. Without Node installed the hook warns and skips.
122122

123123
- **Indexed region**: `_includes/search_body.html` emits `data-pagefind-body` on
124124
the `<article>` of `page.html` and `news_post.html`. Everything outside it, and

README.md

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -140,18 +140,15 @@ npm run watch-css # watch and rebuild CSS automatically
140140

141141
## Search
142142

143-
Search is [Pagefind](https://pagefind.app/), which indexes the built site as a
144-
post-processing step, so it needs no server of its own. `bundle exec rake build`
145-
runs it after Jekyll and writes the index to `_site/pagefind`.
146-
147-
`rake serve` rebuilds `_site` on every change and wipes that index, so the search
148-
button opens on an empty index there. To try search locally, build once and serve
149-
the result:
150-
151-
``` sh
152-
bundle exec rake build
153-
bundle exec rake serve-built # http://localhost:4000/, no live reload
154-
```
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. It needs Node, so run `npm install`
150+
first; without it the build says the index was skipped and carries on, which is
151+
enough for editing content.
155152

156153
Pagefind builds one index per language and picks one by the `lang` attribute of
157154
the page the visitor is on, so each translation searches its own pages and reads

Rakefile

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -26,32 +26,19 @@ task build: :"build-css" do
2626
require "jekyll"
2727

2828
Jekyll::Commands::Build.process({})
29-
30-
Rake::Task[:"search-index"].invoke
31-
end
32-
33-
desc "Build the Pagefind search index over the built site"
34-
task :"search-index" do
35-
require_relative "lib/search_index"
36-
37-
sh "npm run build-search"
38-
SearchIndex.new.apply_fallbacks
3929
end
4030

4131
desc "Serve the Jekyll site locally"
4232
task serve: :"build-css" do
4333
require "jekyll"
4434

45-
Jekyll::Commands::Serve.process({})
46-
end
47-
48-
desc "Serve the built site without rebuilding, so its search index survives"
49-
task :"serve-built" do
50-
require "webrick"
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 }
5139

52-
server = WEBrick::HTTPServer.new(Port: 4000, DocumentRoot: "_site")
53-
trap("INT") { server.shutdown }
54-
server.start
40+
Jekyll::Commands::Build.process(options)
41+
Jekyll::Commands::Serve.process(options)
5542
end
5643

5744
namespace :new_post do

_plugins/search_index.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "../lib/search_index"
4+
5+
# Pagefind indexes the HTML Jekyll wrote, so it hangs off post_write rather than
6+
# off a Rake task. That way `rake build`, `rake serve` and a bare `jekyll build`
7+
# all end with a search index that matches the site they just produced.
8+
Jekyll::Hooks.register :site, :post_write do |site|
9+
entry = SearchIndex.new(
10+
bundle_dir: File.join(site.dest, "pagefind"),
11+
search_config: site.config["search"],
12+
).build
13+
14+
next unless entry
15+
16+
# The languages that fall back share another language's index, so count the
17+
# indexes rather than the keys pointing at them.
18+
indexes = entry["languages"].values.uniq
19+
pages = indexes.sum { |index| index["page_count"] }
20+
Jekyll.logger.info "Search index:", "#{indexes.size} languages, #{pages} pages"
21+
end

lib/search_index.rb

Lines changed: 42 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,58 @@
11
# frozen_string_literal: true
22

33
require "json"
4-
require "yaml"
54

6-
# Post-processes the Pagefind bundle that `npm run build-search` writes into
7-
# `_site/pagefind`.
5+
# Builds the Pagefind search index over a written site and points the languages
6+
# Pagefind cannot index at the one they fall back to.
87
#
9-
# Pagefind builds one index per `lang` attribute it finds in the built site, and
10-
# the browser picks an index by the `lang` of the page the visitor is on. The
11-
# languages under `search.unsupported` in _config.yml are left out of the index
12-
# by the layouts, which leaves them with no index of their own. Pagefind then
13-
# falls back to whichever index holds the most pages, a language we do not pick
14-
# and that shifts as translations are added. This rewrites the bundle entry so
15-
# those languages resolve to `search.fallback` instead.
8+
# Pagefind reads the HTML Jekyll produced, so it can only run once the site has
9+
# been written. Jekyll deletes anything in the destination that has no source
10+
# counterpart, which includes the bundle from the previous build, so this runs
11+
# after every write rather than once. _plugins/search_index.rb does the calling.
12+
#
13+
# Pagefind builds one index per `lang` attribute it finds and the browser picks
14+
# an index by the `lang` of the page the visitor is on. The languages under
15+
# `search.unsupported` in _config.yml are kept out of the index by the layouts,
16+
# which leaves them with no index of their own. Pagefind then falls back to
17+
# whichever index holds the most pages, a language we do not pick and that
18+
# shifts as translations are added, so the bundle entry is rewritten to resolve
19+
# them to `search.fallback` instead.
1620
class SearchIndex
1721
class Error < StandardError; end
1822

19-
def initialize(bundle_dir: "_site/pagefind", config_path: "_config.yml")
23+
PAGEFIND = File.join(File.expand_path("..", __dir__), "node_modules", ".bin", "pagefind")
24+
25+
def initialize(bundle_dir:, search_config:, pagefind: PAGEFIND)
26+
@bundle_dir = bundle_dir
2027
@entry_path = File.join(bundle_dir, "pagefind-entry.json")
21-
@config_path = config_path
28+
@search_config = search_config || {}
29+
@pagefind = pagefind
30+
end
31+
32+
# Returns the bundle entry, or false without indexing when Pagefind is not
33+
# installed, so that editing content only needs Ruby. Everywhere the site is
34+
# published runs `npm ci` first.
35+
#
36+
# Pagefind runs silently because this fires on every rebuild under
37+
# `rake serve`; it still exits non-zero when it fails.
38+
def build
39+
unless File.executable?(@pagefind)
40+
warn "Search index skipped: #{@pagefind} is missing. Run `npm install` to search locally."
41+
return false
42+
end
43+
44+
site_dir = File.dirname(@bundle_dir)
45+
raise Error, "Pagefind failed to index #{site_dir}" unless system(@pagefind, "--site", site_dir, "--silent")
46+
47+
apply_fallbacks
2248
end
2349

2450
# Maps each unsupported language to the language it searches instead.
2551
def fallbacks
26-
config = read_config
27-
fallback = config["fallback"]
28-
raise Error, "search.fallback is not set in #{@config_path}" unless fallback.is_a?(String)
52+
fallback = @search_config["fallback"]
53+
raise Error, "search.fallback is not configured" unless fallback.is_a?(String)
2954

30-
Array(config["unsupported"]).to_h { |lang| [lang, fallback] }
55+
Array(@search_config["unsupported"]).to_h { |lang| [lang, fallback] }
3156
end
3257

3358
def apply_fallbacks
@@ -52,19 +77,10 @@ def apply_fallbacks
5277

5378
private
5479

55-
def read_config
56-
config = YAML.load_file(@config_path)["search"]
57-
raise Error, "No search section in #{@config_path}" unless config.is_a?(Hash)
58-
59-
config
60-
rescue Errno::ENOENT
61-
raise Error, "#{@config_path} is missing"
62-
end
63-
6480
def read_entry
6581
JSON.parse(File.read(@entry_path))
6682
rescue Errno::ENOENT
67-
raise Error, "#{@entry_path} is missing, run `npm run build-search` first"
83+
raise Error, "#{@entry_path} is missing, Pagefind wrote no index"
6884
rescue JSON::ParserError => e
6985
raise Error, "#{@entry_path} is not valid JSON: #{e.message}"
7086
end

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@
44
"description": "Ruby Language Website with Tailwind CSS",
55
"scripts": {
66
"build-css": "tailwindcss -i ./stylesheets/tailwind.css -o ./stylesheets/compiled.css",
7-
"watch-css": "tailwindcss -i ./stylesheets/tailwind.css -o ./stylesheets/compiled.css --watch",
8-
"build-search": "pagefind --site _site"
7+
"watch-css": "tailwindcss -i ./stylesheets/tailwind.css -o ./stylesheets/compiled.css --watch"
98
},
109
"devDependencies": {
1110
"@tailwindcss/typography": "^0.5.19",

test/test_search_index.rb

Lines changed: 35 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,21 @@
22

33
require "helper"
44
require "json"
5-
require "yaml"
65
require_relative "../lib/search_index"
76

87
describe SearchIndex do
98
before do
109
setup_tempdir
11-
@bundle_dir = File.join(TEMP_DIR, "pagefind")
10+
@bundle_dir = File.join(TEMP_DIR, "_site", "pagefind")
1211
FileUtils.mkdir_p(@bundle_dir)
1312
@entry_path = File.join(@bundle_dir, "pagefind-entry.json")
14-
@config_path = File.join(TEMP_DIR, "_config.yml")
15-
write_config(fallback: "en", unsupported: ["bg"])
13+
@search_config = { "fallback" => "en", "unsupported" => ["bg"] }
1614
end
1715

1816
after do
1917
teardown_tempdir
2018
end
2119

22-
def write_config(search)
23-
File.write(@config_path, YAML.dump({ "search" => search.transform_keys(&:to_s) }))
24-
end
25-
2620
def write_entry(languages)
2721
File.write(@entry_path, JSON.generate({ "version" => "1.5.2", "languages" => languages }))
2822
end
@@ -31,29 +25,48 @@ def read_entry
3125
JSON.parse(File.read(@entry_path))
3226
end
3327

34-
def search_index
35-
SearchIndex.new(bundle_dir: @bundle_dir, config_path: @config_path)
28+
def search_index(pagefind: SearchIndex::PAGEFIND)
29+
SearchIndex.new(bundle_dir: @bundle_dir, search_config: @search_config, pagefind: pagefind)
30+
end
31+
32+
describe "#build" do
33+
it "skips indexing when Pagefind is not installed" do
34+
index = search_index(pagefind: File.join(TEMP_DIR, "no-such-pagefind"))
35+
36+
result = nil
37+
_, stderr = capture_io { result = index.build }
38+
39+
_(result).must_equal false
40+
_(stderr).must_match(/npm install/)
41+
end
3642
end
3743

3844
describe "#fallbacks" do
3945
it "maps every unsupported language to the fallback language" do
40-
write_config(fallback: "en", unsupported: %w[bg xx])
46+
@search_config = { "fallback" => "en", "unsupported" => %w[bg xx] }
4147

4248
_(search_index.fallbacks).must_equal({ "bg" => "en", "xx" => "en" })
4349
end
4450

4551
it "is empty when no language is unsupported" do
46-
write_config(fallback: "en", unsupported: [])
52+
@search_config = { "fallback" => "en", "unsupported" => [] }
4753

4854
_(search_index.fallbacks).must_be_empty
4955
end
56+
57+
it "raises when no fallback language is configured" do
58+
@search_config = { "unsupported" => ["bg"] }
59+
60+
error = _(-> { search_index.fallbacks }).must_raise SearchIndex::Error
61+
_(error.message).must_match(/search.fallback is not configured/)
62+
end
5063
end
5164

5265
describe "#apply_fallbacks" do
5366
it "points an unsupported language at the index it falls back to" do
5467
write_entry({
55-
"en" => { "hash" => "en_abc", "wasm" => "en", "page_count" => 563 },
56-
"ja" => { "hash" => "ja_def", "wasm" => nil, "page_count" => 541 },
68+
"en" => { "hash" => "en_abc", "wasm" => "en", "page_count" => 560 },
69+
"ja" => { "hash" => "ja_def", "wasm" => nil, "page_count" => 536 },
5770
})
5871

5972
search_index.apply_fallbacks
@@ -64,8 +77,8 @@ def search_index
6477

6578
it "does not leave the fallback to the largest index" do
6679
write_entry({
67-
"en" => { "hash" => "en_abc", "wasm" => "en", "page_count" => 563 },
68-
"uk" => { "hash" => "uk_def", "wasm" => nil, "page_count" => 564 },
80+
"en" => { "hash" => "en_abc", "wasm" => "en", "page_count" => 560 },
81+
"uk" => { "hash" => "uk_def", "wasm" => nil, "page_count" => 900 },
6982
})
7083

7184
search_index.apply_fallbacks
@@ -75,8 +88,8 @@ def search_index
7588

7689
it "leaves the other languages untouched" do
7790
write_entry({
78-
"en" => { "hash" => "en_abc", "wasm" => "en", "page_count" => 563 },
79-
"zh-cn" => { "hash" => "zh-cn_ghi", "wasm" => nil, "page_count" => 281 },
91+
"en" => { "hash" => "en_abc", "wasm" => "en", "page_count" => 560 },
92+
"zh-cn" => { "hash" => "zh-cn_ghi", "wasm" => nil, "page_count" => 276 },
8093
})
8194

8295
search_index.apply_fallbacks
@@ -88,7 +101,7 @@ def search_index
88101

89102
it "warns when a language meant to be skipped was indexed anyway" do
90103
write_entry({
91-
"en" => { "hash" => "en_abc", "wasm" => "en", "page_count" => 563 },
104+
"en" => { "hash" => "en_abc", "wasm" => "en", "page_count" => 560 },
92105
"bg" => { "hash" => "bg_xyz", "wasm" => nil, "page_count" => 60 },
93106
})
94107

@@ -99,7 +112,7 @@ def search_index
99112
end
100113

101114
it "raises when the index it falls back to is missing" do
102-
write_entry({ "ja" => { "hash" => "ja_def", "wasm" => nil, "page_count" => 541 } })
115+
write_entry({ "ja" => { "hash" => "ja_def", "wasm" => nil, "page_count" => 536 } })
103116

104117
error = _(-> { search_index.apply_fallbacks }).must_raise SearchIndex::Error
105118
_(error.message).must_match(/missing index "en"/)
@@ -112,17 +125,9 @@ def search_index
112125
_(error.message).must_match(/did the site build/)
113126
end
114127

115-
it "raises when the bundle entry does not exist" do
116-
error = _(-> { search_index.apply_fallbacks }).must_raise SearchIndex::Error
117-
_(error.message).must_match(/npm run build-search/)
118-
end
119-
120-
it "raises when the config has no search section" do
121-
write_entry({ "en" => { "hash" => "en_abc", "wasm" => "en", "page_count" => 563 } })
122-
File.write(@config_path, YAML.dump({ "url" => "https://www.ruby-lang.org" }))
123-
128+
it "raises when Pagefind wrote no index" do
124129
error = _(-> { search_index.apply_fallbacks }).must_raise SearchIndex::Error
125-
_(error.message).must_match(/No search section/)
130+
_(error.message).must_match(/wrote no index/)
126131
end
127132
end
128133
end

0 commit comments

Comments
 (0)