Skip to content

Commit ddde73a

Browse files
authored
Merge pull request #44 from szolkowski/feature/wave-4-search-webp-consent
Implement cookie consent banner and Pagefind search functionality
2 parents 7f6f70b + 20f1afe commit ddde73a

27 files changed

Lines changed: 693 additions & 12 deletions

.github/workflows/ci.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,25 @@ jobs:
4040
steps:
4141
- name: Checkout
4242
uses: actions/checkout@v4
43+
- name: Install webp encoder
44+
# _plugins/webp_generator.rb shells out to cwebp to write .webp
45+
# siblings of every PNG/JPEG into the deployed _site. The plugin
46+
# no-ops gracefully if cwebp is missing, but production should ship
47+
# the WebP siblings so future <picture> markup can use them.
48+
run: sudo apt-get update -qq && sudo apt-get install -y -qq webp
4349
- name: Setup Ruby
4450
uses: ruby/setup-ruby@086ffb1a2090c870a3f881cc91ea83aa4243d408 # v1.195.0
4551
with:
4652
ruby-version: '3.1' # Not needed with a .ruby-version file
4753
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
4854
cache-version: 0 # Increment this number if you need to re-download cached gems
55+
- name: Setup Node
56+
uses: actions/setup-node@v4
57+
with:
58+
node-version: '22'
59+
cache: 'npm'
60+
- name: Install npm deps
61+
run: npm ci
4962
- name: Setup Pages
5063
id: pages
5164
uses: actions/configure-pages@v5
@@ -54,6 +67,10 @@ jobs:
5467
run: bundle exec jekyll build --baseurl "${{ steps.pages.outputs.base_path }}"
5568
env:
5669
JEKYLL_ENV: production
70+
- name: Generate Pagefind search index
71+
# Pagefind crawls the built _site/ and writes /_site/pagefind/* — the
72+
# static UI bundle + WASM index that the header search input loads.
73+
run: npx pagefind --site _site
5774
- name: Upload Pages artifact
5875
# Automatically uploads an artifact from the './_site' directory by default
5976
uses: actions/upload-pages-artifact@v3
@@ -159,6 +176,10 @@ jobs:
159176
JEKYLL_ENV: production
160177
- name: Install npm deps
161178
run: npm ci
179+
- name: Generate Pagefind search index
180+
# lhci serves _site/, so the search bundle must exist there or the
181+
# header search input would 404 on every URL it audits.
182+
run: npx pagefind --site _site
162183
- name: Run Lighthouse CI
163184
id: lhci
164185
# The Playwright image ships chromium at /ms-playwright/chromium-*/...

_includes/_consent-banner.html

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{%- if site.google_analytics -%}
2+
<div id="consent-banner" class="consent-banner" hidden role="region" aria-label="Cookie consent">
3+
<p class="consent-banner__copy">
4+
This site uses Google Analytics to understand which posts are useful. No ads, no tracking across other sites. You can decline and the rest of the page works exactly the same.
5+
</p>
6+
<div class="consent-banner__actions">
7+
<button type="button" id="consent-decline" class="consent-banner__button consent-banner__button--decline">Decline</button>
8+
<button type="button" id="consent-accept" class="consent-banner__button consent-banner__button--accept">Accept analytics</button>
9+
</div>
10+
</div>
11+
{%- endif -%}
Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,34 @@
1-
<script async src="https://www.googletagmanager.com/gtag/js?id={{ site.google_analytics }}"></script>
1+
{%- comment -%}
2+
Consent Mode v2: every signal starts denied. assets/js/consent.js flips them
3+
to 'granted' when the user accepts the banner, and persists the choice in
4+
localStorage so we don't re-prompt on subsequent visits. If the user already
5+
accepted in a previous visit, we restore their consent here BEFORE gtag.js
6+
fetches, so the very first pageview after page load is recorded.
7+
{%- endcomment -%}
28
<script>
39
window.dataLayer = window.dataLayer || [];
410
function gtag(){dataLayer.push(arguments);}
5-
gtag('js', new Date());
611

12+
gtag('consent', 'default', {
13+
'ad_storage': 'denied',
14+
'ad_user_data': 'denied',
15+
'ad_personalization': 'denied',
16+
'analytics_storage': 'denied',
17+
'wait_for_update': 500
18+
});
19+
20+
try {
21+
if (localStorage.getItem('analytics_consent') === 'granted') {
22+
gtag('consent', 'update', {
23+
'ad_storage': 'granted',
24+
'ad_user_data': 'granted',
25+
'ad_personalization': 'granted',
26+
'analytics_storage': 'granted'
27+
});
28+
}
29+
} catch (e) { /* localStorage unavailable — stay denied */ }
30+
31+
gtag('js', new Date());
732
gtag('config', '{{ site.google_analytics }}');
8-
</script>
33+
</script>
34+
<script async src="https://www.googletagmanager.com/gtag/js?id={{ site.google_analytics }}"></script>

_includes/head-custom.html

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,15 @@
5252
"name": "Szołkowski's Blog",
5353
"description": {{ site.description | jsonify }},
5454
"publisher": { "@id": "{{ person_id }}" },
55-
"inLanguage": "en-US"
55+
"inLanguage": "en-US",
56+
"potentialAction": {
57+
"@type": "SearchAction",
58+
"target": {
59+
"@type": "EntryPoint",
60+
"urlTemplate": "{{ site.url }}/?q={search_term_string}"
61+
},
62+
"query-input": "required name=search_term_string"
63+
}
5664
},
5765
{
5866
"@type": "Person",

_layouts/default.html

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@
2626
<!-- Font Awesome CDN -->
2727
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" integrity="sha384-/o6I2CkkWC//PSjvWC/eYN7l3xM3tJm8ZzVkCOfp//W05QcE3mlGskpoHB6XqI+B" crossorigin="anonymous">
2828

29+
<!-- Pagefind search (header input mounts itself in #search via assets/js/search.js) -->
30+
<link rel="stylesheet" href="{{ '/pagefind/pagefind-ui.css' | relative_url }}">
31+
<script defer src="{{ '/pagefind/pagefind-ui.js' | relative_url }}"></script>
32+
<script defer src="{{ '/assets/js/search.js' | relative_url }}"></script>
33+
34+
<!-- custom-styles.css MUST load after pagefind-ui.css so our `.sidebar-search`
35+
overrides (icon centring, placeholder color, border reset) win on
36+
equal specificity. -->
2937
<link rel="stylesheet" href="{{ '/assets/css/custom-styles.css' | relative_url }}">
3038

3139
{%- comment -%}
@@ -48,11 +56,19 @@
4856
{% include _header.html %}
4957
<div id="content-wrapper">
5058
<div class="inner clearfix">
59+
<!-- Mobile-only search slot — hidden via CSS on desktop. The sidebar
60+
instance below stacks under main content on small viewports, so
61+
we render the search a second time at the top so it's reachable
62+
without scrolling past every post. assets/js/search.js mounts a
63+
separate Pagefind UI per `.pagefind-search-mount`. -->
64+
<div class="pagefind-search-mount mobile-search" role="search" aria-label="Site search"></div>
65+
5166
<main id="main-content">
5267
{{ content }}
5368
</main>
5469

5570
<aside id="sidebar">
71+
<div class="pagefind-search-mount sidebar-search" role="search" aria-label="Site search"></div>
5672
<p class="repo-owner">Tags</p>
5773
{% include _all-tags.html %}
5874
{% assign tags = all_tag_names | sort %}
@@ -63,9 +79,14 @@
6379
</div>
6480
{% include _footer.html %}
6581

82+
{% include _consent-banner.html %}
83+
6684
{%- if page.layout == "post" -%}
6785
<script defer src="{{ '/assets/js/code-modal.js' | relative_url }}"></script>
6886
{%- endif -%}
87+
{%- if site.google_analytics -%}
88+
<script defer src="{{ '/assets/js/consent.js' | relative_url }}"></script>
89+
{%- endif -%}
6990

7091
</body>
7192
</html>

_plugins/pagefind_runner.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
require 'open3'
2+
3+
module Jekyll
4+
# Runs `pagefind --site <dest>` after every build so the header search
5+
# works without a separate `npm run build` step. Mirrors the
6+
# WebpGenerator pattern: hooks :site, :post_write, shells out to a
7+
# native binary, no-ops gracefully if the binary isn't present.
8+
#
9+
# We invoke the platform-specific native binary directly (e.g.
10+
# `node_modules/@pagefind/darwin-arm64/bin/pagefind_extended`) instead of
11+
# the npm wrapper at `node_modules/.bin/pagefind`. The wrapper is a Node
12+
# script — running it via Jekyll's shellout picks up whatever `node` is
13+
# first on PATH, which on this machine resolves to the macOS system Node
14+
# (darwin-x64) and fails to find a darwin-x64 prebuild. Native binary
15+
# has no such ambiguity.
16+
Jekyll::Hooks.register :site, :post_write do |site|
17+
next unless File.directory?(site.dest)
18+
19+
bin = Dir.glob(File.join(site.source, 'node_modules', '@pagefind', '*', 'bin', '*'))
20+
.reject { |p| p.end_with?('.sha256') }
21+
.find { |p| File.executable?(p) && !File.directory?(p) }
22+
23+
unless bin
24+
Jekyll.logger.warn 'PagefindRunner:', 'no pagefind binary in node_modules/@pagefind/*/bin — run `npm install`. Header search will be empty.'
25+
next
26+
end
27+
28+
_out, status = Open3.capture2e(bin, '--site', site.dest, '--quiet')
29+
if status.success?
30+
Jekyll.logger.info 'PagefindRunner:', "indexed #{site.dest}/pagefind/"
31+
else
32+
Jekyll.logger.warn 'PagefindRunner:', "#{File.basename(bin)} exited non-zero (header search will be empty)"
33+
end
34+
end
35+
end

_plugins/webp_generator.rb

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
require 'open3'
2+
3+
module Jekyll
4+
# Generates .webp siblings for every .png / .jpg / .jpeg in the rendered
5+
# site under assets/img/. Runs on :site, :post_write so it sees the
6+
# final destination paths Jekyll just copied — no source-tree pollution,
7+
# no clash with the existing pngquant'd PNGs.
8+
#
9+
# Requires the `cwebp` binary (Google's libwebp encoder):
10+
# - macOS: brew install webp
11+
# - Ubuntu: apt-get install -y webp
12+
#
13+
# If cwebp isn't on PATH the plugin warns once and skips. WebP siblings are
14+
# still useful to have on disk — `<picture>` markup that prefers them is a
15+
# follow-up, kept out of this PR per the SEO/OG validator caveat.
16+
Jekyll::Hooks.register :site, :post_write do |site|
17+
img_dir = File.join(site.dest, 'assets', 'img')
18+
next unless File.directory?(img_dir)
19+
20+
unless system('which cwebp > /dev/null 2>&1')
21+
Jekyll.logger.warn 'WebpGenerator:', 'cwebp not found on PATH — skipping WebP generation'
22+
next
23+
end
24+
25+
converted = 0
26+
skipped = 0
27+
# FNM_CASEFOLD so we match .PNG/.JPG too without double-counting on
28+
# case-insensitive filesystems (macOS default APFS).
29+
Dir.glob(File.join(img_dir, '*.{png,jpg,jpeg}'), File::FNM_CASEFOLD).each do |src|
30+
webp = src.sub(/\.(png|jpe?g)\z/i, '.webp')
31+
if File.exist?(webp) && File.mtime(webp) >= File.mtime(src)
32+
skipped += 1
33+
next
34+
end
35+
_, status = Open3.capture2e('cwebp', '-quiet', '-q', '80', src, '-o', webp)
36+
if status.success?
37+
converted += 1
38+
else
39+
Jekyll.logger.warn 'WebpGenerator:', "cwebp failed on #{File.basename(src)}"
40+
end
41+
end
42+
43+
Jekyll.logger.info 'WebpGenerator:', "converted #{converted}, skipped #{skipped} (already up to date)"
44+
end
45+
end

0 commit comments

Comments
 (0)