From 57bd0c2871efd52e7d95646af8fd92e8aa6357bf Mon Sep 17 00:00:00 2001 From: Paul Keen <125715+pftg@users.noreply.github.com> Date: Tue, 14 Apr 2026 09:17:29 +0200 Subject: [PATCH 1/2] feat: serve blog images via wsrv.nl CDN proxy in production Fixes GitHub Pages artifact exceeding 1GB limit (was 1.28GB). In production, image templates emit wsrv.nl proxy URLs instead of Hugo-processed local paths, reducing artifact from 2GB to 119MB. - CDN disabled by default, enabled in config/production/hugo.toml - Templates check site.Params.cdn.enabled (per-environment config) - Responsive images preserved: picture/srcset with webp+jpeg at 4 widths - Blog thumbnails, OG images, shortcode images all CDN-ified - GitHub Actions strips blog media after build - Dev mode unchanged (hugo server works as before) - Cloudflare R2 option documented for future use Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/_hugo.yml | 6 + config/_default/hugo.toml | 6 + config/production/hugo.toml | 3 + docs/workflows/cdn-image-proxy.md | 100 +++++++++++++ layouts/partials/seo/enhanced-meta-tags.html | 52 ++++--- test/unit/cdn_image_proxy_test.rb | 134 ++++++++++++++++++ .../beaver/layouts/_markup/render-image.html | 42 +++++- themes/beaver/layouts/_shortcodes/img.html | 8 +- .../layouts/partials/blog/img-cropped.html | 10 +- .../partials/seo/enhanced-meta-tags.html | 30 ++-- 10 files changed, 357 insertions(+), 34 deletions(-) create mode 100644 docs/workflows/cdn-image-proxy.md create mode 100644 test/unit/cdn_image_proxy_test.rb diff --git a/.github/workflows/_hugo.yml b/.github/workflows/_hugo.yml index 5bcbc3b62..e878588e0 100644 --- a/.github/workflows/_hugo.yml +++ b/.github/workflows/_hugo.yml @@ -78,6 +78,12 @@ jobs: --baseURL "https://jetthoughts.com/" \ --environment production + - name: Remove blog media from artifact + run: | + find public/blog/ -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.webp' -o -iname '*.svg' -o -iname '*.mp4' \) -delete + echo "Cleaned blog media. Artifact size:" + du -sh public/ + - name: Upload artifact uses: actions/upload-pages-artifact@v4 with: diff --git a/config/_default/hugo.toml b/config/_default/hugo.toml index 253d9526c..5e004f1b3 100644 --- a/config/_default/hugo.toml +++ b/config/_default/hugo.toml @@ -53,6 +53,12 @@ disableKinds = [] changeFreq = 'weekly' priority = 0.5 +[params.cdn] + enabled = false # enabled per-environment in config/production/hugo.toml + provider = "wsrv" # "wsrv" (free proxy) or "cloudflare" (R2 bucket) + rawBase = "raw.githubusercontent.com/jetthoughts/jetthoughts.github.io/master/content/" + # cloudflareBase = "cdn.jetthoughts.com" # uncomment for Cloudflare R2 + [params] email="info@jetthoughts.com" phone="+1 754 216 9568" diff --git a/config/production/hugo.toml b/config/production/hugo.toml index fc7c1ae67..0f0e5cb8d 100644 --- a/config/production/hugo.toml +++ b/config/production/hugo.toml @@ -26,6 +26,9 @@ disableKinds = [] # Production environment environment = "production" +[params.cdn] + enabled = true + [outputs] home = ["HTML"] # Only build HTML, skip JSON/RSS in tests section = ["HTML"] diff --git a/docs/workflows/cdn-image-proxy.md b/docs/workflows/cdn-image-proxy.md new file mode 100644 index 000000000..bc3c6a5c9 --- /dev/null +++ b/docs/workflows/cdn-image-proxy.md @@ -0,0 +1,100 @@ +# CDN Image Proxy for Production + +Solves the GitHub Pages 1 GB artifact limit by serving blog images from a CDN proxy in production, while keeping local Hugo image processing for development. + +## How It Works + +1. **Development** (`hugo server`): Images processed locally by Hugo as before — no change. +2. **Production** (`--environment production`): Templates emit CDN URLs instead of local paths. Hugo still reads images for dimensions but skips processing. +3. **GitHub Actions**: After Hugo build, all blog media files are deleted from `public/blog/` before uploading the artifact. + +## Configuration + +**Default** (`config/_default/hugo.toml`) — CDN disabled: +```toml +[params.cdn] + enabled = false + provider = "wsrv" + rawBase = "raw.githubusercontent.com/jetthoughts/jetthoughts.github.io/master/content/" +``` + +**Production** (`config/production/hugo.toml`) — CDN enabled: +```toml +[params.cdn] + enabled = true +``` + +Gate in templates: `{{ if site.Params.cdn.enabled }}` + +**Important:** Root `layouts/` overrides `themes/beaver/layouts/`. Both copies of `enhanced-meta-tags.html` must be updated. + +## Templates Modified + +| Template | CDN behavior | +|----------|-------------| +| `_markup/render-image.html` | `` srcset via wsrv.nl (webp+jpeg, 400/800/1200/1600w) | +| `partials/blog/img-cropped.html` | Retina thumbnail via wsrv.nl | +| `partials/seo/enhanced-meta-tags.html` | OG image (1200×630) via wsrv.nl | +| `_shortcodes/img.html` | Direct image via wsrv.nl | + +## Solution 1: wsrv.nl (Current) + +Free image proxy at [wsrv.nl](https://wsrv.nl). No account needed. Supports resizing, format conversion, quality control. + +**URL pattern:** +``` +https://wsrv.nl/?url=raw.githubusercontent.com/OWNER/REPO/BRANCH/content/PATH&w=WIDTH&output=FORMAT&q=QUALITY +``` + +**Params used:** +- `&w=N` — width resize +- `&h=N` — height resize +- `&output=webp|jpeg` — format conversion +- `&q=N` — quality (80 default, 85 OG, 90 thumbnails) +- `&fit=inside` — aspect-preserving fit (OG images) + +**Pros:** Zero cost, zero setup, good caching. +**Cons:** Third-party dependency, no SLA, images must be in a public repo. + +## Solution 2: Cloudflare R2 (Future) + +Upload all image variants to Cloudflare R2 bucket with a custom domain (e.g., `cdn.jetthoughts.com`). + +### Setup Required + +1. Create R2 bucket + connect custom domain +2. Add GitHub secrets: `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_ENDPOINT_URL`, `R2_BUCKET_NAME` +3. Change config: + ```toml + [params.cdn] + enabled = true + provider = "cloudflare" + cloudflareBase = "cdn.jetthoughts.com" + ``` +4. Update templates to check `provider` and use `cloudflareBase` for URL prefix +5. Add sync step to GitHub Actions before media cleanup: + ```yaml + - name: Sync media to Cloudflare R2 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} + run: | + aws s3 sync ./public/ s3://${{ secrets.R2_BUCKET_NAME }}/ \ + --endpoint-url $ENDPOINT_URL \ + --exclude "*" \ + --include "*.jpg" --include "*.jpeg" --include "*.png" \ + --include "*.gif" --include "*.webp" --include "*.mp4" + ``` + +**Pros:** Full control, SLA, works with private repos, custom domain. +**Cons:** Requires R2 account, AWS CLI in CI, storage costs (minimal). + +## Not Yet Covered + +These templates still use local Hugo processing (non-blog assets from `assets/`/`static/`, not affected by blog media cleanup): + +- `partials/img/generic.html` (hero, homepage, testimonials, etc.) +- `partials/img/resize.html` +- `partials/page/cover_image.html` diff --git a/layouts/partials/seo/enhanced-meta-tags.html b/layouts/partials/seo/enhanced-meta-tags.html index 8b135fce0..ed2c4ce29 100644 --- a/layouts/partials/seo/enhanced-meta-tags.html +++ b/layouts/partials/seo/enhanced-meta-tags.html @@ -106,10 +106,15 @@ {{- with .Params.metatags.image -}} {{- $resource := $page.Resources.Get . -}} {{- if $resource -}} - {{- $optimized := $resource.Resize "1200x630 jpg q90" -}} - {{- $image = $optimized.Permalink -}} - {{- $imageWidth = $optimized.Width -}} - {{- $imageHeight = $optimized.Height -}} + {{- if site.Params.cdn.enabled -}} + {{- $rawURL := printf "%s%s%s" site.Params.cdn.rawBase $page.File.Dir $resource.Name -}} + {{- $image = printf "https://wsrv.nl/?url=%s&w=1200&h=630&fit=inside&output=webp&q=85" $rawURL -}} + {{- else -}} + {{- $optimized := $resource.Resize "1200x630 jpg q90" -}} + {{- $image = $optimized.Permalink -}} + {{- $imageWidth = $optimized.Width -}} + {{- $imageHeight = $optimized.Height -}} + {{- end -}} {{- end -}} {{- end -}} @@ -118,10 +123,15 @@ {{- with .Params.cover_image -}} {{- $resource := $page.Resources.Get . -}} {{- if $resource -}} - {{- $optimized := $resource.Resize "1200x630 jpg q90" -}} - {{- $image = $optimized.Permalink -}} - {{- $imageWidth = $optimized.Width -}} - {{- $imageHeight = $optimized.Height -}} + {{- if site.Params.cdn.enabled -}} + {{- $rawURL := printf "%s%s%s" site.Params.cdn.rawBase $page.File.Dir $resource.Name -}} + {{- $image = printf "https://wsrv.nl/?url=%s&w=1200&h=630&fit=inside&output=webp&q=85" $rawURL -}} + {{- else -}} + {{- $optimized := $resource.Resize "1200x630 jpg q90" -}} + {{- $image = $optimized.Permalink -}} + {{- $imageWidth = $optimized.Width -}} + {{- $imageHeight = $optimized.Height -}} + {{- end -}} {{- end -}} {{- end -}} {{- end -}} @@ -131,10 +141,15 @@ {{- with .Params.cover -}} {{- $resource := $page.Resources.Get . -}} {{- if $resource -}} - {{- $optimized := $resource.Resize "1200x630 jpg q90" -}} - {{- $image = $optimized.Permalink -}} - {{- $imageWidth = $optimized.Width -}} - {{- $imageHeight = $optimized.Height -}} + {{- if site.Params.cdn.enabled -}} + {{- $rawURL := printf "%s%s%s" site.Params.cdn.rawBase $page.File.Dir $resource.Name -}} + {{- $image = printf "https://wsrv.nl/?url=%s&w=1200&h=630&fit=inside&output=webp&q=85" $rawURL -}} + {{- else -}} + {{- $optimized := $resource.Resize "1200x630 jpg q90" -}} + {{- $image = $optimized.Permalink -}} + {{- $imageWidth = $optimized.Width -}} + {{- $imageHeight = $optimized.Height -}} + {{- end -}} {{- end -}} {{- end -}} {{- end -}} @@ -144,10 +159,15 @@ {{- with .Params.featured_image -}} {{- $resource := $page.Resources.Get . -}} {{- if $resource -}} - {{- $optimized := $resource.Resize "1200x630 jpg q90" -}} - {{- $image = $optimized.Permalink -}} - {{- $imageWidth = $optimized.Width -}} - {{- $imageHeight = $optimized.Height -}} + {{- if site.Params.cdn.enabled -}} + {{- $rawURL := printf "%s%s%s" site.Params.cdn.rawBase $page.File.Dir $resource.Name -}} + {{- $image = printf "https://wsrv.nl/?url=%s&w=1200&h=630&fit=inside&output=webp&q=85" $rawURL -}} + {{- else -}} + {{- $optimized := $resource.Resize "1200x630 jpg q90" -}} + {{- $image = $optimized.Permalink -}} + {{- $imageWidth = $optimized.Width -}} + {{- $imageHeight = $optimized.Height -}} + {{- end -}} {{- end -}} {{- end -}} {{- end -}} diff --git a/test/unit/cdn_image_proxy_test.rb b/test/unit/cdn_image_proxy_test.rb new file mode 100644 index 000000000..6f0734e01 --- /dev/null +++ b/test/unit/cdn_image_proxy_test.rb @@ -0,0 +1,134 @@ +require_relative "../base_page_test_case" + +class CdnImageProxyTest < BasePageTestCase + # Tests that production builds serve blog images via wsrv.nl CDN proxy. + # The test helper builds with --environment production, so CDN is enabled. + + def setup + @blog_posts = Dir.glob("#{root_path}/blog/*/index.html") + .reject { |f| f.end_with?("blog/index.html") } + + skip "No blog posts found" if @blog_posts.empty? + end + + def test_markdown_images_use_cdn_picture_element + post_with_image = find_post_with_picture_element + skip "No blog post with element found" unless post_with_image + + doc = Nokogiri::HTML(File.read(post_with_image)) + picture = doc.css("picture").first + + sources = picture.css("source") + assert sources.length >= 2, "Picture should have at least webp and jpeg sources" + + sources.each do |source| + srcset = source["srcset"] + assert_includes srcset, "wsrv.nl", "Source srcset should use wsrv.nl CDN" + assert_match(/w=\d+/, srcset, "CDN URLs should include width parameter") + assert_match(/output=(webp|jpeg)/, srcset, "CDN URLs should include output format") + assert_match(/q=\d+/, srcset, "CDN URLs should include quality parameter") + assert_match(/\d+w/, srcset, "Srcset should include width descriptors") + end + + # Fallback img inside picture + img = picture.css("img").first + refute_nil img, "Picture element must have fallback img" + assert_includes img["src"], "wsrv.nl", "Fallback img should use wsrv.nl CDN" + assert img["loading"] == "lazy", "Images should have lazy loading" + end + + def test_cdn_urls_have_correct_sizes + post_with_image = find_post_with_picture_element + skip "No blog post with element found" unless post_with_image + + doc = Nokogiri::HTML(File.read(post_with_image)) + source = doc.css("picture source").first + srcset = source["srcset"] + + # Should have 4 size variants: 400, 800, 1200, 1600 + %w[w=400 w=800 w=1200 w=1600].each do |size| + assert_includes srcset, size, "Srcset should include #{size}" + end + end + + def test_cdn_urls_point_to_github_raw + post_with_image = find_post_with_picture_element + skip "No blog post with element found" unless post_with_image + + doc = Nokogiri::HTML(File.read(post_with_image)) + source = doc.css("picture source").first + srcset = source["srcset"] + + assert_includes srcset, "raw.githubusercontent.com", + "CDN URLs should proxy from GitHub raw content" + assert_includes srcset, "jetthoughts/jetthoughts.github.io", + "CDN URLs should reference this repository" + end + + def test_blog_thumbnails_use_cdn + doc = parse_html_file("blog/index.html") + + # Blog listing page thumbnails + thumbnail_imgs = doc.css(".blog-post img, .post-image img, article img") + cdn_thumbnails = thumbnail_imgs.select { |img| img["src"]&.include?("wsrv.nl") } + + # At least some thumbnails should use CDN (posts with cover images) + assert cdn_thumbnails.any?, "Blog listing thumbnails should use wsrv.nl CDN" + + cdn_thumbnails.each do |img| + src = img["src"] + assert_match(/output=jpg/, src, "Thumbnails should request JPG format") + assert_match(/q=90/, src, "Thumbnails should use q=90 quality") + end + end + + def test_og_images_use_cdn + post_with_og = @blog_posts.find do |path| + content = File.read(path) + content.include?("og:image") && content.include?("wsrv.nl") + end + skip "No blog post with CDN og:image found" unless post_with_og + + doc = Nokogiri::HTML(File.read(post_with_og)) + og_image = doc.css("meta[property='og:image']").first + + refute_nil og_image, "Blog post should have og:image meta tag" + og_url = og_image["content"] + + assert_includes og_url, "wsrv.nl", "OG image should use wsrv.nl CDN" + assert_match(/w=1200/, og_url, "OG image should be 1200px wide") + assert_match(/h=630/, og_url, "OG image should be 630px tall") + assert_match(/output=webp/, og_url, "OG image should use WebP format") + end + + def test_picture_element_has_sizes_attribute + post_with_image = find_post_with_picture_element + skip "No blog post with element found" unless post_with_image + + doc = Nokogiri::HTML(File.read(post_with_image)) + source = doc.css("picture source").first + + assert source["sizes"], "Source should have sizes attribute" + assert_includes source["sizes"], "48rem", "Sizes should reference max content width" + end + + def test_images_preserve_dimensions + post_with_image = find_post_with_picture_element + skip "No blog post with element found" unless post_with_image + + doc = Nokogiri::HTML(File.read(post_with_image)) + img = doc.css("picture img").first + + assert img["width"], "Image should have width attribute for CLS prevention" + assert img["height"], "Image should have height attribute for CLS prevention" + end + + private + + def find_post_with_picture_element + @blog_posts.find do |path| + content = File.read(path) + content.include?("") && content.include?("wsrv.nl") + end + end +end diff --git a/themes/beaver/layouts/_markup/render-image.html b/themes/beaver/layouts/_markup/render-image.html index d4bfd1269..5966f8ff2 100644 --- a/themes/beaver/layouts/_markup/render-image.html +++ b/themes/beaver/layouts/_markup/render-image.html @@ -3,26 +3,54 @@ {{ $alt := .PlainText | safeHTML }} {{ if $src }} -{{/* Check if image is a GIF - if so, render it directly */}} -{{ if eq (path.Ext $src.RelPermalink | lower) ".gif" }} +{{ $useCDN := site.Params.cdn.enabled }} +{{ $isGif := eq (path.Ext $src.Name | lower) ".gif" }} + +{{ if $isGif }} + {{ if $useCDN }} + {{ $rawURL := printf "%s%s%s" site.Params.cdn.rawBase .Page.File.Dir $src.Name }} + {{ $alt }} + {{ else }} {{ $alt }} + {{ end }} +{{ else if $useCDN }} + {{/* CDN: construct wsrv.nl URLs for responsive images */}} + {{ $formats := slice "webp" "jpeg" }} + {{ $sizes := slice 400 800 1200 1600 }} + {{ $rawURL := printf "%s%s%s" site.Params.cdn.rawBase .Page.File.Dir $src.Name }} + + + {{ range $format := $formats }} + {{ $srcset := slice }} + {{ range $size := $sizes }} + {{ $srcset = $srcset | append (printf "https://wsrv.nl/?url=%s&w=%d&output=%s&q=80 %dw" $rawURL $size $format $size) }} + {{ end }} + + {{ end }} + {{ $alt }} + {{ else }} - {{/* Get formats and sizes from config */}} - {{/* From less common to most common. For example avif webp jpeg */}} + {{/* Local: Hugo image processing for development */}} {{ $formats := slice "webp" "jpeg" }} {{ $sizes := slice 400 800 1200 1600 }} - {{/* default format if browser doesn't support picture element */}} {{ $defaultFormat := "jpeg" }} - {{/* Create optimized versions of the original image in the specified formats */}} {{ $cacheKey := printf "img-%s" ($src.RelPermalink | md5) }} {{ $data := partialCached "image-processor" (dict "src" $src "formats" $formats "sizes" $sizes) $cacheKey }} - {{ range $format := $formats }} {{ $srcset := slice }} diff --git a/themes/beaver/layouts/_shortcodes/img.html b/themes/beaver/layouts/_shortcodes/img.html index 32a0c04d6..1bf375f38 100644 --- a/themes/beaver/layouts/_shortcodes/img.html +++ b/themes/beaver/layouts/_shortcodes/img.html @@ -1,6 +1,12 @@ {{ $image := .Page.Resources.GetMatch (printf "%s" (.Get 0)) }} {{ if $image }} +{{ $useCDN := site.Params.cdn.enabled }} +{{ if $useCDN }} +{{ $rawURL := printf "%s%s%s" site.Params.cdn.rawBase .Page.File.Dir $image.Name }} +{{ .Get 1 }} +{{ else }} {{ .Get 1 }} +{{ end }} {{ else }}

Image not found: {{ .Get 0 }}

-{{ end }} \ No newline at end of file +{{ end }} diff --git a/themes/beaver/layouts/partials/blog/img-cropped.html b/themes/beaver/layouts/partials/blog/img-cropped.html index 14fe76dc0..b8f37e2a9 100644 --- a/themes/beaver/layouts/partials/blog/img-cropped.html +++ b/themes/beaver/layouts/partials/blog/img-cropped.html @@ -16,16 +16,22 @@ {{- end -}} {{ if $image }} +{{ $useCDN := site.Params.cdn.enabled }} +{{ $retinaW := mul $.width 2 }} + +{{ if $useCDN }} +{{ $rawURL := printf "%s%s%s" site.Params.cdn.rawBase $page.File.Dir $image.Name }} +{{ $page.Title }} +{{ else }} {{/* Aspect-preserving resize at 2× retina. The cover is designed as a landscape composition (2400×1260 = 1.905:1) following the canonical 6-slot layout in .stitch/design.md — we must NOT center-crop it because the left-aligned hero + right visual would be mangled. CSS uses object-fit: contain with a dark background to letterbox cleanly inside the 180×180 thumbnail container. */}} -{{ $retinaW := mul $.width 2 }} {{ $resized := $image.Resize (printf "%dx jpg q90" $retinaW) }} - {{ $page.Title }} +{{ end }} {{ else }} Placeholder Image diff --git a/themes/beaver/layouts/partials/seo/enhanced-meta-tags.html b/themes/beaver/layouts/partials/seo/enhanced-meta-tags.html index 2bb111070..04c60dfcf 100644 --- a/themes/beaver/layouts/partials/seo/enhanced-meta-tags.html +++ b/themes/beaver/layouts/partials/seo/enhanced-meta-tags.html @@ -210,10 +210,17 @@ {{- with .Params.metatags.image -}} {{- $resource := $page.Resources.Get . -}} {{- if $resource -}} - {{- $optimized := $resource.Resize "1200x630 webp q85" -}} - {{- $image = $optimized.Permalink -}} - {{- $imageWidth = $optimized.Width -}} - {{- $imageHeight = $optimized.Height -}} + {{- if site.Params.cdn.enabled -}} + {{- $rawURL := printf "%s%s%s" site.Params.cdn.rawBase $page.File.Dir $resource.Name -}} + {{- $image = printf "https://wsrv.nl/?url=%s&w=1200&h=630&fit=inside&output=webp&q=85" $rawURL -}} + {{- $imageWidth = "1200" -}} + {{- $imageHeight = "630" -}} + {{- else -}} + {{- $optimized := $resource.Resize "1200x630 webp q85" -}} + {{- $image = $optimized.Permalink -}} + {{- $imageWidth = $optimized.Width -}} + {{- $imageHeight = $optimized.Height -}} + {{- end -}} {{- end -}} {{- end -}} @@ -222,10 +229,17 @@ {{- with .Params.cover_image -}} {{- $resource := $page.Resources.Get . -}} {{- if $resource -}} - {{- $optimized := $resource.Resize "1200x630 webp q85" -}} - {{- $image = $optimized.Permalink -}} - {{- $imageWidth = $optimized.Width -}} - {{- $imageHeight = $optimized.Height -}} + {{- if site.Params.cdn.enabled -}} + {{- $rawURL := printf "%s%s%s" site.Params.cdn.rawBase $page.File.Dir $resource.Name -}} + {{- $image = printf "https://wsrv.nl/?url=%s&w=1200&h=630&fit=inside&output=webp&q=85" $rawURL -}} + {{- $imageWidth = "1200" -}} + {{- $imageHeight = "630" -}} + {{- else -}} + {{- $optimized := $resource.Resize "1200x630 webp q85" -}} + {{- $image = $optimized.Permalink -}} + {{- $imageWidth = $optimized.Width -}} + {{- $imageHeight = $optimized.Height -}} + {{- end -}} {{- end -}} {{- end -}} {{- end -}} From 25b1f07af1481e556f6256df56961ae8d5f642a2 Mon Sep 17 00:00:00 2001 From: Paul Keen <125715+pftg@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:20:54 +0200 Subject: [PATCH 2/2] refactor(ci): deduplicate builds, upgrade Hugo 0.160.1, Node 24 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build once, test with precompiled output: - Hugo build in _hugo.yml uploads artifact, unit tests download it - Unit tests moved from test.yml to publish.yml (needs: build) - test.yml now only handles manual screenshot tests - PRECOMPILED_ASSETS skips redundant Hugo rebuild in test helper Hugo & actions upgrade: - Hugo 0.149.1 → 0.160.1 (CI workflow + setup-hugo action) - Migrate site.Data → hugo.Data (8 files, fixes deprecation warning) - configure-pages v5→v6, upload/deploy-pages v4→v5 - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 for peaceiris/actions-hugo v3 CDN cleanup from review: - Extract shared partials/cdn/url.html (was 10+ copies) - Fix trailing & in CDN URL when params empty - Fix duplicate twitter:card meta tag - Fix OG image fit=inside → fit=cover - Remove SVG/mp4 from blog media cleanup - Add preconnect hint for wsrv.nl - bin/hugo-build: accept OUTPUT_DIR and BASE_URL env vars Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/actions/setup-hugo/action.yml | 14 +++- .github/workflows/_hugo.yml | 14 ++-- .github/workflows/publish.yml | 19 +++++ .github/workflows/test.yml | 28 ++----- .gitignore | 1 + bin/hugo-build | 13 ++- config/_default/hugo.toml | 2 - docs/workflows/cdn-image-proxy.md | 72 ++++------------ layouts/partials/seo/enhanced-meta-tags.html | 84 +++++-------------- test/unit/cdn_image_proxy_test.rb | 74 +++++++--------- .../beaver/layouts/_markup/render-image.html | 12 +-- themes/beaver/layouts/_shortcodes/img.html | 7 +- .../layouts/partials/blog/img-cropped.html | 18 ++-- .../beaver/layouts/partials/blog/json-ld.html | 2 +- themes/beaver/layouts/partials/cdn/url.html | 6 ++ .../partials/components/testimonial.html | 2 +- .../layouts/partials/data/authors-cached.html | 2 +- .../layouts/partials/data/company-cached.html | 2 +- .../partials/data/testimonials-cached.html | 2 +- .../layouts/partials/homepage/companies.html | 2 +- .../layouts/partials/page/testimonials.html | 2 +- .../partials/seo/enhanced-meta-tags.html | 55 ++++++------ .../beaver/layouts/partials/technologies.html | 2 +- 23 files changed, 172 insertions(+), 263 deletions(-) create mode 100644 themes/beaver/layouts/partials/cdn/url.html diff --git a/.github/actions/setup-hugo/action.yml b/.github/actions/setup-hugo/action.yml index 81e8d8fe2..c06fa1969 100644 --- a/.github/actions/setup-hugo/action.yml +++ b/.github/actions/setup-hugo/action.yml @@ -4,14 +4,20 @@ description: 'Install Hugo + Bun, cache dependencies and build artifacts' inputs: hugo-version: description: 'Hugo version' - default: '0.149.1' + default: '0.160.1' environment: description: 'Hugo build environment' - default: 'development' + default: 'production' + output-dir: + description: 'Hugo build output directory' + default: '_dest/public-test' + base-url: + description: 'Hugo base URL (default: /)' + default: '/' runs: using: 'composite' steps: - - uses: peaceiris/actions-hugo@v3 + - uses: jetthoughts/actions-hugo@feat/node24 with: hugo-version: ${{ inputs.hugo-version }} extended: true @@ -48,3 +54,5 @@ runs: env: HUGO_CACHEDIR: /tmp/hugo_cache ENVIRONMENT: ${{ inputs.environment }} + OUTPUT_DIR: ${{ inputs.output-dir }} + BASE_URL: ${{ inputs.base-url }} diff --git a/.github/workflows/_hugo.yml b/.github/workflows/_hugo.yml index e878588e0..eed151f1a 100644 --- a/.github/workflows/_hugo.yml +++ b/.github/workflows/_hugo.yml @@ -16,9 +16,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Setup Hugo - uses: peaceiris/actions-hugo@v3 + uses: jetthoughts/actions-hugo@feat/node24 with: - hugo-version: 0.149.1 + hugo-version: 0.160.1 extended: true - name: Checkout @@ -28,7 +28,7 @@ jobs: - name: Setup Pages id: pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 # Tier 1: Dependencies cache (changes infrequently) - uses: actions/cache@v5 @@ -80,12 +80,12 @@ jobs: - name: Remove blog media from artifact run: | - find public/blog/ -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.webp' -o -iname '*.svg' -o -iname '*.mp4' \) -delete + find public/blog/ -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.webp' \) -delete echo "Cleaned blog media. Artifact size:" du -sh public/ - - name: Upload artifact - uses: actions/upload-pages-artifact@v4 + - name: Upload pages artifact + uses: actions/upload-pages-artifact@v5 with: path: ./public @@ -100,4 +100,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 875d7f71b..c30e6be6f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -29,3 +29,22 @@ defaults: jobs: build_and_deploy: uses: ./.github/workflows/_hugo.yml + + unit_tests: + name: Unit Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '4.0' + bundler-cache: true + + - uses: ./.github/actions/setup-hugo + + - run: bundle exec rake test:unit + env: + PRECOMPILED_ASSETS: '1' + HUGO_DEFAULT_PATH: _dest/public-test diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fb9c4d1b6..a8a5a0120 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,15 +1,13 @@ --- -name: Tests +name: Screenshot Tests on: - pull_request: - types: [opened, synchronize, reopened] workflow_dispatch: inputs: screenshots: description: 'Run screenshot tests (slow, requires Hugo build)' type: boolean - default: false + default: true update-baselines: description: 'Re-record screenshot baselines and commit' type: boolean @@ -20,26 +18,10 @@ permissions: pull-requests: write concurrency: - group: tests-${{ github.event.pull_request.number || github.ref }} + group: screenshots-${{ github.ref }} cancel-in-progress: true jobs: - unit: - name: Unit Tests - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v6 - - - uses: ruby/setup-ruby@v1 - with: - ruby-version: '4.0' - bundler-cache: true - - - uses: ./.github/actions/setup-hugo - - - run: bundle exec rake test:unit - screenshots: name: Screenshot Tests if: ${{ inputs.screenshots || inputs.update-baselines }} @@ -63,12 +45,16 @@ jobs: run: bundle exec rake test env: SCREENSHOT_DRIVER: vips + PRECOMPILED_ASSETS: '1' + HUGO_DEFAULT_PATH: _dest/public-test - name: Record baselines if: ${{ inputs.update-baselines }} run: FORCE_SCREENSHOT_UPDATE=true bundle exec rake test env: SCREENSHOT_DRIVER: vips + PRECOMPILED_ASSETS: '1' + HUGO_DEFAULT_PATH: _dest/public-test - name: Commit updated baselines if: ${{ inputs.update-baselines }} diff --git a/.gitignore b/.gitignore index c6a36e918..4fc64e56f 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,4 @@ docs/projects/2509-css-migration/50-59-execution/ *.local.* .cache _workspace +.claude/scheduled_tasks.lock diff --git a/bin/hugo-build b/bin/hugo-build index 7a920420e..8080fcf7e 100755 --- a/bin/hugo-build +++ b/bin/hugo-build @@ -4,12 +4,21 @@ set -euo pipefail # Default settings -OUTPUT_DIR="_dest/public-dev" +OUTPUT_DIR=${OUTPUT_DIR:-_dest/public-dev} ENVIRONMENT=${ENVIRONMENT:-development} +BASE_URL=${BASE_URL:-} + echo "Building Hugo site..." echo "Environment: $ENVIRONMENT" echo "Output: $OUTPUT_DIR" -hugo build --noBuildLock --environment "$ENVIRONMENT" --destination "$OUTPUT_DIR" + +BUILD_DRAFTS=${BUILD_DRAFTS:-} + +HUGO_ARGS=(hugo build --noBuildLock --environment "$ENVIRONMENT" --destination "$OUTPUT_DIR") +[ -n "$BASE_URL" ] && HUGO_ARGS+=(--baseURL "$BASE_URL") +[ -n "$BUILD_DRAFTS" ] && HUGO_ARGS+=(--buildDrafts) + +"${HUGO_ARGS[@]}" echo "✓ Build complete" diff --git a/config/_default/hugo.toml b/config/_default/hugo.toml index 5e004f1b3..95c9f3a57 100644 --- a/config/_default/hugo.toml +++ b/config/_default/hugo.toml @@ -55,9 +55,7 @@ disableKinds = [] [params.cdn] enabled = false # enabled per-environment in config/production/hugo.toml - provider = "wsrv" # "wsrv" (free proxy) or "cloudflare" (R2 bucket) rawBase = "raw.githubusercontent.com/jetthoughts/jetthoughts.github.io/master/content/" - # cloudflareBase = "cdn.jetthoughts.com" # uncomment for Cloudflare R2 [params] email="info@jetthoughts.com" diff --git a/docs/workflows/cdn-image-proxy.md b/docs/workflows/cdn-image-proxy.md index bc3c6a5c9..bc4b1cacb 100644 --- a/docs/workflows/cdn-image-proxy.md +++ b/docs/workflows/cdn-image-proxy.md @@ -4,9 +4,9 @@ Solves the GitHub Pages 1 GB artifact limit by serving blog images from a CDN pr ## How It Works -1. **Development** (`hugo server`): Images processed locally by Hugo as before — no change. -2. **Production** (`--environment production`): Templates emit CDN URLs instead of local paths. Hugo still reads images for dimensions but skips processing. -3. **GitHub Actions**: After Hugo build, all blog media files are deleted from `public/blog/` before uploading the artifact. +1. **Development** (`hugo server`): Images processed locally by Hugo — no change. +2. **Production** (`--environment production`): Templates emit CDN URLs instead of local paths via `partials/cdn/url.html`. +3. **GitHub Actions**: After Hugo build, blog media files are deleted from `public/blog/` before uploading the artifact. ## Configuration @@ -14,7 +14,6 @@ Solves the GitHub Pages 1 GB artifact limit by serving blog images from a CDN pr ```toml [params.cdn] enabled = false - provider = "wsrv" rawBase = "raw.githubusercontent.com/jetthoughts/jetthoughts.github.io/master/content/" ``` @@ -26,7 +25,14 @@ Solves the GitHub Pages 1 GB artifact limit by serving blog images from a CDN pr Gate in templates: `{{ if site.Params.cdn.enabled }}` -**Important:** Root `layouts/` overrides `themes/beaver/layouts/`. Both copies of `enhanced-meta-tags.html` must be updated. +## Shared Partial + +`themes/beaver/layouts/partials/cdn/url.html` — single source for CDN URL construction: + +```text +{{ partial "cdn/url" (dict "page" $page "resource" $resource "params" "w=400&output=webp&q=80") }} +→ "https://wsrv.nl/?url=raw.githubusercontent.com/.../image.jpg&w=400&output=webp&q=80" +``` ## Templates Modified @@ -37,64 +43,20 @@ Gate in templates: `{{ if site.Params.cdn.enabled }}` | `partials/seo/enhanced-meta-tags.html` | OG image (1200×630) via wsrv.nl | | `_shortcodes/img.html` | Direct image via wsrv.nl | -## Solution 1: wsrv.nl (Current) +**Note:** Root `layouts/` overrides `themes/beaver/layouts/`. Both `enhanced-meta-tags.html` copies must stay in sync. -Free image proxy at [wsrv.nl](https://wsrv.nl). No account needed. Supports resizing, format conversion, quality control. - -**URL pattern:** -``` -https://wsrv.nl/?url=raw.githubusercontent.com/OWNER/REPO/BRANCH/content/PATH&w=WIDTH&output=FORMAT&q=QUALITY -``` +## wsrv.nl Params -**Params used:** - `&w=N` — width resize - `&h=N` — height resize - `&output=webp|jpeg` — format conversion -- `&q=N` — quality (80 default, 85 OG, 90 thumbnails) -- `&fit=inside` — aspect-preserving fit (OG images) - -**Pros:** Zero cost, zero setup, good caching. -**Cons:** Third-party dependency, no SLA, images must be in a public repo. - -## Solution 2: Cloudflare R2 (Future) - -Upload all image variants to Cloudflare R2 bucket with a custom domain (e.g., `cdn.jetthoughts.com`). - -### Setup Required - -1. Create R2 bucket + connect custom domain -2. Add GitHub secrets: `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_ENDPOINT_URL`, `R2_BUCKET_NAME` -3. Change config: - ```toml - [params.cdn] - enabled = true - provider = "cloudflare" - cloudflareBase = "cdn.jetthoughts.com" - ``` -4. Update templates to check `provider` and use `cloudflareBase` for URL prefix -5. Add sync step to GitHub Actions before media cleanup: - ```yaml - - name: Sync media to Cloudflare R2 - env: - AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: auto - ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} - run: | - aws s3 sync ./public/ s3://${{ secrets.R2_BUCKET_NAME }}/ \ - --endpoint-url $ENDPOINT_URL \ - --exclude "*" \ - --include "*.jpg" --include "*.jpeg" --include "*.png" \ - --include "*.gif" --include "*.webp" --include "*.mp4" - ``` - -**Pros:** Full control, SLA, works with private repos, custom domain. -**Cons:** Requires R2 account, AWS CLI in CI, storage costs (minimal). +- `&q=N` — quality (80 content, 85 OG, 90 thumbnails) +- `&fit=cover` — crop to exact dimensions (OG images) ## Not Yet Covered -These templates still use local Hugo processing (non-blog assets from `assets/`/`static/`, not affected by blog media cleanup): +Non-blog image templates (assets from `assets/`/`static/`, unaffected by blog media cleanup): -- `partials/img/generic.html` (hero, homepage, testimonials, etc.) +- `partials/img/generic.html` (hero, homepage, testimonials) - `partials/img/resize.html` - `partials/page/cover_image.html` diff --git a/layouts/partials/seo/enhanced-meta-tags.html b/layouts/partials/seo/enhanced-meta-tags.html index ed2c4ce29..341e7523a 100644 --- a/layouts/partials/seo/enhanced-meta-tags.html +++ b/layouts/partials/seo/enhanced-meta-tags.html @@ -97,76 +97,31 @@ {{- end -}} {{- end -}} -{{/* Image Handling - metatags.image is primary, then cover_image/cover/featured_image */}} +{{/* Image Handling — try frontmatter fields in priority order */}} {{- $image := "" -}} {{- $imageWidth := "1200" -}} {{- $imageHeight := "630" -}} -{{/* Try metatags.image first (primary convention) */}} -{{- with .Params.metatags.image -}} - {{- $resource := $page.Resources.Get . -}} - {{- if $resource -}} - {{- if site.Params.cdn.enabled -}} - {{- $rawURL := printf "%s%s%s" site.Params.cdn.rawBase $page.File.Dir $resource.Name -}} - {{- $image = printf "https://wsrv.nl/?url=%s&w=1200&h=630&fit=inside&output=webp&q=85" $rawURL -}} +{{- $imageFields := slice "metatags.image" "cover_image" "cover" "featured_image" -}} +{{- range $field := $imageFields -}} + {{- if not $image -}} + {{- $value := "" -}} + {{- if eq $field "metatags.image" -}} + {{- $value = $page.Params.metatags.image -}} {{- else -}} - {{- $optimized := $resource.Resize "1200x630 jpg q90" -}} - {{- $image = $optimized.Permalink -}} - {{- $imageWidth = $optimized.Width -}} - {{- $imageHeight = $optimized.Height -}} + {{- $value = index $page.Params $field -}} {{- end -}} - {{- end -}} -{{- end -}} - -{{/* Try cover_image */}} -{{- if not $image -}} - {{- with .Params.cover_image -}} - {{- $resource := $page.Resources.Get . -}} - {{- if $resource -}} - {{- if site.Params.cdn.enabled -}} - {{- $rawURL := printf "%s%s%s" site.Params.cdn.rawBase $page.File.Dir $resource.Name -}} - {{- $image = printf "https://wsrv.nl/?url=%s&w=1200&h=630&fit=inside&output=webp&q=85" $rawURL -}} - {{- else -}} - {{- $optimized := $resource.Resize "1200x630 jpg q90" -}} - {{- $image = $optimized.Permalink -}} - {{- $imageWidth = $optimized.Width -}} - {{- $imageHeight = $optimized.Height -}} - {{- end -}} - {{- end -}} - {{- end -}} -{{- end -}} - -{{/* Try cover (alias) */}} -{{- if not $image -}} - {{- with .Params.cover -}} - {{- $resource := $page.Resources.Get . -}} - {{- if $resource -}} - {{- if site.Params.cdn.enabled -}} - {{- $rawURL := printf "%s%s%s" site.Params.cdn.rawBase $page.File.Dir $resource.Name -}} - {{- $image = printf "https://wsrv.nl/?url=%s&w=1200&h=630&fit=inside&output=webp&q=85" $rawURL -}} - {{- else -}} - {{- $optimized := $resource.Resize "1200x630 jpg q90" -}} - {{- $image = $optimized.Permalink -}} - {{- $imageWidth = $optimized.Width -}} - {{- $imageHeight = $optimized.Height -}} - {{- end -}} - {{- end -}} - {{- end -}} -{{- end -}} - -{{/* Try featured_image (legacy) */}} -{{- if not $image -}} - {{- with .Params.featured_image -}} - {{- $resource := $page.Resources.Get . -}} - {{- if $resource -}} - {{- if site.Params.cdn.enabled -}} - {{- $rawURL := printf "%s%s%s" site.Params.cdn.rawBase $page.File.Dir $resource.Name -}} - {{- $image = printf "https://wsrv.nl/?url=%s&w=1200&h=630&fit=inside&output=webp&q=85" $rawURL -}} - {{- else -}} - {{- $optimized := $resource.Resize "1200x630 jpg q90" -}} - {{- $image = $optimized.Permalink -}} - {{- $imageWidth = $optimized.Width -}} - {{- $imageHeight = $optimized.Height -}} + {{- with $value -}} + {{- $resource := $page.Resources.Get . -}} + {{- if $resource -}} + {{- if site.Params.cdn.enabled -}} + {{- $image = partial "cdn/url" (dict "page" $page "resource" $resource "params" "w=1200&h=630&fit=cover&output=webp&q=85") -}} + {{- else -}} + {{- $optimized := $resource.Resize "1200x630 jpg q90" -}} + {{- $image = $optimized.Permalink -}} + {{- $imageWidth = $optimized.Width -}} + {{- $imageHeight = $optimized.Height -}} + {{- end -}} {{- end -}} {{- end -}} {{- end -}} @@ -182,7 +137,6 @@ - {{- end -}} diff --git a/test/unit/cdn_image_proxy_test.rb b/test/unit/cdn_image_proxy_test.rb index 6f0734e01..1c93fe7ae 100644 --- a/test/unit/cdn_image_proxy_test.rb +++ b/test/unit/cdn_image_proxy_test.rb @@ -8,14 +8,12 @@ def setup @blog_posts = Dir.glob("#{root_path}/blog/*/index.html") .reject { |f| f.end_with?("blog/index.html") } - skip "No blog posts found" if @blog_posts.empty? + refute_empty @blog_posts, "Expected compiled blog posts in blog/*/index.html" end def test_markdown_images_use_cdn_picture_element - post_with_image = find_post_with_picture_element - skip "No blog post with element found" unless post_with_image - - doc = Nokogiri::HTML(File.read(post_with_image)) + post = require_post_with_picture_element + doc = Nokogiri::HTML(File.read(post)) picture = doc.css("picture").first sources = picture.css("source") @@ -30,50 +28,37 @@ def test_markdown_images_use_cdn_picture_element assert_match(/\d+w/, srcset, "Srcset should include width descriptors") end - # Fallback img inside picture img = picture.css("img").first refute_nil img, "Picture element must have fallback img" assert_includes img["src"], "wsrv.nl", "Fallback img should use wsrv.nl CDN" - assert img["loading"] == "lazy", "Images should have lazy loading" + assert_equal "lazy", img["loading"], "Images should have lazy loading" end def test_cdn_urls_have_correct_sizes - post_with_image = find_post_with_picture_element - skip "No blog post with element found" unless post_with_image - - doc = Nokogiri::HTML(File.read(post_with_image)) - source = doc.css("picture source").first - srcset = source["srcset"] + post = require_post_with_picture_element + doc = Nokogiri::HTML(File.read(post)) + srcset = doc.css("picture source").first["srcset"] - # Should have 4 size variants: 400, 800, 1200, 1600 %w[w=400 w=800 w=1200 w=1600].each do |size| assert_includes srcset, size, "Srcset should include #{size}" end end - def test_cdn_urls_point_to_github_raw - post_with_image = find_post_with_picture_element - skip "No blog post with element found" unless post_with_image - - doc = Nokogiri::HTML(File.read(post_with_image)) - source = doc.css("picture source").first - srcset = source["srcset"] + def test_cdn_urls_proxy_through_wsrv + post = require_post_with_picture_element + doc = Nokogiri::HTML(File.read(post)) + srcset = doc.css("picture source").first["srcset"] - assert_includes srcset, "raw.githubusercontent.com", - "CDN URLs should proxy from GitHub raw content" - assert_includes srcset, "jetthoughts/jetthoughts.github.io", - "CDN URLs should reference this repository" + assert_match(/url=.*\/content\//, srcset, "CDN URLs should proxy source content path") end def test_blog_thumbnails_use_cdn doc = parse_html_file("blog/index.html") - # Blog listing page thumbnails thumbnail_imgs = doc.css(".blog-post img, .post-image img, article img") cdn_thumbnails = thumbnail_imgs.select { |img| img["src"]&.include?("wsrv.nl") } - # At least some thumbnails should use CDN (posts with cover images) - assert cdn_thumbnails.any?, "Blog listing thumbnails should use wsrv.nl CDN" + refute_empty cdn_thumbnails, "Blog listing thumbnails should use wsrv.nl CDN" cdn_thumbnails.each do |img| src = img["src"] @@ -87,25 +72,20 @@ def test_og_images_use_cdn content = File.read(path) content.include?("og:image") && content.include?("wsrv.nl") end - skip "No blog post with CDN og:image found" unless post_with_og + refute_nil post_with_og, "At least one blog post should have CDN og:image" doc = Nokogiri::HTML(File.read(post_with_og)) - og_image = doc.css("meta[property='og:image']").first - - refute_nil og_image, "Blog post should have og:image meta tag" - og_url = og_image["content"] + og_url = doc.css("meta[property='og:image']").first["content"] assert_includes og_url, "wsrv.nl", "OG image should use wsrv.nl CDN" assert_match(/w=1200/, og_url, "OG image should be 1200px wide") assert_match(/h=630/, og_url, "OG image should be 630px tall") - assert_match(/output=webp/, og_url, "OG image should use WebP format") + assert_match(/fit=cover/, og_url, "OG image should use cover fit") end def test_picture_element_has_sizes_attribute - post_with_image = find_post_with_picture_element - skip "No blog post with element found" unless post_with_image - - doc = Nokogiri::HTML(File.read(post_with_image)) + post = require_post_with_picture_element + doc = Nokogiri::HTML(File.read(post)) source = doc.css("picture source").first assert source["sizes"], "Source should have sizes attribute" @@ -113,10 +93,8 @@ def test_picture_element_has_sizes_attribute end def test_images_preserve_dimensions - post_with_image = find_post_with_picture_element - skip "No blog post with element found" unless post_with_image - - doc = Nokogiri::HTML(File.read(post_with_image)) + post = require_post_with_picture_element + doc = Nokogiri::HTML(File.read(post)) img = doc.css("picture img").first assert img["width"], "Image should have width attribute for CLS prevention" @@ -125,10 +103,14 @@ def test_images_preserve_dimensions private - def find_post_with_picture_element - @blog_posts.find do |path| - content = File.read(path) - content.include?("") && content.include?("wsrv.nl") + def require_post_with_picture_element + @_post_with_picture ||= begin + post = @blog_posts.find do |path| + content = File.read(path) + content.include?("") && content.include?("wsrv.nl") + end + refute_nil post, "Expected at least one blog post with CDN element" + post end end end diff --git a/themes/beaver/layouts/_markup/render-image.html b/themes/beaver/layouts/_markup/render-image.html index 5966f8ff2..7a1fb2800 100644 --- a/themes/beaver/layouts/_markup/render-image.html +++ b/themes/beaver/layouts/_markup/render-image.html @@ -8,9 +8,9 @@ {{ if $isGif }} {{ if $useCDN }} - {{ $rawURL := printf "%s%s%s" site.Params.cdn.rawBase .Page.File.Dir $src.Name }} + {{ $cdnURL := partial "cdn/url" (dict "page" .Page "resource" $src "params" "") }} {{ $alt }} @@ -22,22 +22,22 @@ width="{{ $src.Width }}"> {{ end }} {{ else if $useCDN }} - {{/* CDN: construct wsrv.nl URLs for responsive images */}} {{ $formats := slice "webp" "jpeg" }} {{ $sizes := slice 400 800 1200 1600 }} - {{ $rawURL := printf "%s%s%s" site.Params.cdn.rawBase .Page.File.Dir $src.Name }} {{ range $format := $formats }} {{ $srcset := slice }} {{ range $size := $sizes }} - {{ $srcset = $srcset | append (printf "https://wsrv.nl/?url=%s&w=%d&output=%s&q=80 %dw" $rawURL $size $format $size) }} + {{ $url := partial "cdn/url" (dict "page" $.Page "resource" $src "params" (printf "w=%d&output=%s&q=80" $size $format)) }} + {{ $srcset = $srcset | append (printf "%s %dw" $url $size) }} {{ end }} {{ end }} + {{ $fallbackURL := partial "cdn/url" (dict "page" .Page "resource" $src "params" "w=400&output=jpeg&q=80") }} {{ $alt }} diff --git a/themes/beaver/layouts/_shortcodes/img.html b/themes/beaver/layouts/_shortcodes/img.html index 1bf375f38..4aa8cfa64 100644 --- a/themes/beaver/layouts/_shortcodes/img.html +++ b/themes/beaver/layouts/_shortcodes/img.html @@ -1,9 +1,8 @@ {{ $image := .Page.Resources.GetMatch (printf "%s" (.Get 0)) }} {{ if $image }} -{{ $useCDN := site.Params.cdn.enabled }} -{{ if $useCDN }} -{{ $rawURL := printf "%s%s%s" site.Params.cdn.rawBase .Page.File.Dir $image.Name }} -{{ .Get 1 }} +{{ if site.Params.cdn.enabled }} +{{ $cdnURL := partial "cdn/url" (dict "page" .Page "resource" $image "params" "") }} +{{ .Get 1 }} {{ else }} {{ .Get 1 }} {{ end }} diff --git a/themes/beaver/layouts/partials/blog/img-cropped.html b/themes/beaver/layouts/partials/blog/img-cropped.html index b8f37e2a9..716af4fb7 100644 --- a/themes/beaver/layouts/partials/blog/img-cropped.html +++ b/themes/beaver/layouts/partials/blog/img-cropped.html @@ -5,30 +5,22 @@ {{- if $page.Params.metatags.image -}} {{ $image = $page.Resources.Get $page.Params.metatags.image }} {{- else if $page.Params.cover_image -}} - {{/* Try cover_image */}} {{ $image = $page.Resources.Get $page.Params.cover_image }} {{- else if $page.Params.cover -}} - {{/* Try cover (alias) */}} {{ $image = $page.Resources.Get $page.Params.cover }} {{- else if $page.Params.featured_image -}} - {{/* Try featured_image (legacy) */}} {{ $image = $page.Resources.Get $page.Params.featured_image }} {{- end -}} {{ if $image }} -{{ $useCDN := site.Params.cdn.enabled }} {{ $retinaW := mul $.width 2 }} -{{ if $useCDN }} -{{ $rawURL := printf "%s%s%s" site.Params.cdn.rawBase $page.File.Dir $image.Name }} -{{ $page.Title }} +{{ if site.Params.cdn.enabled }} +{{ $cdnURL := partial "cdn/url" (dict "page" $page "resource" $image "params" (printf "w=%d&output=jpg&q=90" $retinaW)) }} +{{ $page.Title }} {{ else }} -{{/* Aspect-preserving resize at 2× retina. The cover is designed as a - landscape composition (2400×1260 = 1.905:1) following the canonical - 6-slot layout in .stitch/design.md — we must NOT center-crop it - because the left-aligned hero + right visual would be mangled. - CSS uses object-fit: contain with a dark background to letterbox - cleanly inside the 180×180 thumbnail container. */}} +{{/* Aspect-preserving resize at 2× retina. CSS uses object-fit: contain + with a dark background to letterbox inside the thumbnail container. */}} {{ $resized := $image.Resize (printf "%dx jpg q90" $retinaW) }} {{ $page.Title }} {{ end }} diff --git a/themes/beaver/layouts/partials/blog/json-ld.html b/themes/beaver/layouts/partials/blog/json-ld.html index 0ad721697..0bdb911d1 100644 --- a/themes/beaver/layouts/partials/blog/json-ld.html +++ b/themes/beaver/layouts/partials/blog/json-ld.html @@ -14,7 +14,7 @@ "datePublished": "{{ with .Params.created_at }}{{ . }}{{ else }}{{ .Date.Format "2006-01-02T15:04:05Z07:00" }}{{ end }}", "dateModified": "{{ .Lastmod.Format "2006-01-02T15:04:05Z07:00" }}", "mainEntityOfPage": "{{ .Permalink }}", - "publisher": {{ site.Data.company | jsonify }}, + "publisher": {{ hugo.Data.company | jsonify }}, {{ with (.Resources.Get .Params.metatags.image) }} "image": { "@type": "ImageObject", diff --git a/themes/beaver/layouts/partials/cdn/url.html b/themes/beaver/layouts/partials/cdn/url.html new file mode 100644 index 000000000..09580ea91 --- /dev/null +++ b/themes/beaver/layouts/partials/cdn/url.html @@ -0,0 +1,6 @@ +{{/* Constructs a CDN proxy URL for a page bundle image resource. + Input: dict "page" $page "resource" $resource "params" "w=400&output=webp&q=80" + Returns: "https://wsrv.nl/?url=...&w=400&output=webp&q=80" */}} +{{- $rawURL := printf "%s%s%s" site.Params.cdn.rawBase .page.File.Dir .resource.Name -}} +{{- $suffix := cond (ne .params "") (printf "&%s" .params) "" -}} +{{- return printf "https://wsrv.nl/?url=%s%s" $rawURL $suffix -}} diff --git a/themes/beaver/layouts/partials/components/testimonial.html b/themes/beaver/layouts/partials/components/testimonial.html index 1ed0fee7e..dc9b89752 100644 --- a/themes/beaver/layouts/partials/components/testimonial.html +++ b/themes/beaver/layouts/partials/components/testimonial.html @@ -32,7 +32,7 @@ {{- $rating := .rating | default "4.8" -}} {{- $rating_text := .rating_text | default "Based on client reviews" -}} {{- $node_id := .node_id | default "testimonials" -}} -{{- $testimonialsData := site.Data.testimonials -}} +{{- $testimonialsData := hugo.Data.testimonials -}} {{- $nonCriticalCSS := resources.Get "css/vendors/swiper.min.css" | fingerprint "md5" -}} diff --git a/themes/beaver/layouts/partials/data/authors-cached.html b/themes/beaver/layouts/partials/data/authors-cached.html index 516d72056..3c49cf0c2 100644 --- a/themes/beaver/layouts/partials/data/authors-cached.html +++ b/themes/beaver/layouts/partials/data/authors-cached.html @@ -1,3 +1,3 @@ {{/* Cached authors data access - invalidates when authors.yaml changes */}} -{{ $authorsHash := site.Data.authors | jsonify | md5 }} +{{ $authorsHash := hugo.Data.authors | jsonify | md5 }} {{ return partialCached "data/authors-content.html" . $authorsHash }} diff --git a/themes/beaver/layouts/partials/data/company-cached.html b/themes/beaver/layouts/partials/data/company-cached.html index 27c0b5016..de23944f4 100644 --- a/themes/beaver/layouts/partials/data/company-cached.html +++ b/themes/beaver/layouts/partials/data/company-cached.html @@ -1,3 +1,3 @@ {{/* Cached company data access - invalidates when company.yaml changes */}} -{{ $companyHash := site.Data.company | jsonify | md5 }} +{{ $companyHash := hugo.Data.company | jsonify | md5 }} {{ return partialCached "data/company-content.html" . $companyHash }} diff --git a/themes/beaver/layouts/partials/data/testimonials-cached.html b/themes/beaver/layouts/partials/data/testimonials-cached.html index ecfe747a7..1c39d33f2 100644 --- a/themes/beaver/layouts/partials/data/testimonials-cached.html +++ b/themes/beaver/layouts/partials/data/testimonials-cached.html @@ -1,3 +1,3 @@ {{/* Cached testimonials data access - invalidates when testimonials.yaml changes */}} -{{ $testimonialsHash := site.Data.testimonials | jsonify | md5 }} +{{ $testimonialsHash := hugo.Data.testimonials | jsonify | md5 }} {{ return partialCached "data/testimonials-content.html" . $testimonialsHash }} diff --git a/themes/beaver/layouts/partials/homepage/companies.html b/themes/beaver/layouts/partials/homepage/companies.html index edd854f4e..56c959bb8 100644 --- a/themes/beaver/layouts/partials/homepage/companies.html +++ b/themes/beaver/layouts/partials/homepage/companies.html @@ -1,5 +1,5 @@
    - {{ range $index, $tech := site.Data.companies }} + {{ range $index, $tech := hugo.Data.companies }}