abqari 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a20fb790c6db4c0a3d9a357373667acbfb346811e2b5909c97a3b085b3e99580
4
- data.tar.gz: 05321cf63a57429e11ac958512ec6628f99e2a2345ce9b4d708faee1feded544
3
+ metadata.gz: fd411b1e1b18a824d5cee74ee6e557c7135fc973f6fc9edb4f4c806edff7cadf
4
+ data.tar.gz: bb34137c4de0ffb733d1b911f6e523df2cde7b933d196e07d0e4ca4a4fb91460
5
5
  SHA512:
6
- metadata.gz: b578071dfb5c70920b40503e1d60a7f0a340242687273cb3dd769be19e79faa2328763a9d88e2a520d3b414d1b9be7f1cd7d99bc7e812c1293374aea25a00dcb
7
- data.tar.gz: 5085055521aa63a6be54eecb66213bfb05a64bb470886113bc951a5bd3fd441ff516013e6c770997dae14ace656d024946cb8cad2ca605f0f69ff53a59542805
6
+ metadata.gz: b1c8ea8aa8cad1f13f9dc3eddd81fbbedf891d024b6998f8a1f7aacf7c45c66ce783bea05174d983973c46eac5318372addd0d8b590e29871723f29f1d71a40c
7
+ data.tar.gz: 2820827176cec3ecaaf08ad2ceb70c15b506435368154e24d2afc7ea544f2fe959613da696e2bb05eb81eff3011d4c37e07081565e1c4fa2ae2e901073b4e5c8
data/CHANGELOG.md CHANGED
@@ -9,6 +9,109 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  _Nothing yet._
11
11
 
12
+ ## [1.1.2] — 2026-10-09
13
+
14
+ ### Fixed
15
+
16
+ - **Changing `fonts:` and re-running `bin/fetch-fonts` didn't reach
17
+ returning visitors; they kept the old fonts for up to a year.** The
18
+ head linked the font stylesheet at a fixed `/assets/fonts/fonts.css`,
19
+ and the generated `_headers` serves all of `/assets/*` with
20
+ `Cache-Control: public, max-age=31536000, immutable`, so a browser
21
+ that had cached the old file never asked for it again. The new
22
+ families fell back to system fonts. It hid because a first visit, a
23
+ hard reload, and the dev server (which doesn't apply `_headers`) all
24
+ show the new fonts; only returning visitors on a host that honours
25
+ `_headers` were stuck. `syntax.css` had the same bug before the CSS
26
+ bundle.
27
+
28
+ `vendor/fonts/fonts.css` now goes through the asset pipeline and is
29
+ linked as `/assets/fonts/fonts.<hash>.css`, minified in production,
30
+ so a re-fetch changes the URL. Dev-server rebuilds now re-render
31
+ every page after a re-fetch, because every head carries the hash.
32
+ The head no longer emits the link when `fonts:` is set but
33
+ `bin/fetch-fonts` hasn't run; it used to link a stylesheet that
34
+ 404'd.
35
+
36
+ If your site overrides `_head.html.erb`, change its font link to
37
+ `<%= asset_path('fonts/fonts.css') %>`. The unhashed copy still ships
38
+ at `/assets/fonts/fonts.css`, so an overridden head keeps working,
39
+ but it keeps the stale-cache problem.
40
+
41
+ ## [1.1.1] — 2026-08-11
42
+
43
+ Bug-fix release, all of it found while building a site against the
44
+ published 1.1.0 gem.
45
+
46
+ Upgrade if you minify (the default in production): a tag name written
47
+ inside an HTML comment could silently delete everything between that
48
+ comment and the next one, and layouts carry explanatory comments as a
49
+ matter of course. Upgrade too if you use the contact button — it never
50
+ worked at all.
51
+
52
+ ### Fixed
53
+
54
+ - **A tag name written inside an HTML comment silently deleted page
55
+ content — production builds only.** `Minifier.minify` stashed
56
+ `<pre>` / `<textarea>` / `<script>` / `<style>` blocks *before* it
57
+ stripped comments, so ordinary explanatory prose in a layout or
58
+ partial — `<!-- see the <script> tag below -->` — opened a
59
+ "preserved" region at the comment's own text. That region ran to the
60
+ next genuine `</script>` further down the document, swallowing the
61
+ comment's `-->` on the way; the orphaned `<!--` then paired with some
62
+ later `-->`, and the comment pass deleted the placeholder along with
63
+ everything between the two. Pages shipped with an empty `<main>`
64
+ against a green build, and only in production, because dev doesn't
65
+ minify.
66
+
67
+ Comments and preserved blocks are now matched in one ordered
68
+ left-to-right pass, so whichever construct starts first consumes the
69
+ other and tag-like text inside a comment can't open a region. This is
70
+ the same single-pass discipline (and the same failure class) as the
71
+ CSS minifier's comment/string handling. Conditional comments
72
+ (`<!--[if …]>`) are stashed rather than stripped, so they now survive
73
+ verbatim instead of having their contents whitespace-collapsed.
74
+
75
+ - **A document beginning or ending with a `<pre>` / `<script>` /
76
+ `<style>` block leaked the minifier's internal placeholder.** The
77
+ final trim used `String#strip`, which removes NUL bytes as well as
78
+ whitespace, truncating the stash sentinel so the placeholder never
79
+ matched on restore and shipped as literal `ABQARI_PRESERVED_0` text.
80
+ Real pages end in `</html>`, which is why this stayed hidden. Both
81
+ the HTML and CSS minifiers now trim whitespace only.
82
+
83
+ - **The contact button did nothing when clicked.** `contact-button.js`
84
+ delegates on `.contact-button[data-user][data-domain]`, but the
85
+ partial only ever emitted the styling classes (`btn
86
+ btn-outline-secondary`, or whatever `css_class:` was set to) — the
87
+ wrapper's `.contact-button-wrapper` is a different class token, so
88
+ the selector matched nothing on every site that enabled the button.
89
+ No console error; the button just sat there. `.contact-button` is now
90
+ emitted as a styling-free JS hook alongside `css_class:`, so
91
+ overriding the variant can't break the handler.
92
+
93
+ - **`contact.subject:` rendered a malformed `data-subject` attribute.**
94
+ `_contact_button.html.erb` built the whole attribute as a Ruby string
95
+ and emitted it through `<%= %>`, which auto-escapes under Erubi — so
96
+ the attribute's own quote characters came out as entities
97
+ (`data-subject=&quot;Project enquiry&quot;`) and the subject would
98
+ not have reached `contact-button.js` even once the click handler
99
+ fired. The attribute name and quotes are now literal template text;
100
+ only the value passes through the escape.
101
+
102
+ ### Changed
103
+
104
+ - `Minifier::PRESERVE_REGEX` and `Minifier::COMMENT_REGEX` are gone,
105
+ replaced by the single `Minifier::HTML_TOKEN_REGEX`. Both were
106
+ internal to `minify` and nothing outside the engine has reason to
107
+ reference them; they're called out because 1.1.0 is on RubyGems and
108
+ removing a public constant is technically breaking. `PRESERVE_TAGS`
109
+ is unchanged.
110
+
111
+ - `test/low_priority_hardening_test.rb` no longer relies on
112
+ `Object#stub`. Minitest 6 dropped `minitest/mock` from the default
113
+ install, so that test errored on every run of the suite.
114
+
12
115
  ## [1.1.0] — 2026-08-06
13
116
 
14
117
  First public release on RubyGems. 1.0.0 below was tagged in git but
@@ -884,6 +987,8 @@ run a real working author / publisher site.
884
987
  commonmarker ≥ 2.4 ships precompiled 4.0 binaries, which is what
885
988
  the `~> 2.4` floor bought.
886
989
 
887
- [Unreleased]: https://github.com/grantrayner/abqari/compare/v1.1.0...HEAD
990
+ [Unreleased]: https://github.com/grantrayner/abqari/compare/v1.1.2...HEAD
991
+ [1.1.2]: https://github.com/grantrayner/abqari/compare/v1.1.1...v1.1.2
992
+ [1.1.1]: https://github.com/grantrayner/abqari/compare/v1.1.0...v1.1.1
888
993
  [1.1.0]: https://github.com/grantrayner/abqari/compare/v1.0.0...v1.1.0
889
994
  [1.0.0]: https://github.com/grantrayner/abqari/releases/tag/v1.0.0
data/README.md CHANGED
@@ -11,7 +11,7 @@ optional overrides), while the engine — layouts, themes, helpers, asset
11
11
  pipeline — lives in the dependency. Forking the engine is also
12
12
  supported for users who want to own all the code.
13
13
 
14
- > **Status:** v1.1.0 — first public release, see
14
+ > **Status:** v1.1.2 — see
15
15
  > [CHANGELOG.md](https://github.com/grantrayner/abqari/blob/main/CHANGELOG.md).
16
16
  > Distributed via RubyGems (`gem install abqari`) and as a Bundler
17
17
  > path/git source. The API follows [semver](https://semver.org/) from
@@ -174,7 +174,7 @@ Test that it worked:
174
174
  abqari version
175
175
  ```
176
176
 
177
- You should see a version number, e.g. `abqari 1.1.0`. If you get
177
+ You should see a version number, e.g. `abqari 1.1.2`. If you get
178
178
  `command not found`, see [Troubleshooting](#troubleshooting) below.
179
179
 
180
180
  ### Step 3 — Create your first site
@@ -470,7 +470,7 @@ end
470
470
  Or — pin to a git ref while tracking pre-release work:
471
471
 
472
472
  ```ruby
473
- gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.1.0'
473
+ gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.1.2'
474
474
  ```
475
475
 
476
476
  Install the libvips C library once (hard dep for the image pipeline):
@@ -1629,7 +1629,9 @@ Then run once:
1629
1629
  bin/fetch-fonts
1630
1630
  ```
1631
1631
 
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 head partial conditionally includes the link tag whenever `fonts:` is configured.
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
+
1634
+ 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.
1633
1635
 
1634
1636
  ## Icons
1635
1637
 
@@ -2550,7 +2552,7 @@ taking a feature release. To track a fork or an unreleased branch
2550
2552
  instead, swap the line for a git source:
2551
2553
 
2552
2554
  ```ruby
2553
- gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.1.0'
2555
+ gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.1.2'
2554
2556
  ```
2555
2557
 
2556
2558
  The cadence: read [CHANGELOG.md](https://github.com/grantrayner/abqari/blob/main/CHANGELOG.md) before each upgrade,
@@ -15,6 +15,10 @@
15
15
  Place anywhere by calling `<%%= render 'partials/contact_button' %%>`
16
16
  from any layout. Already wired into the default about layout.
17
17
 
18
+ `.contact-button` is the JS hook contact-button.js delegates on. It's
19
+ always emitted and carries no styling, so `css_class:` can be anything
20
+ without breaking the click handler — keep the two roles separate.
21
+
18
22
  Default styling: `.btn .btn-outline-secondary` from _common.css.
19
23
  Override with `css_class:` to swap the variant — e.g.
20
24
  <%%= render 'partials/contact_button', css_class: 'btn btn-success' %%>
@@ -31,10 +35,10 @@
31
35
  <% subject = cfg['subject'] %>
32
36
  <% button_class = defined?(css_class) ? css_class.to_s : 'btn btn-outline-secondary' %>
33
37
  <p class="contact-button-wrapper">
34
- <button type="button" class="<%= button_class %>"
38
+ <button type="button" class="contact-button <%= button_class %>"
35
39
  data-user="<%= user %>"
36
40
  data-domain="<%= domain %>"
37
- <%= subject ? %(data-subject="#{h(subject)}") : '' %>>
41
+ <% if subject %>data-subject="<%= subject %>"<% end %>>
38
42
  <%= label %>
39
43
  </button>
40
44
  </p>
@@ -367,8 +367,12 @@
367
367
  <% Array(site.config['fonts_preload']).each do |path| %>
368
368
  <link rel="preload" href="<%= sanitized_url(path) %>" as="font" type="font/woff2" crossorigin>
369
369
  <% end %>
370
- <% if Array(site.config['fonts']).any? %>
371
- <link rel="stylesheet" href="/assets/fonts/fonts.css">
370
+ <%# Fingerprinted, never the fixed `/assets/fonts/fonts.css` — that
371
+ URL is cached `immutable`, so a re-fetch never reached returning
372
+ visitors. No link until `bin/fetch-fonts` has run. %>
373
+ <% fonts_css = Array(site.config['fonts']).any? && optional_asset_path('fonts/fonts.css') %>
374
+ <% if fonts_css %>
375
+ <link rel="stylesheet" href="<%= fonts_css %>">
372
376
  <% end %>
373
377
  <%# Render-blocking CSS ships as ONE fingerprinted bundle (syntax +
374
378
  _common + theme app.css + site.css, same cascade order as the
@@ -8,6 +8,7 @@ module Abqari
8
8
  OUTPUT_SUBDIR = 'assets'
9
9
  HASH_LENGTH = 10
10
10
  BUNDLE_LOGICAL = 'css/bundle.css'
11
+ FONTS_LOGICAL = 'fonts/fonts.css'
11
12
  # A theme name is a directory name under themes/ and nothing more.
12
13
  # The strict pattern is load-bearing: the name is joined into
13
14
  # filesystem paths in several places (here, ArtifactWriter,
@@ -55,6 +56,7 @@ module Abqari
55
56
  [engine_app_assets, site_app_assets].uniq.each do |dir|
56
57
  process_dir(dir) if dir && Dir.exist?(dir)
57
58
  end
59
+ process_font_stylesheet
58
60
  build_css_bundle
59
61
  end
60
62
 
@@ -172,6 +174,22 @@ module Abqari
172
174
  @paths[logical] = "/#{OUTPUT_SUBDIR}/#{fingerprinted}"
173
175
  end
174
176
 
177
+ # The `bin/fetch-fonts` stylesheet gets a content hash like any
178
+ # other asset. At its old fixed URL it sat under the `/assets/*
179
+ # immutable` cache rule, so a site that changed `fonts:` and
180
+ # re-fetched kept serving returning visitors the old `@font-face`
181
+ # rules for a year. Its woff2 `url()`s are root-absolute, so the
182
+ # renamed file still resolves them. ArtifactWriter#copy_fonts keeps
183
+ # shipping the unhashed copy for sites that override the head.
184
+ def process_font_stylesheet
185
+ source = File.join(@site.site_root, 'vendor', 'fonts', 'fonts.css')
186
+ return unless File.file?(source)
187
+ return if @site.external_symlink?(source)
188
+
189
+ write_asset(FONTS_LOGICAL, processed_content(source, FONTS_LOGICAL))
190
+ @sources[FONTS_LOGICAL] = source
191
+ end
192
+
175
193
  def processed_content(entry, logical)
176
194
  raw = File.binread(entry)
177
195
  kind = MINIFY_KINDS[File.extname(logical)]
@@ -6,20 +6,46 @@ module Abqari
6
6
  # <pre>, <textarea>, <script>, and <style> blocks where whitespace matters.
7
7
  module Minifier
8
8
  PRESERVE_TAGS = %w[pre textarea script style].freeze
9
- PRESERVE_REGEX = /<(#{PRESERVE_TAGS.join('|')})\b[^>]*>.*?<\/\1>/im.freeze
10
9
  PLACEHOLDER = "\x00ABQARI_PRESERVED_%d\x00"
11
- COMMENT_REGEX = /<!--(?!\[if).*?-->/m.freeze
10
+
11
+ # Comments and preserved blocks are recognised in ONE left-to-right
12
+ # pass, via ordered alternation, so whichever construct STARTS first
13
+ # consumes the other — the same single-pass discipline (and for the
14
+ # same reason) as CSS_TOKEN_REGEX below.
15
+ #
16
+ # Stashing preserved blocks first and stripping comments afterwards
17
+ # is what this replaced, and it was wrong: a tag-like string written
18
+ # inside a comment — `<!-- see the <script> tag -->` in a layout —
19
+ # opened a preserved region at the `<script>` text, which then ran
20
+ # to the next GENUINE `</script>` further down the document,
21
+ # swallowing the comment's own `-->` on the way. The orphaned `<!--`
22
+ # then paired with some later `-->`, and the comment pass deleted
23
+ # the placeholder and everything between. Pages came out with an
24
+ # empty `<main>`, in production builds only (dev doesn't minify).
25
+ #
26
+ # Comments matched here are dropped, except conditional comments
27
+ # (`<!--[if …]>`), which are stashed so they survive verbatim.
28
+ HTML_TOKEN_REGEX = %r{
29
+ (?<comment><!--.*?-->)
30
+ |
31
+ (?<preserved><(?<tag>#{PRESERVE_TAGS.join('|')})\b[^>]*>.*?</\k<tag>>)
32
+ }imx.freeze
33
+ CONDITIONAL_COMMENT_REGEX = /\A<!--\[if/i.freeze
12
34
 
13
35
  def self.minify(html)
14
36
  preserved = []
15
37
 
16
- protected_html = html.gsub(PRESERVE_REGEX) do |match|
17
- preserved << match
18
- format(PLACEHOLDER, preserved.length - 1)
38
+ protected_html = html.gsub(HTML_TOKEN_REGEX) do
39
+ match = ::Regexp.last_match
40
+ if match[:comment] && !match[:comment].match?(CONDITIONAL_COMMENT_REGEX)
41
+ ''
42
+ else
43
+ preserved << match[0]
44
+ format(PLACEHOLDER, preserved.length - 1)
45
+ end
19
46
  end
20
47
 
21
48
  minified = protected_html
22
- .gsub(COMMENT_REGEX, '')
23
49
  # Collapse inter-tag whitespace to a SINGLE space, not
24
50
  # nothing. `<em>a</em> <strong>b</strong>` must stay
25
51
  # "a b" — deleting the space renders "ab". The size win
@@ -29,12 +55,28 @@ module Abqari
29
55
  # significant whitespace inside them is untouched.)
30
56
  .gsub(/>\s+</, '> <')
31
57
  .gsub(/\s{2,}/, ' ')
32
- .strip
58
+ minified = trim(minified)
33
59
 
34
- minified.gsub(/\x00ABQARI_PRESERVED_(\d+)\x00/) do
35
- preserved[::Regexp.last_match(1).to_i]
36
- end
60
+ restore(minified, preserved)
61
+ end
62
+
63
+ # `String#strip` also removes NUL bytes, which would bite the
64
+ # PLACEHOLDER's trailing \x00 whenever a document starts or ends
65
+ # with a preserved block — the placeholder then fails to match on
66
+ # restore and ships to the browser as literal
67
+ # "\x00ABQARI_PRESERVED_0" text. `\s` in a Ruby regex is whitespace
68
+ # only, so trimming this way leaves the sentinels intact.
69
+ TRIM_REGEX = /\A\s+|\s+\z/.freeze
70
+ PLACEHOLDER_REGEX = /\x00ABQARI_PRESERVED_(\d+)\x00/.freeze
71
+
72
+ def self.trim(text)
73
+ text.gsub(TRIM_REGEX, '')
74
+ end
75
+
76
+ def self.restore(text, preserved)
77
+ text.gsub(PLACEHOLDER_REGEX) { preserved[::Regexp.last_match(1).to_i] }
37
78
  end
79
+ private_class_method :trim, :restore
38
80
 
39
81
  # Comments and quoted strings are recognised in ONE left-to-right
40
82
  # pass, via ordered alternation, so whichever construct STARTS first
@@ -81,11 +123,9 @@ module Abqari
81
123
  .gsub(/\s+/, ' ')
82
124
  .gsub(/\s*([{};,>])\s*/, '\1')
83
125
  .gsub(';}', '}')
84
- .strip
126
+ minified = trim(minified)
85
127
 
86
- minified.gsub(/\x00ABQARI_PRESERVED_(\d+)\x00/) do
87
- preserved[::Regexp.last_match(1).to_i]
88
- end
128
+ restore(minified, preserved)
89
129
  end
90
130
 
91
131
  JS_LINE_COMMENT_REGEX = %r{^[ \t]*//.*$}.freeze
@@ -48,13 +48,17 @@ module Abqari
48
48
  # `compute_shared_deps_mtime` time so we only walk the active
49
49
  # theme's directory, not every theme that happens to ship with
50
50
  # the gem.
51
+ #
52
+ # `vendor/fonts/fonts.css` is here because every page's head links
53
+ # it by content hash; a re-fetch must re-render them all.
51
54
  GLOBAL_DEP_GLOBS = [
52
55
  'app/views/partials/**/*',
53
56
  'app/icons/**/*',
54
57
  'app/assets/**/*',
55
58
  'lib/**/*',
56
59
  'config/**/*',
57
- 'data/**/*'
60
+ 'data/**/*',
61
+ 'vendor/fonts/fonts.css'
58
62
  ].freeze
59
63
 
60
64
  # The renderer only needs the frontmatter block (no body), but
@@ -14,5 +14,5 @@
14
14
  #
15
15
  # Keep this file dependency-free.
16
16
  module Abqari
17
- VERSION = '1.1.0'
17
+ VERSION = '1.1.2'
18
18
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: abqari
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Grant Rayner