abqari 1.1.2 → 1.3.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 +157 -1
- data/README.md +26 -12
- data/app/views/partials/_head.html.erb +10 -18
- data/lib/abqari/audit/csp.rb +178 -0
- data/lib/abqari/audit.rb +4 -2
- data/lib/abqari/cli/headers.rb +95 -0
- data/lib/abqari/cli.rb +3 -0
- data/lib/abqari/headers_file.rb +28 -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 +4 -1
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
def parse(text)
|
|
51
|
+
HeadersFile.parse(text)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def resolve(path)
|
|
57
|
+
File.directory?(path) ? File.join(path, '_headers') : path
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# JSON strings are valid YAML double-quoted scalars, so the CSP's
|
|
61
|
+
# quotes, colons and semicolons survive a paste untouched.
|
|
62
|
+
def render_yaml(rules)
|
|
63
|
+
lines = rules.flat_map do |rule|
|
|
64
|
+
[" - path: #{rule['path'].to_json}",
|
|
65
|
+
" name: #{rule['name']}",
|
|
66
|
+
" value: #{rule['value'].to_json}"]
|
|
67
|
+
end
|
|
68
|
+
<<~YAML
|
|
69
|
+
# Render doesn't read _site/_headers. Paste this block under your static
|
|
70
|
+
# site in render.yaml, or add each rule (Path, Name, Value) in the
|
|
71
|
+
# dashboard under Settings → Headers.
|
|
72
|
+
#
|
|
73
|
+
# The Content-Security-Policy pins SHA-256 hashes of Abqari's inline
|
|
74
|
+
# scripts, and one depends on config. After upgrading abqari or changing
|
|
75
|
+
# config/site.yml, rebuild, re-run this, and re-copy any rule that changed.
|
|
76
|
+
headers:
|
|
77
|
+
#{lines.join("\n")}
|
|
78
|
+
YAML
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def build_parser
|
|
82
|
+
OptionParser.new do |opts|
|
|
83
|
+
opts.banner = <<~BANNER
|
|
84
|
+
Usage: abqari headers [path]
|
|
85
|
+
|
|
86
|
+
Prints the built _site/_headers (or the _headers file / output
|
|
87
|
+
directory at [path]) as Render header rules, ready for render.yaml
|
|
88
|
+
or the dashboard's Settings → Headers. Run bin/build first.
|
|
89
|
+
BANNER
|
|
90
|
+
opts.on('-h', '--help', 'Show this message') { puts opts; exit }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
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,10 +27,28 @@ 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
|
|
|
34
|
+
# A `_headers` file as `{ 'path', 'name', 'value' }` rules: an
|
|
35
|
+
# unindented path line, then indented `Name: value` lines. The shape
|
|
36
|
+
# `build` writes, for both the generated policy and a `headers:`
|
|
37
|
+
# override. Read back by `abqari headers` and the CSP audit.
|
|
38
|
+
def self.parse(text)
|
|
39
|
+
path = nil
|
|
40
|
+
text.each_line.with_object([]) do |line, rules|
|
|
41
|
+
next if line.strip.empty? || line.lstrip.start_with?('#')
|
|
42
|
+
|
|
43
|
+
if line.match?(/\A\S/)
|
|
44
|
+
path = line.strip
|
|
45
|
+
elsif path
|
|
46
|
+
name, value = line.strip.split(/:\s*/, 2)
|
|
47
|
+
rules << { 'path' => path, 'name' => name, 'value' => value.to_s } unless name.to_s.empty?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
33
52
|
def initialize(site)
|
|
34
53
|
@site = site
|
|
35
54
|
end
|
|
@@ -208,21 +227,19 @@ module Abqari
|
|
|
208
227
|
end
|
|
209
228
|
|
|
210
229
|
# The `<script type="speculationrules">` block the head emits is an
|
|
211
|
-
# inline script as far as CSP is concerned
|
|
212
|
-
#
|
|
213
|
-
#
|
|
214
|
-
#
|
|
215
|
-
#
|
|
216
|
-
#
|
|
230
|
+
# inline script as far as CSP is concerned. `'inline-speculation-rules'`
|
|
231
|
+
# is Chrome's keyword for exactly this, but Chrome stops honouring
|
|
232
|
+
# it once script-src lists a hash — and the base policy always does
|
|
233
|
+
# — so the block is pinned by its own SHA-256 as well. The body is
|
|
234
|
+
# config-dependent, so the hash is computed per build from the same
|
|
235
|
+
# SpeculationRules.script the head emits.
|
|
217
236
|
def apply_speculation_rules(policy)
|
|
237
|
+
policy['script-src'] << "'#{SpeculationRules.sha256(@site)}'"
|
|
218
238
|
policy['script-src'] << "'inline-speculation-rules'"
|
|
219
239
|
end
|
|
220
240
|
|
|
221
241
|
def speculation_rules_enabled?
|
|
222
|
-
|
|
223
|
-
return true unless cfg.is_a?(Hash)
|
|
224
|
-
|
|
225
|
-
cfg['enabled'] != false
|
|
242
|
+
SpeculationRules.enabled?(@site)
|
|
226
243
|
end
|
|
227
244
|
|
|
228
245
|
# 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
|