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 +4 -4
- data/CHANGELOG.md +125 -1
- data/README.md +24 -11
- data/app/views/partials/_head.html.erb +10 -18
- data/lib/abqari/cli/headers.rb +108 -0
- data/lib/abqari/cli.rb +3 -0
- data/lib/abqari/headers_file.rb +10 -11
- data/lib/abqari/hooks.rb +59 -4
- data/lib/abqari/renderer.rb +95 -13
- data/lib/abqari/server.rb +28 -1
- data/lib/abqari/site.rb +9 -0
- data/lib/abqari/speculation_rules.rb +83 -0
- data/lib/abqari/templates/site_skeleton/.github/workflows/deploy-cloudflare.yml +9 -0
- data/lib/abqari/templates/site_skeleton/.github/workflows/deploy-github-pages.yml +8 -0
- data/lib/abqari/templates/site_skeleton/.github/workflows/deploy-render.yml +16 -5
- data/lib/abqari/version.rb +1 -1
- data/lib/abqari.rb +1 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b9d5e9a272de668ea9e75b33d47fd252c9a46413c6873c15d077a938f3817004
|
|
4
|
+
data.tar.gz: 0d55679d1de17c940765b5a04c81e15980e1929a590fab878b36e65fdc877195
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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 `
|
|
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.
|
|
2550
|
-
|
|
2551
|
-
|
|
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.
|
|
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
|
|
329
|
-
browsers (Chrome,
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
<%
|
|
343
|
-
|
|
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
|
|
data/lib/abqari/headers_file.rb
CHANGED
|
@@ -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=()
|
|
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
|
|
212
|
-
#
|
|
213
|
-
#
|
|
214
|
-
#
|
|
215
|
-
#
|
|
216
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
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
|
data/lib/abqari/renderer.rb
CHANGED
|
@@ -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)
|
|
27
|
-
# logs a warning with the URL,
|
|
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
|
|
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 = [
|
|
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 =
|
|
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
|
-
|
|
477
|
+
Thread.current[PLUGIN_FILTER_MS] = 0
|
|
478
|
+
t0 = Process.clock_gettime(PAGE_CLOCK, :millisecond)
|
|
404
479
|
page.write
|
|
405
|
-
elapsed_ms =
|
|
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: ->(
|
|
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
|
-
|
|
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
|
|
9
|
-
# `
|
|
10
|
-
#
|
|
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
|
|
27
|
-
#
|
|
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
|
data/lib/abqari/version.rb
CHANGED
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.
|
|
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
|