abqari 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fd411b1e1b18a824d5cee74ee6e557c7135fc973f6fc9edb4f4c806edff7cadf
4
- data.tar.gz: bb34137c4de0ffb733d1b911f6e523df2cde7b933d196e07d0e4ca4a4fb91460
3
+ metadata.gz: b9d5e9a272de668ea9e75b33d47fd252c9a46413c6873c15d077a938f3817004
4
+ data.tar.gz: 0d55679d1de17c940765b5a04c81e15980e1929a590fab878b36e65fdc877195
5
5
  SHA512:
6
- metadata.gz: b1c8ea8aa8cad1f13f9dc3eddd81fbbedf891d024b6998f8a1f7aacf7c45c66ce783bea05174d983973c46eac5318372addd0d8b590e29871723f29f1d71a40c
7
- data.tar.gz: 2820827176cec3ecaaf08ad2ceb70c15b506435368154e24d2afc7ea544f2fe959613da696e2bb05eb81eff3011d4c37e07081565e1c4fa2ae2e901073b4e5c8
6
+ metadata.gz: 27ecd3e0f352ce9970cd85256badd8973bcd950092fa67cd56de2f9820862eef89740b3995d8c3e7f692259a9f50d49da1d41e3a76d7912c0873d650b46d101a
7
+ data.tar.gz: 77b4d04a24911bf1705cb074ce4515bd130ee5db2340e1c988c81a5a707c54014bf5a62cc94ca1d83139747d7c87595a3c7c2607a79ae9b2f953c926b7f16d43
data/CHANGELOG.md CHANGED
@@ -9,6 +9,129 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  _Nothing yet._
11
11
 
12
+ ## [1.2.0] — 2026-10-11
13
+
14
+ Upgrade if you serve the generated CSP: Chrome has been blocking
15
+ Abqari's own speculation rules, so nothing was prerendered and every
16
+ page logged a CSP violation, plus a Permissions-Policy error about
17
+ `interest-cohort`. Upgrade too if you deploy to Render, which ignores
18
+ `_headers` (`abqari headers` prints the rules it needs), or through the
19
+ shipped GitHub Actions workflows with `fonts:` set, which deployed
20
+ sites without their fonts.
21
+
22
+ On a host that ignores `_headers`, rebuild after upgrading and re-copy
23
+ the CSP and Permissions-Policy: both changed. Workflow files are copied
24
+ into each site at `abqari new`, so the font fix needs adding to existing
25
+ sites by hand.
26
+
27
+ ### Added
28
+
29
+ - **`speculation_rules.href_matches`** sets the URL patterns to
30
+ prerender: one pattern or a list. The default is unchanged: the posts
31
+ collection (`/posts/*`, or `/<slug>/*` with a custom `slug:`). A site
32
+ whose articles live in another collection prerendered nothing, which
33
+ is how Sanad's `/news/*` surfaced it. Changing it changes the CSP
34
+ hash (see Fixed).
35
+
36
+ - **`abqari headers`** prints the built `_site/_headers` as Render
37
+ header rules, ready to paste into `render.yaml` or the dashboard's
38
+ Settings → Headers. Render static sites don't read `_headers`, so
39
+ without this the CSP, the security headers and the year-long
40
+ `immutable` cache on `/assets/*` were missing, and Render served
41
+ assets with `max-age=0` (fonts revalidated on every page, so text
42
+ flashed). It reads the built file rather than computing headers, so
43
+ the CSP hashes are the ones the site's bundled engine and config
44
+ produced. Run it with `bundle exec` from the site root.
45
+ `docs/deploying.md` now covers Render's headers, the git-ignored
46
+ `vendor/fonts/` and Render's missing AVIF encoder, and no longer
47
+ tells Render users to `apt-get` packages.
48
+
49
+ ### Fixed
50
+
51
+ - **Chrome logged a CSP violation on every page, and nothing was
52
+ prerendered.** "Applying inline speculation rules violates the
53
+ following Content Security Policy directive". The generated CSP
54
+ allowed the head's `<script type="speculationrules">` block only
55
+ through `'inline-speculation-rules'`, and Chrome ignores that keyword
56
+ once `script-src` also lists hashes, which it always does (the theme
57
+ bootstrap and the data-saver guard). It hid because nothing visibly
58
+ breaks: the page works, the dev server doesn't apply `_headers`, and
59
+ the unit tests checked the keyword and the hashes separately.
60
+
61
+ The block's body now comes from `Abqari::SpeculationRules`, which the
62
+ head partial emits and `HeadersFile` hashes, so the two can't drift.
63
+ The keyword stays for browsers that honour it. With unchanged config
64
+ the body is byte-identical to 1.1.x, so a hash already pasted into a
65
+ host's settings keeps working. A site that overrides `_head.html.erb`
66
+ keeps matching too, until it sets `href_matches`; then its block
67
+ needs to emit `<%== Abqari::SpeculationRules.script(site) %>`.
68
+ `test/csp_inline_scripts_test.rb` now checks every inline script on a
69
+ built page against the built policy.
70
+
71
+ - **Chrome logged a Permissions-Policy error about `interest-cohort`
72
+ on every page.** The generated `Permissions-Policy` still opted out
73
+ of FLoC, which Google has dropped. It's gone; `camera`, `microphone`
74
+ and `geolocation` stay off.
75
+
76
+ - **Builds on Render logged dozens of false "Slow page" warnings, each
77
+ blaming a "plugin filter".** One build flagged about 45 pages at
78
+ 500ms+, while the same 270 pages render in about 5ms each locally.
79
+ There were three causes. The render pool was sized by
80
+ `Etc.nprocessors`, which in a container reports the host's CPUs
81
+ rather than the quota, so dozens of threads queued for one GVL.
82
+ Pages were timed by wall-clock, which counted that queueing and any
83
+ CPU throttling as the page's own time. And the plugin-filter cause
84
+ was attached to every slow page whenever any `:post_process` hook was
85
+ registered, without being timed. It hid because a developer machine
86
+ has the CPUs it reports.
87
+
88
+ Pages are now timed by their thread's CPU time, and the warning says
89
+ "of CPU". The pool follows the cgroup CPU quota when there is one,
90
+ capped at 4 (`Renderer::MAX_RENDER_THREADS`): rendering holds the
91
+ GVL, and a 270-page site built no faster on 8 threads than on 1. A
92
+ plugin filter is named only when it used at least a quarter of the
93
+ page's time, and with that time ("plugin filter 340ms"). Time a page
94
+ spends blocked on I/O no longer counts toward the threshold.
95
+
96
+ - **Sites deployed through the shipped GitHub Actions workflows had no
97
+ custom fonts.** The scaffold git-ignores `vendor/fonts/`, so a CI
98
+ checkout has no font files until `bin/fetch-fonts` runs, and
99
+ `deploy-cloudflare.yml`, `deploy-github-pages.yml` and
100
+ `deploy-render.yml` went straight to `bin/build`. The build passed,
101
+ the head (correctly) omitted the missing font stylesheet, and the
102
+ site shipped in fallback fonts. It hid because the developer's own
103
+ checkout has the fonts, so local builds look right.
104
+
105
+ The workflows now run `bundle exec bin/fetch-fonts` before building;
106
+ it's a no-op for a site with no `fonts:`. **Existing sites keep their
107
+ own copies of the workflows and must add the step themselves**, or
108
+ commit `vendor/fonts/`. Render's own build doesn't use these
109
+ workflows: its build command needs `bin/fetch-fonts` too (see
110
+ `docs/deploying.md`).
111
+
112
+ - **Editing a plugin while `bin/serve` was running broke every page with
113
+ an unexplained `NoMethodError`.** Add a helper in `plugins/`, save a
114
+ layout that calls it, and the next rebuild failed every page with
115
+ `undefined method '…' for an instance of Abqari::RenderContext` until
116
+ the server was restarted. Plugins load once, at `Site.new`, and the
117
+ dev server builds its Site once, so neither a plugin nor anything it
118
+ `require`s from the site's `lib/` is ever reloaded. `plugins/` wasn't
119
+ even watched, so the server had no idea anything had changed. It hid
120
+ because the plugin itself was fine: `bin/build` and a freshly started
121
+ `bin/serve` both load it and work.
122
+
123
+ `bin/serve` now watches `plugins/`. When a plugin, or a `lib/` file a
124
+ plugin required, changes, it logs a one-line warning naming the file
125
+ and saying to restart. A build that fails while that Ruby is stale
126
+ ends its error output with the same reminder, because by then the
127
+ first warning has usually scrolled away. The server deliberately
128
+ doesn't reload or re-exec itself: a half-saved plugin would then take
129
+ it down instead of logging an error. Tracking lives in
130
+ `Site#stale_ruby_sources`.
131
+
132
+ `docs/plugins.md` also said a stale plugin's old *and* new callbacks
133
+ both fire on each rebuild. Only the old ones do.
134
+
12
135
  ## [1.1.2] — 2026-10-09
13
136
 
14
137
  ### Fixed
@@ -987,7 +1110,8 @@ run a real working author / publisher site.
987
1110
  commonmarker ≥ 2.4 ships precompiled 4.0 binaries, which is what
988
1111
  the `~> 2.4` floor bought.
989
1112
 
990
- [Unreleased]: https://github.com/grantrayner/abqari/compare/v1.1.2...HEAD
1113
+ [Unreleased]: https://github.com/grantrayner/abqari/compare/v1.2.0...HEAD
1114
+ [1.2.0]: https://github.com/grantrayner/abqari/compare/v1.1.2...v1.2.0
991
1115
  [1.1.2]: https://github.com/grantrayner/abqari/compare/v1.1.1...v1.1.2
992
1116
  [1.1.1]: https://github.com/grantrayner/abqari/compare/v1.1.0...v1.1.1
993
1117
  [1.1.0]: https://github.com/grantrayner/abqari/compare/v1.0.0...v1.1.0
data/README.md CHANGED
@@ -11,7 +11,7 @@ optional overrides), while the engine — layouts, themes, helpers, asset
11
11
  pipeline — lives in the dependency. Forking the engine is also
12
12
  supported for users who want to own all the code.
13
13
 
14
- > **Status:** v1.1.2 — see
14
+ > **Status:** v1.2.0 — see
15
15
  > [CHANGELOG.md](https://github.com/grantrayner/abqari/blob/main/CHANGELOG.md).
16
16
  > Distributed via RubyGems (`gem install abqari`) and as a Bundler
17
17
  > path/git source. The API follows [semver](https://semver.org/) from
@@ -174,7 +174,7 @@ Test that it worked:
174
174
  abqari version
175
175
  ```
176
176
 
177
- You should see a version number, e.g. `abqari 1.1.2`. If you get
177
+ You should see a version number, e.g. `abqari 1.2.0`. If you get
178
178
  `command not found`, see [Troubleshooting](#troubleshooting) below.
179
179
 
180
180
  ### Step 3 — Create your first site
@@ -460,7 +460,7 @@ Edit `Gemfile`:
460
460
  ```ruby
461
461
  source 'https://rubygems.org'
462
462
 
463
- gem 'abqari', '~> 1.1'
463
+ gem 'abqari', '~> 1.2'
464
464
 
465
465
  group :development do
466
466
  gem 'listen', '~> 3.8' # optional, for the dev-server file watcher
@@ -470,7 +470,7 @@ end
470
470
  Or — pin to a git ref while tracking pre-release work:
471
471
 
472
472
  ```ruby
473
- gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.1.2'
473
+ gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.2.0'
474
474
  ```
475
475
 
476
476
  Install the libvips C library once (hard dep for the image pipeline):
@@ -1631,6 +1631,8 @@ bin/fetch-fonts
1631
1631
 
1632
1632
  Abqari downloads the woff2 files to `vendor/fonts/`, generates a `fonts.css` with rewritten URLs, and serves everything from `/assets/fonts/` at build time. The stylesheet is fingerprinted like any other asset (`/assets/fonts/fonts.<hash>.css`), so changing `fonts:` and re-running `bin/fetch-fonts` busts the immutable cache. The head partial links it whenever `fonts:` is configured and the fetch has run.
1633
1633
 
1634
+ `vendor/fonts/` is git-ignored, so every deploy has to fetch the fonts too, or the site ships in fallback fonts with a green build. The shipped GitHub Actions workflows run `bin/fetch-fonts` before `bin/build`; on a host that builds from its own command (Render), add it there. Or commit `vendor/fonts/` instead. See [deploying.md → Fonts](https://github.com/grantrayner/abqari/blob/main/docs/deploying.md#fonts).
1635
+
1634
1636
  If you override the head partial, link the stylesheet with `<%= asset_path('fonts/fonts.css') %>`. An unhashed copy still ships at `/assets/fonts/fonts.css`, but `_headers` caches it for a year, so returning visitors won't see font changes.
1635
1637
 
1636
1638
  ## Icons
@@ -1763,7 +1765,7 @@ Every build emits the files a static host needs to serve a real site:
1763
1765
  | `feed.xml` | RSS 2.0 feed of latest posts |
1764
1766
  | `feed.json` | JSON Feed 1.1 |
1765
1767
  | `robots.txt` | Per-environment — `Disallow: /` in dev/staging, real one in production |
1766
- | `_headers` | Security headers (CSP, HSTS, Permissions-Policy, etc.) + asset cache-control. Read by Cloudflare Pages and Netlify |
1768
+ | `_headers` | Security headers (CSP, HSTS, Permissions-Policy, etc.) + asset cache-control. Read by Cloudflare Pages and Netlify. Render ignores it — `bundle exec abqari headers` prints the same rules to paste in (see [deploying.md](https://github.com/grantrayner/abqari/blob/main/docs/deploying.md#headers-render-ignores-_headers)) |
1767
1769
  | `_redirects` | 301 mappings for any post with `redirect_from:` |
1768
1770
  | `manifest.json` | Web app manifest, populated from your site title + locale. Override colours/names under `manifest:` in `site.yml` |
1769
1771
  | `/.well-known/security.txt` | Generated when `security_contact:` is set |
@@ -1784,7 +1786,7 @@ Abqari generates a stub HTML page at `/old-tutorial/` with a `<meta refresh>` an
1784
1786
 
1785
1787
  **SEO meta** — every page's `<head>` includes canonical URL, Open Graph tags, Twitter card tags, RSS/JSON Feed alternates, and (for posts) JSON-LD `Article` structured data. All driven from `site.config` and frontmatter — no manual tagging required.
1786
1788
 
1787
- **Security headers** — the default `_headers` ships with a strict CSP (`'self'` only — works because Abqari has no inline scripts/styles), HSTS (1 year), `Referrer-Policy`, `Permissions-Policy` (camera/mic/geolocation/FLoC all off), `X-Content-Type-Options`, and `frame-ancestors 'none'`.
1789
+ **Security headers** — the default `_headers` ships with a strict CSP (`'self'` only, with each of Abqari's few inline scripts pinned by SHA-256 rather than allowed by `'unsafe-inline'`), HSTS (1 year), `Referrer-Policy`, `Permissions-Policy` (camera/mic/geolocation off), `X-Content-Type-Options`, and `frame-ancestors 'none'`.
1788
1790
 
1789
1791
  The policy is *built* from the features you've configured — enabling Plausible adds `plausible.io`, setting `newsletter.action` adds that provider's origin, and so on. You never hand-write it. Two keys extend it when the engine can't infer what you need:
1790
1792
 
@@ -1879,8 +1881,14 @@ for readers on slow or data-saving connections. Tune or disable:
1879
1881
  speculation_rules:
1880
1882
  enabled: true # false to omit entirely
1881
1883
  eagerness: moderate # conservative / moderate / eager
1884
+ href_matches: /news/* # default: the posts collection (/posts/*); one pattern or a list
1882
1885
  ```
1883
1886
 
1887
+ Set `href_matches` when your main content isn't in `posts` — a site
1888
+ whose articles live in a `news` collection prerenders nothing until it
1889
+ does. The generated CSP pins the rules block by hash, so changing any
1890
+ of these changes the CSP; on a host that ignores `_headers`, re-copy it.
1891
+
1884
1892
  Run `bin/audit` after building — it checks every page for missing
1885
1893
  descriptions, broken links, missing alt text, and other SEO and
1886
1894
  accessibility problems before you deploy.
@@ -2416,7 +2424,7 @@ Run a single test file: `bundle exec ruby -Ilib -Itest test/helpers_test.rb`. Ru
2416
2424
 
2417
2425
  ## Livereload
2418
2426
 
2419
- `bin/serve` watches `content/`, `app/`, `themes/`, `config/`, `lib/`, and `data/` for changes. On change:
2427
+ `bin/serve` watches `content/`, `app/`, `themes/`, `config/`, `lib/`, `data/`, and `plugins/` for changes. On change:
2420
2428
 
2421
2429
  1. The build runs incrementally (no `_site/` wipe; mtime-aware copies skip unchanged work).
2422
2430
  2. The build version bumps and the watcher classifies the change scope.
@@ -2424,6 +2432,8 @@ Run a single test file: `bundle exec ruby -Ilib -Itest test/helpers_test.rb`. Ru
2424
2432
  - **Swaps the `<link rel="stylesheet">` href** in place if only CSS files changed (preserves scroll position and form state), or
2425
2433
  - **Reloads the page** if anything else changed.
2426
2434
 
2435
+ **Ruby isn't reloaded.** Plugins, and anything they `require` from `lib/`, load once when `bin/serve` starts. When one of those files changes, the server logs a one-line warning naming it; restart `bin/serve` to load the new code. If a build fails while that code is stale, the failure output ends with the same reminder, because the usual symptom is `NoMethodError: undefined method '…' for an instance of Abqari::RenderContext` from a layout calling a helper the server hasn't loaded.
2436
+
2427
2437
  **Watcher backend.** When the `listen` gem is installed (it's a development-group dep), the watcher uses native FS events (FSEvents on macOS, inotify on Linux) — zero overhead between changes. Without `listen`, it falls back to 500ms mtime polling. Both modes debounce events by 100ms so editor tools that fire two events per save don't double-rebuild.
2428
2438
 
2429
2439
  **Transport.** Server-Sent Events (SSE) over plain HTTP — no WebSocket dep, works through any proxy. The browser opens a single persistent EventSource per tab; the server pushes events with sub-50ms median latency. Connection auto-reconnects if the dev server restarts. The script is only injected when `ABQARI_ENV=development`, so production builds are clean.
@@ -2444,6 +2454,8 @@ end
2444
2454
 
2445
2455
  Lifecycle events: `:before_build`, `:after_load_pages`, `:after_generate`, `:after_build`. Filters: `:post_process(html, site, page)`. Full reference in [`docs/plugins.md`](https://github.com/grantrayner/abqari/blob/main/docs/plugins.md).
2446
2456
 
2457
+ `bin/serve` doesn't reload plugins, so restart it after editing one. It warns you when it sees the change (see [Livereload](#livereload)).
2458
+
2447
2459
  ## Performance
2448
2460
 
2449
2461
  Build-time and runtime perf are already tuned for typical sites (~50–200 pages). What's baked in:
@@ -2546,13 +2558,14 @@ bin/audit # verify nothing regressed
2546
2558
  ```
2547
2559
 
2548
2560
  `abqari new` scaffolds the Gemfile pinned to the current major.minor
2549
- (`gem 'abqari', '~> 1.1'` as of this release), so `bundle update abqari`
2550
- picks up patch releases and bumping the pin is the deliberate action of
2551
- taking a feature release. To track a fork or an unreleased branch
2561
+ (`gem 'abqari', '~> 1.2'` as of this release). Bundler reads that as
2562
+ `>= 1.2, < 2.0`, so `bundle update abqari` takes minor and patch
2563
+ releases but never a major one, which is the release that can need
2564
+ changes to your site. To track a fork or an unreleased branch
2552
2565
  instead, swap the line for a git source:
2553
2566
 
2554
2567
  ```ruby
2555
- gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.1.2'
2568
+ gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.2.0'
2556
2569
  ```
2557
2570
 
2558
2571
  The cadence: read [CHANGELOG.md](https://github.com/grantrayner/abqari/blob/main/CHANGELOG.md) before each upgrade,
@@ -325,12 +325,14 @@
325
325
  <% end %>
326
326
 
327
327
  <%# Speculation Rules — prerender posts the user is likely to click
328
- (any `/posts/<slug>/` link in the current document). Modern
329
- browsers (Chrome, Edge) act on this for measurably-faster
330
- navigations; other browsers safely ignore it. The generated
331
- `_headers` CSP adds `'inline-speculation-rules'` to script-src so
332
- this typed JSON block is allowed under strict CSP (see
333
- HeadersFile#apply_speculation_rules). Toggle off with
328
+ (any `/posts/<slug>/` link in the current document, or whatever
329
+ `speculation_rules.href_matches` names). Modern browsers (Chrome,
330
+ Edge) act on this for measurably-faster navigations; other
331
+ browsers safely ignore it. The body comes from
332
+ Abqari::SpeculationRules so the generated `_headers` CSP can pin
333
+ its exact SHA-256 — Chrome ignores `'inline-speculation-rules'`
334
+ once script-src carries hashes. Emit it byte-for-byte: any change
335
+ here (even whitespace) breaks the hash. Toggle off with
334
336
  `speculation_rules.enabled: false` in config.
335
337
 
336
338
  The CSP-pinned inline guard JS below honours `prefers-reduced-data`
@@ -339,18 +341,8 @@
339
341
  can act on it. This avoids burning bandwidth on metered users.
340
342
  The script gets its own SHA256 entry in HeadersFile so strict CSP
341
343
  stays clean. %>
342
- <% spec_cfg = site.config['speculation_rules'] || {} %>
343
- <% if spec_cfg.fetch('enabled', true) %>
344
- <%
345
- eagerness = %w[conservative moderate eager].include?(spec_cfg['eagerness'].to_s) ? spec_cfg['eagerness'].to_s : 'moderate'
346
- posts_slug = site.collection_config('posts')['slug'].to_s
347
- posts_path = posts_slug.empty? ? 'posts' : posts_slug
348
- %>
349
- <script type="speculationrules" id="abqari-spec-rules">
350
- { "prerender": [{ "source": "document",
351
- "where": { "href_matches": "/<%= posts_path %>/*" },
352
- "eagerness": "<%= eagerness %>" }] }
353
- </script>
344
+ <% if Abqari::SpeculationRules.enabled?(site) %>
345
+ <script type="speculationrules" id="abqari-spec-rules"><%== Abqari::SpeculationRules.script(site) %></script>
354
346
  <script><%== Abqari::SpeculationRulesGuard::SCRIPT %></script>
355
347
  <% end %>
356
348
 
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'optparse'
5
+
6
+ module Abqari
7
+ module CLI
8
+ # `abqari headers` — print the built `_site/_headers` as rules for
9
+ # hosts that don't read that file.
10
+ #
11
+ # Render static sites ignore `_headers`: the CSP, the security
12
+ # headers and the year-long `immutable` cache on `/assets/*` all
13
+ # have to be entered in the dashboard or `render.yaml`. Without the
14
+ # cache rule Render serves assets with `max-age=0`, so fonts are
15
+ # revalidated on every page and text flashes in the fallback font.
16
+ #
17
+ # It reads the BUILT file rather than computing headers itself, so
18
+ # the CSP hashes are the ones the site's own bundled engine and
19
+ # config produced. The global `abqari` on PATH may be a different
20
+ # version, and one hash (speculation rules) depends on config.
21
+ #
22
+ # bin/build && bundle exec abqari headers
23
+ # bundle exec abqari headers path/to/_site
24
+ class Headers
25
+ DEFAULT_PATH = File.join('_site', '_headers')
26
+
27
+ def self.run(argv)
28
+ new.run(argv)
29
+ end
30
+
31
+ def run(argv)
32
+ rest = build_parser.parse(argv)
33
+ path = resolve(rest.first || DEFAULT_PATH)
34
+
35
+ unless File.file?(path)
36
+ warn "No headers file at #{path}. Run `bin/build` first (it writes _site/_headers)."
37
+ return false
38
+ end
39
+
40
+ rules = parse(File.read(path, encoding: 'UTF-8'))
41
+ if rules.empty?
42
+ warn "#{path} has no header rules."
43
+ return false
44
+ end
45
+
46
+ puts render_yaml(rules)
47
+ true
48
+ end
49
+
50
+ # `_headers` blocks: an unindented path line, then indented
51
+ # `Name: value` lines. Same shape HeadersFile writes, for both the
52
+ # generated policy and a `headers:` override in site.yml.
53
+ def parse(text)
54
+ path = nil
55
+ text.each_line.with_object([]) do |line, rules|
56
+ next if line.strip.empty? || line.lstrip.start_with?('#')
57
+
58
+ if line.match?(/\A\S/)
59
+ path = line.strip
60
+ elsif path
61
+ name, value = line.strip.split(/:\s*/, 2)
62
+ rules << { 'path' => path, 'name' => name, 'value' => value.to_s } unless name.to_s.empty?
63
+ end
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def resolve(path)
70
+ File.directory?(path) ? File.join(path, '_headers') : path
71
+ end
72
+
73
+ # JSON strings are valid YAML double-quoted scalars, so the CSP's
74
+ # quotes, colons and semicolons survive a paste untouched.
75
+ def render_yaml(rules)
76
+ lines = rules.flat_map do |rule|
77
+ [" - path: #{rule['path'].to_json}",
78
+ " name: #{rule['name']}",
79
+ " value: #{rule['value'].to_json}"]
80
+ end
81
+ <<~YAML
82
+ # Render doesn't read _site/_headers. Paste this block under your static
83
+ # site in render.yaml, or add each rule (Path, Name, Value) in the
84
+ # dashboard under Settings → Headers.
85
+ #
86
+ # The Content-Security-Policy pins SHA-256 hashes of Abqari's inline
87
+ # scripts, and one depends on config. After upgrading abqari or changing
88
+ # config/site.yml, rebuild, re-run this, and re-copy any rule that changed.
89
+ headers:
90
+ #{lines.join("\n")}
91
+ YAML
92
+ end
93
+
94
+ def build_parser
95
+ OptionParser.new do |opts|
96
+ opts.banner = <<~BANNER
97
+ Usage: abqari headers [path]
98
+
99
+ Prints the built _site/_headers (or the _headers file / output
100
+ directory at [path]) as Render header rules, ready for render.yaml
101
+ or the dashboard's Settings → Headers. Run bin/build first.
102
+ BANNER
103
+ opts.on('-h', '--help', 'Show this message') { puts opts; exit }
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
data/lib/abqari/cli.rb CHANGED
@@ -5,6 +5,7 @@
5
5
  require_relative '../abqari'
6
6
  require_relative 'cli/new'
7
7
  require_relative 'cli/theme'
8
+ require_relative 'cli/headers'
8
9
 
9
10
  module Abqari
10
11
  # Tiny subcommand dispatcher for the `abqari` global executable.
@@ -29,6 +30,7 @@ module Abqari
29
30
  COMMANDS = {
30
31
  'new' => 'Abqari::CLI::New',
31
32
  'theme' => 'Abqari::CLI::Theme',
33
+ 'headers' => 'Abqari::CLI::Headers',
32
34
  'version' => :builtin_version,
33
35
  '-v' => :builtin_version,
34
36
  '--version' => :builtin_version,
@@ -43,6 +45,7 @@ module Abqari
43
45
  Commands:
44
46
  new <projectname> Scaffold a new Abqari site in ./<projectname>/
45
47
  theme eject <name> Copy an engine theme into this site for editing
48
+ headers [path] Print the built _site/_headers as Render header rules
46
49
  version Print the engine version
47
50
  help Show this message
48
51
 
@@ -4,6 +4,7 @@ require 'uri'
4
4
 
5
5
  require_relative 'theme_bootstrap'
6
6
  require_relative 'plausible_script'
7
+ require_relative 'speculation_rules'
7
8
 
8
9
  module Abqari
9
10
  class HeadersFile
@@ -26,7 +27,7 @@ module Abqari
26
27
  BASE_HEADERS = {
27
28
  'X-Content-Type-Options' => 'nosniff',
28
29
  'Referrer-Policy' => 'strict-origin-when-cross-origin',
29
- 'Permissions-Policy' => 'camera=(), microphone=(), geolocation=(), interest-cohort=()',
30
+ 'Permissions-Policy' => 'camera=(), microphone=(), geolocation=()',
30
31
  'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains'
31
32
  }.freeze
32
33
 
@@ -208,21 +209,19 @@ module Abqari
208
209
  end
209
210
 
210
211
  # The `<script type="speculationrules">` block the head emits is an
211
- # inline script as far as CSP is concerned, so `script-src` must
212
- # carry `'inline-speculation-rules'` (Chrome's keyword for exactly
213
- # this) or the block is rejected — no prerender, plus a console
214
- # error on every page. Its content is config-dependent (posts slug,
215
- # eagerness), so a static hash can't cover it. On by default; the
216
- # head gates on `speculation_rules.enabled` not being false.
212
+ # inline script as far as CSP is concerned. `'inline-speculation-rules'`
213
+ # is Chrome's keyword for exactly this, but Chrome stops honouring
214
+ # it once script-src lists a hash — and the base policy always does
215
+ # — so the block is pinned by its own SHA-256 as well. The body is
216
+ # config-dependent, so the hash is computed per build from the same
217
+ # SpeculationRules.script the head emits.
217
218
  def apply_speculation_rules(policy)
219
+ policy['script-src'] << "'#{SpeculationRules.sha256(@site)}'"
218
220
  policy['script-src'] << "'inline-speculation-rules'"
219
221
  end
220
222
 
221
223
  def speculation_rules_enabled?
222
- cfg = @site.config['speculation_rules']
223
- return true unless cfg.is_a?(Hash)
224
-
225
- cfg['enabled'] != false
224
+ SpeculationRules.enabled?(@site)
226
225
  end
227
226
 
228
227
  # Plausible: external tracker + inline init script. Both need explicit
data/lib/abqari/hooks.rb CHANGED
@@ -102,10 +102,15 @@ module Abqari
102
102
  # `Abqari::Hooks.register` to wire themselves into the lifecycle.
103
103
  #
104
104
  # Why `load` instead of `require`: `require` caches by path, so a
105
- # plugin edit isn't reloaded on subsequent Site.new calls. `load`
106
- # re-executes every time — important for the dev-server case where
107
- # editing a plugin file should re-register its hooks on the next
108
- # incremental rebuild.
105
+ # second Site.new in the same process (the test suite, an embedder)
106
+ # would silently skip a plugin it had already seen. `load` re-executes
107
+ # every time.
108
+ #
109
+ # The dev server constructs its Site once, so a plugin edited while
110
+ # `bin/serve` runs is never re-loaded — and neither is anything the
111
+ # plugin `require`d. `snapshot` and `changed_since` exist so the
112
+ # server can say so instead of failing with an unexplained
113
+ # NoMethodError on the next render.
109
114
  #
110
115
  # The hook table is NOT cleared between reloads. A plugin that
111
116
  # registers in its top-level body will accumulate duplicate
@@ -132,5 +137,55 @@ module Abqari
132
137
  raise
133
138
  end
134
139
  end
140
+
141
+ # The site's Ruby as this process has it loaded: every plugin file,
142
+ # plus every file under the site's `lib/` in `$LOADED_FEATURES` —
143
+ # in practice, what the plugins required. Maps path → mtime.
144
+ #
145
+ # Paths are realpaths because `require_relative` records realpaths
146
+ # in `$LOADED_FEATURES`; on macOS the temp dir alone (/var →
147
+ # /private/var) is enough to make a naive prefix match miss.
148
+ def snapshot(site_root)
149
+ root = realpath(site_root)
150
+ return {} unless root
151
+
152
+ (plugin_paths(root) | loaded_lib_paths(site_root, root)).each_with_object({}) do |path, mtimes|
153
+ mtime = mtime_of(path)
154
+ mtimes[path] = mtime if mtime
155
+ end
156
+ end
157
+
158
+ # Site-relative paths of Ruby that differs on disk from `snapshot`:
159
+ # edited, deleted, or a plugin file added after it was taken.
160
+ def changed_since(snapshot, site_root)
161
+ root = realpath(site_root)
162
+ return [] unless root
163
+
164
+ edited = snapshot.reject { |path, mtime| mtime_of(path) == mtime }.keys
165
+ added = plugin_paths(root) - snapshot.keys
166
+ (edited + added).map { |path| path.delete_prefix("#{root}/") }.sort
167
+ end
168
+
169
+ def plugin_paths(root)
170
+ Dir.glob(File.join(root, PLUGIN_GLOB))
171
+ end
172
+
173
+ def loaded_lib_paths(site_root, root)
174
+ prefixes = [File.expand_path(site_root), root].uniq.map { |r| File.join(r, 'lib', '') }
175
+ $LOADED_FEATURES.select { |f| prefixes.any? { |p| f.start_with?(p) } }
176
+ .filter_map { |f| realpath(f) }
177
+ end
178
+
179
+ def realpath(path)
180
+ File.realpath(path)
181
+ rescue SystemCallError
182
+ nil
183
+ end
184
+
185
+ def mtime_of(path)
186
+ File.mtime(path)
187
+ rescue SystemCallError
188
+ nil
189
+ end
135
190
  end
136
191
  end
@@ -18,21 +18,84 @@ module Abqari
18
18
  # Below this many pages, parallelism hurts more than it helps.
19
19
  PARALLEL_THRESHOLD = 16
20
20
 
21
+ # Render threads can only overlap file I/O: ERB, markdown and the
22
+ # post-processors are Ruby and hold the GVL. Past a handful they
23
+ # add contention, not throughput — a 270-page site built in ~1.07s
24
+ # on 1, 2, 4 or 8 threads, and 1.18s on 14.
25
+ MAX_RENDER_THREADS = 4
26
+
27
+ # Where a container's CPU quota is exposed. `Etc.nprocessors` reports
28
+ # the host's CPUs (or the cpuset), not the quota, so a build
29
+ # container on a large host would otherwise start dozens of threads
30
+ # to share a fraction of one CPU.
31
+ CGROUP_ROOT = '/sys/fs/cgroup'
32
+
21
33
  # Default page-size for `paginate: true`. Overridable per-site via
22
34
  # `pagination.per_page` config.
23
35
  PAGINATION_DEFAULT = 10
24
36
 
25
37
  # Slow-page detection. When a single page's `write` (render +
26
- # post-process + file write) takes longer than this, the renderer
27
- # logs a warning with the URL, elapsed time, and a short list of
38
+ # post-process + file write) uses more CPU than this, the renderer
39
+ # logs a warning with the URL, the time, and a short list of
28
40
  # heuristic causes (output size, image count, code-block count,
29
- # TOC presence). End-of-build summary surfaces the slowest few.
41
+ # TOC presence, a measured plugin filter). End-of-build summary
42
+ # surfaces the slowest few.
30
43
  #
31
44
  # Default 500ms — high enough to ignore the normal per-page
32
45
  # baseline (~5–50ms), low enough to catch real outliers. Override
33
46
  # with `ABQARI_SLOW_PAGE_MS=N` (set to 0 to disable detection).
34
47
  SLOW_PAGE_THRESHOLD_MS = (ENV['ABQARI_SLOW_PAGE_MS'] || '500').to_i
35
48
 
49
+ # Pages are timed by the CPU their own thread used, not wall-clock.
50
+ # Wall-clock includes every other render thread's turn on the GVL
51
+ # and any CPU throttling, so on a container with a small quota a
52
+ # 5ms page read as 500ms+ and dozens of pages were reported slow.
53
+ # The trade: time spent blocked on I/O no longer counts, which
54
+ # suits a warning whose every listed cause is CPU work.
55
+ PAGE_CLOCK = defined?(Process::CLOCK_THREAD_CPUTIME_ID) ? Process::CLOCK_THREAD_CPUTIME_ID : Process::CLOCK_MONOTONIC
56
+
57
+ # A plugin filter is named as a cause only when it accounts for at
58
+ # least this share of the slow page's time. It used to be named on
59
+ # every slow page whenever any `:post_process` hook was registered.
60
+ PLUGIN_FILTER_SHARE = 0.25
61
+ PLUGIN_FILTER_MS = :abqari_plugin_filter_ms
62
+
63
+ class << self
64
+ def render_threads
65
+ [available_cpus, MAX_RENDER_THREADS].min
66
+ end
67
+
68
+ def available_cpus(cgroup_root: CGROUP_ROOT)
69
+ [Etc.nprocessors, cgroup_cpu_limit(cgroup_root)].compact.min
70
+ end
71
+
72
+ # CPUs granted by the cgroup quota, rounded up, or nil when there
73
+ # is no quota (or no cgroup to read). v2 first, then v1.
74
+ def cgroup_cpu_limit(root)
75
+ v2 = read_cgroup(File.join(root, 'cpu.max'))
76
+ quota, period = v2 ? v2.split : v1_quota(root)
77
+ return nil if quota.nil? || quota == 'max' || quota.to_i <= 0 || period.to_i <= 0
78
+
79
+ [(quota.to_f / period.to_i).ceil, 1].max
80
+ end
81
+
82
+ private
83
+
84
+ def v1_quota(root)
85
+ %w[cpu cpu,cpuacct].each do |dir|
86
+ quota = read_cgroup(File.join(root, dir, 'cpu.cfs_quota_us'))
87
+ return [quota, read_cgroup(File.join(root, dir, 'cpu.cfs_period_us'))] if quota
88
+ end
89
+ nil
90
+ end
91
+
92
+ def read_cgroup(path)
93
+ File.read(path).strip
94
+ rescue SystemCallError
95
+ nil
96
+ end
97
+ end
98
+
36
99
  # Files whose mtime invalidates every cached page render.
37
100
  # `app/views/layouts/` is NOT here — layout files are handled
38
101
  # per-layout via `layout_deps_mtime(name)` so editing one layout
@@ -296,7 +359,7 @@ module Abqari
296
359
  queue = Thread::Queue.new
297
360
  pages.each { |page| queue << page }
298
361
 
299
- thread_count = [Etc.nprocessors, pages.size].min
362
+ thread_count = [self.class.render_threads, pages.size].min
300
363
  threads = Array.new(thread_count) do
301
364
  Thread.new do
302
365
  loop do
@@ -335,7 +398,7 @@ module Abqari
335
398
  # so plugin output sees the same shape browsers will. Runs BEFORE
336
399
  # livereload injection + minification so plugin transformations
337
400
  # are minified along with the rest of the page.
338
- html = Hooks.run_filter(:post_process, html, @site, page)
401
+ html = run_plugin_filters(html, page)
339
402
  html = inject_livereload(html) if @site.env == :development
340
403
  html = Minifier.minify(html) if minify_html?
341
404
  html
@@ -352,6 +415,17 @@ module Abqari
352
415
 
353
416
  private
354
417
 
418
+ # Plugin filters, timed on the page clock so the slow-page report
419
+ # can say whether they mattered. Read back by `render_one`, which
420
+ # zeroes the counter before each page.
421
+ def run_plugin_filters(html, page)
422
+ t0 = Process.clock_gettime(PAGE_CLOCK, :millisecond)
423
+ Hooks.run_filter(:post_process, html, @site, page)
424
+ ensure
425
+ spent = Process.clock_gettime(PAGE_CLOCK, :millisecond) - t0
426
+ Thread.current[PLUGIN_FILTER_MS] = (Thread.current[PLUGIN_FILTER_MS] || 0) + spent
427
+ end
428
+
355
429
  # Fail the build when two pages resolve to the same output file.
356
430
  # Without this, whichever page the (parallel) render loop writes
357
431
  # last silently wins — nondeterministically build-to-build — and
@@ -400,13 +474,14 @@ module Abqari
400
474
  # `SLOW_PAGE_THRESHOLD_MS`. Slow pages are collected for the
401
475
  # end-of-build summary.
402
476
  def render_one(page, errors, mutex, slow_pages = nil, slow_mutex = nil)
403
- t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
477
+ Thread.current[PLUGIN_FILTER_MS] = 0
478
+ t0 = Process.clock_gettime(PAGE_CLOCK, :millisecond)
404
479
  page.write
405
- elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round
480
+ elapsed_ms = Process.clock_gettime(PAGE_CLOCK, :millisecond) - t0
406
481
 
407
482
  return unless slow_page?(elapsed_ms) && slow_pages && slow_mutex
408
483
 
409
- causes = infer_slow_causes(page)
484
+ causes = infer_slow_causes(page, elapsed_ms: elapsed_ms, plugin_ms: Thread.current[PLUGIN_FILTER_MS])
410
485
  Log.warn(slow_page_note(page, elapsed_ms, causes))
411
486
  slow_mutex.synchronize { slow_pages << { page: page, ms: elapsed_ms, causes: causes } }
412
487
  rescue StandardError, PathSafe::Error => e
@@ -466,7 +541,7 @@ module Abqari
466
541
  advice: 'Set `toc: false` in the page\'s frontmatter to skip the TOC for one page, or raise `posts.show.toc.min_headings` to skip shorter pages globally.'
467
542
  },
468
543
  plugin_filter: {
469
- label: ->(_) { 'plugin filter' },
544
+ label: ->(meta) { "plugin filter #{meta[:ms]}ms" },
470
545
  impact: 'A plugin registered against the `:post_process` hook transforms the rendered HTML — the engine can\'t see inside the plugin code.',
471
546
  advice: 'Profile the offending plugin in `plugins/*.rb`, or temporarily disable it (rename to `*.rb.off`) to confirm it\'s the bottleneck.'
472
547
  }
@@ -478,7 +553,7 @@ module Abqari
478
553
  def slow_page_note(page, elapsed_ms, causes)
479
554
  url = page.respond_to?(:url) && page.url ? page.url : page.source_path.to_s
480
555
  cause_str = causes.empty? ? '' : " — #{causes.map { |c| c[:label] }.join(' · ')}"
481
- "Slow page: #{url} took #{format_ms(elapsed_ms)}#{cause_str}"
556
+ "Slow page: #{url} took #{format_ms(elapsed_ms)} of CPU#{cause_str}"
482
557
  end
483
558
 
484
559
  # Sample cheap heuristics that often correlate with slow renders.
@@ -490,7 +565,15 @@ module Abqari
490
565
  # cause-catalogue key, `label` is the populated short string for
491
566
  # the inline warning. The summary uses `type` to look up the
492
567
  # full impact + advice from `SLOW_PAGE_CAUSES`.
493
- def infer_slow_causes(page)
568
+ def infer_slow_causes(page, elapsed_ms: 0, plugin_ms: 0)
569
+ causes = content_causes(page)
570
+ if plugin_ms.to_i.positive? && plugin_ms >= elapsed_ms * PLUGIN_FILTER_SHARE
571
+ causes << build_cause(:plugin_filter, ms: plugin_ms)
572
+ end
573
+ causes
574
+ end
575
+
576
+ def content_causes(page)
494
577
  causes = []
495
578
  return causes unless page.respond_to?(:output_path)
496
579
  path = page.output_path
@@ -505,7 +588,6 @@ module Abqari
505
588
  code_count = html.scan(/<pre\b/i).size
506
589
  causes << build_cause(:many_code_blocks, count: code_count) if code_count >= 10
507
590
  causes << build_cause(:toc) if html.include?('class="toc"') || html.include?("class='toc'")
508
- causes << build_cause(:plugin_filter) if Hooks.registered_events.include?(:post_process)
509
591
  causes
510
592
  rescue StandardError
511
593
  # Cause inference is best-effort — never let a probe failure
@@ -532,7 +614,7 @@ module Abqari
532
614
  return if slow_pages.empty?
533
615
 
534
616
  slow_pages.sort_by! { |sp| -sp[:ms] }
535
- Log.info("Slow pages (>#{SLOW_PAGE_THRESHOLD_MS}ms): #{slow_pages.size}")
617
+ Log.info("Slow pages (>#{SLOW_PAGE_THRESHOLD_MS}ms CPU): #{slow_pages.size}")
536
618
  slow_pages.first(5).each do |sp|
537
619
  url = sp[:page].respond_to?(:url) && sp[:page].url ? sp[:page].url : sp[:page].source_path
538
620
  cause_str = sp[:causes].empty? ? '' : " (#{sp[:causes].map { |c| c[:label] }.join(' · ')})"
data/lib/abqari/server.rb CHANGED
@@ -22,7 +22,13 @@ module Abqari
22
22
  PORT_RANGE = (1..65_535).freeze
23
23
  POLL_INTERVAL = 0.5
24
24
  DEBOUNCE = 0.1
25
- WATCH_GLOBS = ['content/**/*', 'app/**/*', 'themes/**/*', 'config/**/*', 'lib/**/*', 'data/**/*'].freeze
25
+ # `plugins/` is here so a plugin edit is noticed at all. Plugins
26
+ # load once at Site.new, so the change can only be reported (see
27
+ # #warn_stale_ruby), not picked up.
28
+ WATCH_GLOBS = ['content/**/*', 'app/**/*', 'themes/**/*', 'config/**/*', 'lib/**/*', 'data/**/*',
29
+ 'plugins/**/*'].freeze
30
+ # More than this and the stale-Ruby warning stops being one line.
31
+ STALE_RUBY_LISTED = 3
26
32
  CSS_PATTERN = /\.css\z/.freeze
27
33
 
28
34
  # How long to hold an SSE connection open before sending a no-op
@@ -377,6 +383,7 @@ module Abqari
377
383
  @mutex.synchronize do
378
384
  count = changed_paths.size
379
385
  Log.info "Change detected (#{count} file#{'s' unless count == 1}). Rebuilding…"
386
+ warn_stale_ruby if changed_paths.any? { |p| p.end_with?('.rb') }
380
387
  @site.build(incremental: true)
381
388
  @scope = changed_paths.all? { |p| p =~ CSS_PATTERN } ? 'css' : 'all'
382
389
  @version = Time.now.to_f
@@ -392,6 +399,26 @@ module Abqari
392
399
  rescue StandardError => e
393
400
  Log.error "Build failed: #{e.class}: #{e.message}"
394
401
  e.backtrace&.first(5)&.each { |line| Log.debug " #{line}" }
402
+ warn_stale_ruby(after_failure: true)
403
+ end
404
+
405
+ # The change warning alone isn't enough: the usual sequence is
406
+ # edit the plugin, then edit the layout that calls the new helper,
407
+ # and by the time every page fails with `undefined method ... for
408
+ # an instance of Abqari::RenderContext` the warning has scrolled
409
+ # away. So a failed build repeats it, as an error, last.
410
+ def warn_stale_ruby(after_failure: false)
411
+ stale = @site.stale_ruby_sources
412
+ return if stale.empty?
413
+
414
+ files = stale.first(STALE_RUBY_LISTED).join(', ')
415
+ files += " and #{stale.size - STALE_RUBY_LISTED} more" if stale.size > STALE_RUBY_LISTED
416
+ if after_failure
417
+ Log.error "Restart bin/serve: #{files} changed after it started, and it is still running " \
418
+ 'the old Ruby, so helpers added since are undefined.'
419
+ else
420
+ Log.warn "#{files} changed, but bin/serve loads Ruby once at startup — restart it to pick that up."
421
+ end
395
422
  end
396
423
 
397
424
  # ----------------------------------------------------------------
data/lib/abqari/site.rb CHANGED
@@ -102,6 +102,15 @@ module Abqari
102
102
  # the registrations land in the global hook table and fire on
103
103
  # subsequent `Hooks.run` / `run_filter` calls during build.
104
104
  PluginLoader.load_from(site_root)
105
+ # Taken after loading so it covers what the plugins required.
106
+ @ruby_snapshot = PluginLoader.snapshot(site_root)
107
+ end
108
+
109
+ # Site Ruby (plugins/, and lib/ files they required) that changed on
110
+ # disk after this Site loaded it. Ruby loads once per process, so a
111
+ # long-lived Site — the dev server's — keeps running the old code.
112
+ def stale_ruby_sources
113
+ PluginLoader.changed_since(@ruby_snapshot, site_root)
105
114
  end
106
115
 
107
116
  # ------------------------------------------------------------------
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'base64'
5
+
6
+ module Abqari
7
+ # The `<script type="speculationrules">` block in the head, built here
8
+ # rather than in `_head.html.erb` so HeadersFile hashes the exact bytes
9
+ # the page carries and the two can't drift.
10
+ #
11
+ # The hash is what lets the block run. Chrome stops honouring the
12
+ # `'inline-speculation-rules'` keyword once script-src lists any hash,
13
+ # and ours always does (ThemeBootstrap, SpeculationRulesGuard), so on
14
+ # its own the keyword left every page logging a CSP violation with
15
+ # nothing prerendered. The keyword stays in the policy for browsers
16
+ # that do honour it.
17
+ #
18
+ # Unlike the other inline scripts, the body depends on config (the
19
+ # URL patterns and eagerness), so its hash is per-site, not a constant.
20
+ module SpeculationRules
21
+ EAGERNESS = %w[conservative moderate eager].freeze
22
+ DEFAULT_EAGERNESS = 'moderate'
23
+
24
+ module_function
25
+
26
+ def enabled?(site)
27
+ cfg = site.config['speculation_rules']
28
+ !cfg.is_a?(Hash) || cfg['enabled'] != false
29
+ end
30
+
31
+ # The surrounding whitespace is what the inline template produced
32
+ # before the body moved here. Keeping it byte-identical means a hash
33
+ # already copied into a host that ignores `_headers` (Render) stays
34
+ # valid across the upgrade for any site that doesn't change config.
35
+ def script(site)
36
+ "\n { \"prerender\": [{ \"source\": \"document\",\n \"where\": #{where(href_matches(site))},\n " \
37
+ "\"eagerness\": #{json(eagerness(site))} }] }\n "
38
+ end
39
+
40
+ def sha256(site)
41
+ "sha256-#{Base64.strict_encode64(Digest::SHA256.digest(script(site)))}"
42
+ end
43
+
44
+ # `speculation_rules.href_matches:` — one URL pattern or a list.
45
+ # Defaults to the posts collection, which is all the engine knows is
46
+ # article-shaped; a site whose main content is a custom collection
47
+ # (`/news/*`) names it here.
48
+ def href_matches(site)
49
+ patterns = Array(settings(site)['href_matches']).map { |p| p.to_s.strip }.reject(&:empty?)
50
+ patterns.empty? ? ["/#{posts_path(site)}/*"] : patterns
51
+ end
52
+
53
+ def eagerness(site)
54
+ value = settings(site)['eagerness'].to_s
55
+ EAGERNESS.include?(value) ? value : DEFAULT_EAGERNESS
56
+ end
57
+
58
+ def where(patterns)
59
+ clauses = patterns.map { |p| "{ \"href_matches\": #{json(p)} }" }
60
+ clauses.one? ? clauses.first : "{ \"or\": [#{clauses.join(', ')}] }"
61
+ end
62
+
63
+ # Same slug the posts collection resolves to (Site#collection_config
64
+ # takes it straight from config; the defaults carry none).
65
+ def posts_path(site)
66
+ collections = site.config['collections']
67
+ posts = collections.is_a?(Hash) ? collections['posts'] : nil
68
+ slug = posts.is_a?(Hash) ? posts['slug'].to_s : ''
69
+ slug.empty? ? 'posts' : slug
70
+ end
71
+
72
+ def settings(site)
73
+ cfg = site.config['speculation_rules']
74
+ cfg.is_a?(Hash) ? cfg : {}
75
+ end
76
+
77
+ # Patterns come from site config, but this lands raw inside a
78
+ # <script>, so a stray `</script>` must not be able to close it.
79
+ def json(value)
80
+ Helpers.script_safe_json(value)
81
+ end
82
+ end
83
+ end
@@ -19,6 +19,7 @@
19
19
  # What this does:
20
20
  # - Installs Ruby + libvips, restores bundle cache
21
21
  # - Installs Pagefind (used only if `search: true` in site.yml)
22
+ # - Fetches the self-hosted fonts (vendor/fonts/ is git-ignored)
22
23
  # - Builds the site in production mode
23
24
  # - Runs the audit, uploads report as artifact (audit failures don't block deploy)
24
25
  # - Deploys _site/ to Cloudflare Pages via Wrangler
@@ -67,6 +68,14 @@ jobs:
67
68
  # Skips silently if pagefind is unused; bin/build only runs it when
68
69
  # `search: true` is set in config/site.yml.
69
70
 
71
+ - name: Fetch fonts
72
+ # vendor/fonts/ is git-ignored, so a fresh checkout has no font
73
+ # files and the build would ship the site in fallback fonts.
74
+ # No-op when config/site.yml has no `fonts:`.
75
+ env:
76
+ ABQARI_ENV: production
77
+ run: bundle exec bin/fetch-fonts
78
+
70
79
  - name: Build site
71
80
  env:
72
81
  ABQARI_ENV: production
@@ -69,6 +69,14 @@ jobs:
69
69
  curl -LSf https://pagefind.app/install.sh | bash
70
70
  echo "$HOME/.local/bin" >> $GITHUB_PATH
71
71
 
72
+ - name: Fetch fonts
73
+ # vendor/fonts/ is git-ignored, so a fresh checkout has no font
74
+ # files and the build would ship the site in fallback fonts.
75
+ # No-op when config/site.yml has no `fonts:`.
76
+ env:
77
+ ABQARI_ENV: production
78
+ run: bundle exec bin/fetch-fonts
79
+
72
80
  - name: Build site
73
81
  env:
74
82
  ABQARI_ENV: production
@@ -5,9 +5,11 @@
5
5
  #
6
6
  # (A) Render's own GitHub integration (simplest — no workflow needed):
7
7
  # In the Render dashboard, create a Static Site, connect this repo,
8
- # set build command `bundle exec bin/build`, publish directory
9
- # `_site`, environment variable `ABQARI_ENV=production`. Render
10
- # polls GitHub and rebuilds on push.
8
+ # set build command
9
+ # `bundle install && bundle exec bin/fetch-fonts && bundle exec bin/build`,
10
+ # publish directory `_site`, environment variable
11
+ # `ABQARI_ENV=production`. Render polls GitHub and rebuilds on push.
12
+ # (`bin/fetch-fonts` because vendor/fonts/ is git-ignored.)
11
13
  #
12
14
  # (B) GitHub Actions → Render deploy hook (this workflow). Use when:
13
15
  # - You want CI gates (audit, tests) to block deploys.
@@ -23,8 +25,9 @@
23
25
  # - Variables (tab): add `ENABLE_RENDER = true`
24
26
  #
25
27
  # When triggered, Render fetches the latest commit and builds in its
26
- # own environment. This workflow only pings the deploy hook — it
27
- # doesn't push artifacts. If you need Actions-built artifacts shipped
28
+ # own environment with the build command from option (A) — so that
29
+ # command needs `bin/fetch-fonts` too. This workflow only pings the
30
+ # deploy hook — it doesn't push artifacts. If you need Actions-built artifacts shipped
28
31
  # to Render, use Render's REST API directly (more setup; not covered).
29
32
  #
30
33
  # Three deploy options ship in this repo. Each is independently gated
@@ -63,6 +66,14 @@ jobs:
63
66
  with:
64
67
  bundler-cache: true
65
68
 
69
+ - name: Fetch fonts
70
+ # vendor/fonts/ is git-ignored, so a fresh checkout has no font
71
+ # files and the build would ship the site in fallback fonts.
72
+ # No-op when config/site.yml has no `fonts:`.
73
+ env:
74
+ ABQARI_ENV: production
75
+ run: bundle exec bin/fetch-fonts
76
+
66
77
  - name: Build site (CI verification)
67
78
  # Build locally as a smoke test so Render's build won't run
68
79
  # against code that fails at our own gates. Doesn't ship the
@@ -14,5 +14,5 @@
14
14
  #
15
15
  # Keep this file dependency-free.
16
16
  module Abqari
17
- VERSION = '1.1.2'
17
+ VERSION = '1.2.0'
18
18
  end
data/lib/abqari.rb CHANGED
@@ -141,6 +141,7 @@ require_relative 'abqari/minifier'
141
141
  require_relative 'abqari/search'
142
142
  require_relative 'abqari/syntax_highlighter'
143
143
  require_relative 'abqari/theme_bootstrap'
144
+ require_relative 'abqari/speculation_rules'
144
145
  require_relative 'abqari/speculation_rules_guard'
145
146
  require_relative 'abqari/plausible_script'
146
147
  require_relative 'abqari/visualizations'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: abqari
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.2
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Grant Rayner
@@ -228,6 +228,7 @@ files:
228
228
  - lib/abqari/audit/seo.rb
229
229
  - lib/abqari/bundle_assets.rb
230
230
  - lib/abqari/cli.rb
231
+ - lib/abqari/cli/headers.rb
231
232
  - lib/abqari/cli/new.rb
232
233
  - lib/abqari/cli/theme.rb
233
234
  - lib/abqari/erbi_template.rb
@@ -281,6 +282,7 @@ files:
281
282
  - lib/abqari/site_config.rb
282
283
  - lib/abqari/site_loader.rb
283
284
  - lib/abqari/sitemap.rb
285
+ - lib/abqari/speculation_rules.rb
284
286
  - lib/abqari/speculation_rules_guard.rb
285
287
  - lib/abqari/syndication.rb
286
288
  - lib/abqari/syntax_highlighter.rb