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.
@@ -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
 
@@ -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=(), interest-cohort=()',
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, 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.
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
- cfg = @site.config['speculation_rules']
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
- # 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