abqari 1.3.0 → 1.3.1

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: ac44a7f76fcb30cb218104f9e002a0cd958518542912393d7681fceab92abdf0
4
- data.tar.gz: 8a0cc5fe35425ca6571dbc76549df5105106bf8ff672f67ea9101c26e48af550
3
+ metadata.gz: 1e61ca715dce737000acb0b0f08033cb7dc92d469c0553481d3bbaf6aec1276a
4
+ data.tar.gz: 3e5bf6cae00b055c9a6bd66fed47217b3a2a32f7f5ddc299e1e92200b23b82ed
5
5
  SHA512:
6
- metadata.gz: 9050a56ac582b3cdb3f4590bf90f6543ee57fa0dd6e1c4b56afc7274a937dfbb805400869d157473019af1a9802da7e88af97e30961f436cbed8a2472f2bb6cb
7
- data.tar.gz: db4d7cb9c339676e76cc794b8e205fb0000d50266d8b28ae6a53770b7d701a44fd3664d3f3d2076e3fc78cd2f08745ab442a4abd85a5bcbdbb0ef14586a8f26b
6
+ metadata.gz: 81e34727adb75ca47e012b73579158fd2a203bda6fe517a1d138b4cee563c4bb54d75926ed6597aa0df826861abe68cc7a36321c24701e3469b2cc4ec6854619
7
+ data.tar.gz: b9f6b8ecf943c8fa60048cf2e0bbe7a64b0b8e27592125d7b62bf47bb0eeee4d318b34583129220dd002b72e7bb6427808e79ec61d08b3316095a9553c7f0dbf
data/CHANGELOG.md CHANGED
@@ -9,6 +9,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  _Nothing yet._
11
11
 
12
+ ## [1.3.1] — 2026-10-20
13
+
14
+ Upgrade to correct CSP-audit parsing, HTML text extraction and importer
15
+ escaping. This patch also removes the regex patterns flagged by CodeQL.
16
+
17
+ ### Changed
18
+
19
+ - HTML5 parsing uses `nokolexbor`, one runtime dependency with no
20
+ transitive runtime gems. The clean-container CI matrix validates its
21
+ precompiled packages on Ruby 3.3, 3.4 and 4.0.
22
+ - Workshop rendering tests keep an upcoming fixture in memory instead
23
+ of expiring when the example workshop dates pass.
24
+
25
+ ### Fixed
26
+
27
+ - **CSP audits could miss browser-valid inline scripts and handlers.**
28
+ Regex parsing skipped whitespace in closing tags and unquoted
29
+ attributes, and could change script contents while removing comments.
30
+ HTML5 parsing now preserves browser hash input, decodes attributes
31
+ once and ignores inert template contents. Audit tests use the same
32
+ tag rules, including uppercase and comment edge cases.
33
+ - **Excerpts and reading statistics now read HTML as HTML.** Quoted
34
+ delimiters, entities, comments and non-visible script/style/template
35
+ content are handled by a parser rather than a stripping regex.
36
+ Extracted text still requires normal template escaping.
37
+ - **Imported titles retain quotes, backslashes and Unicode YAML line
38
+ breaks without changing the frontmatter structure.** Escaping is a
39
+ single literal-replacement pass, with round-trip regression coverage.
40
+ - **HTML rewrites and CSS minification avoid overlapping whitespace
41
+ matches.** Image and bundle URL rewrites retain their output while
42
+ regex matching no longer needs to reconsider delimiter whitespace.
43
+
12
44
  ## [1.3.0] — 2026-10-11
13
45
 
14
46
  If CI runs `bin/audit --strict`, run `bin/audit` locally before
@@ -1141,7 +1173,8 @@ run a real working author / publisher site.
1141
1173
  commonmarker ≥ 2.4 ships precompiled 4.0 binaries, which is what
1142
1174
  the `~> 2.4` floor bought.
1143
1175
 
1144
- [Unreleased]: https://github.com/grantrayner/abqari/compare/v1.3.0...HEAD
1176
+ [Unreleased]: https://github.com/grantrayner/abqari/compare/v1.3.1...HEAD
1177
+ [1.3.1]: https://github.com/grantrayner/abqari/compare/v1.3.0...v1.3.1
1145
1178
  [1.3.0]: https://github.com/grantrayner/abqari/compare/v1.2.0...v1.3.0
1146
1179
  [1.2.0]: https://github.com/grantrayner/abqari/compare/v1.1.2...v1.2.0
1147
1180
  [1.1.2]: https://github.com/grantrayner/abqari/compare/v1.1.1...v1.1.2
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.3.0 — see
14
+ > **Status:** v1.3.1 — 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
@@ -38,10 +38,12 @@ Reporting a vulnerability:
38
38
 
39
39
  ## Design principles
40
40
 
41
- 1. **Minimal dependencies.** Seven small runtime gems — three
42
- (`commonmarker`, `erubi`, `ruby-vips`) do the work on every build;
43
- the rest cover syntax highlighting, the dev server, and CSV/Base64
44
- handling. Content importing needs one more (`reverse_markdown`),
41
+ 1. **Minimal dependencies.** Eight runtime gems cover Markdown,
42
+ auto-escaped templates, images, HTML5 parsing, syntax highlighting,
43
+ the dev server, and CSV/Base64 handling. `nokolexbor` reads HTML for
44
+ CSP audits and text extraction, with no transitive runtime dependencies
45
+ and precompiled packages for the supported desktop/Linux platforms.
46
+ Content importing needs one more (`reverse_markdown`),
45
47
  deliberately left out of the runtime set so a normal install doesn't
46
48
  pull in nokogiri for a feature it never uses.
47
49
  2. **Ruby-first.** ERB templates, real Ruby in views.
@@ -174,7 +176,7 @@ Test that it worked:
174
176
  abqari version
175
177
  ```
176
178
 
177
- You should see a version number, e.g. `abqari 1.3.0`. If you get
179
+ You should see a version number, e.g. `abqari 1.3.1`. If you get
178
180
  `command not found`, see [Troubleshooting](#troubleshooting) below.
179
181
 
180
182
  ### Step 3 — Create your first site
@@ -470,7 +472,7 @@ end
470
472
  Or — pin to a git ref while tracking pre-release work:
471
473
 
472
474
  ```ruby
473
- gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.3.0'
475
+ gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.3.1'
474
476
  ```
475
477
 
476
478
  Install the libvips C library once (hard dep for the image pipeline):
@@ -947,6 +949,11 @@ and on `Abqari::RenderContext`, available everywhere ERB runs.
947
949
  | `reading_time(text, wpm:)` | `reading_time(post.body)` → `"4 min read"` |
948
950
  | `slugify(text)` | `slugify('Hello World!')` → `"hello-world"` |
949
951
 
952
+ `excerpt`, `word_count` and `reading_time` parse HTML and decode entities;
953
+ comments and script/style/template contents do not count as visible text.
954
+ An excerpt is plain text, not sanitized HTML. Render it with `<%= excerpt(...) %>`
955
+ so template auto-escaping remains in force.
956
+
950
957
  **Time**
951
958
 
952
959
  | Helper | Example |
@@ -2566,7 +2573,7 @@ changes to your site. To track a fork or an unreleased branch
2566
2573
  instead, swap the line for a git source:
2567
2574
 
2568
2575
  ```ruby
2569
- gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.3.0'
2576
+ gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.3.1'
2570
2577
  ```
2571
2578
 
2572
2579
  The cadence: read [CHANGELOG.md](https://github.com/grantrayner/abqari/blob/main/CHANGELOG.md) before each upgrade,
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'base64'
4
- require 'cgi'
5
4
  require 'digest'
5
+ require_relative '../html'
6
6
 
7
7
  module Abqari
8
8
  class Audit
@@ -114,35 +114,30 @@ module Abqari
114
114
  # Each piece of inline code as `[kind, content, script_type]`, where
115
115
  # content is exactly what the browser hashes.
116
116
  def inline_code(html)
117
- html = html.gsub(/<!--.*?-->/m, '')
118
117
  code = []
118
+ Html.document(html).css('*').each do |node|
119
+ next if node.ancestors.any? { |parent| parent.name == 'template' }
120
+
121
+ if node.name == 'script' && !node.key?('src')
122
+ type = node['type']&.strip&.downcase
123
+ if type.nil? || type.empty? || EXECUTABLE_TYPES.include?(type)
124
+ code << [:script, node.content, type]
125
+ end
126
+ elsif node.name == 'style'
127
+ code << [:style, node.content]
128
+ end
119
129
 
120
- html.scan(%r{<script\b([^>]*)>(.*?)</script>}mi).each do |attrs, body|
121
- next if attribute(attrs, 'src')
122
-
123
- type = attribute(attrs, 'type')&.strip&.downcase
124
- next unless type.nil? || type.empty? || EXECUTABLE_TYPES.include?(type)
130
+ # The parser already decodes attributes once, as the browser
131
+ # does. Decoding again changes the bytes used for CSP hashes.
132
+ node.attributes.each do |name, attribute|
133
+ next unless name == 'style' || name.match?(/\Aon[a-z]+\z/)
125
134
 
126
- code << [:script, body, type]
127
- end
128
- html.scan(%r{<style\b[^>]*>(.*?)</style>}mi).each { |(body)| code << [:style, body] }
129
-
130
- # Attributes, with script and style bodies removed first so a
131
- # string inside a script that looks like markup isn't counted.
132
- tags_only = html.gsub(%r{<(script|style)\b[^>]*>.*?</\1>}mi, '')
133
- tags_only.scan(/<[a-zA-Z][^>]*>/).each do |tag|
134
- tag.scan(/\s(on[a-z]+|style)\s*=\s*(?:"([^"]*)"|'([^']*)')/i).each do |name, dq, sq|
135
- code << [name.casecmp?('style') ? :style_attr : :script_attr, CGI.unescapeHTML(dq || sq)]
135
+ code << [name == 'style' ? :style_attr : :script_attr, attribute.value]
136
136
  end
137
137
  end
138
138
  code
139
139
  end
140
140
 
141
- def attribute(attrs, name)
142
- match = attrs.match(/\b#{name}\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i)
143
- match && (match[1] || match[2] || match[3])
144
- end
145
-
146
141
  # Mirrors the CSP spec's inline checks: a hash or nonce in the list
147
142
  # switches `'unsafe-inline'` off; attribute hashes count only with
148
143
  # `'unsafe-hashes'`. `'inline-speculation-rules'` is honoured only
@@ -5,6 +5,7 @@ require 'date'
5
5
  require 'json'
6
6
  require 'time'
7
7
  require 'uri'
8
+ require_relative 'html'
8
9
 
9
10
  module Abqari
10
11
  module Helpers
@@ -161,7 +162,7 @@ end
161
162
  # `data-heading-content`, so each heading silently added several
162
163
  # phantom "words" to the structured data search engines read.
163
164
  def word_count(text)
164
- text.to_s.gsub(/<[^>]+>/, ' ').scan(/\S+/).length
165
+ Html.text(text, separator: ' ').scan(/\S+/).length
165
166
  end
166
167
 
167
168
  # Format a date or time. Default format is "1 January 2026"
@@ -224,7 +225,7 @@ end
224
225
  end
225
226
 
226
227
  def excerpt(text, words: 50, omission: '…')
227
- stripped = text.to_s.gsub(/<[^>]+>/, '').strip
228
+ stripped = Html.text(text).strip
228
229
  arr = stripped.split(/\s+/)
229
230
  return stripped if arr.length <= words
230
231
 
@@ -232,7 +233,7 @@ end
232
233
  end
233
234
 
234
235
  def reading_time(text, wpm: 200)
235
- stripped = text.to_s.gsub(/<[^>]+>/, '')
236
+ stripped = Html.text(text)
236
237
  word_count = stripped.split(/\s+/).length
237
238
  # Clamp wpm to ≥1 (a `wpm: 0` would divide to Infinity → FloatDomainError)
238
239
  # and the result to ≥1 minute (empty text shouldn't read "0 min read").
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokolexbor'
4
+
5
+ module Abqari
6
+ # HTML must be read with the same tag, comment and attribute rules as
7
+ # the browser. Regex stripping can change script hash input or hide an
8
+ # executable tag. Lexbor also keeps this work out of Ruby's regex engine.
9
+ module Html
10
+ module_function
11
+
12
+ def document(html)
13
+ Nokolexbor::HTML(html.to_s)
14
+ end
15
+
16
+ def fragment(html)
17
+ Nokolexbor::DocumentFragment.parse(html.to_s)
18
+ end
19
+
20
+ # Plain text, never trusted HTML. Callers must still escape it when
21
+ # rendering (the normal <%= interpolation does this automatically).
22
+ # Non-visible code and comments must not inflate reading estimates.
23
+ def text(html, separator: '')
24
+ tree = fragment(html)
25
+ tree.css('script, style, template').each(&:remove)
26
+ tree.xpath('.//text()').map(&:content).join(separator)
27
+ end
28
+ end
29
+ end
@@ -173,7 +173,9 @@ module Abqari
173
173
  def post_process_html(html, page: nil)
174
174
  return html unless enabled?
175
175
 
176
- html.gsub(/<img\s+([^>]*?)>/m) do |match|
176
+ # Whitespace belongs to the delimiter, not to the attributes.
177
+ # Possessive quantifiers prevent overlapping backtracking paths.
178
+ html.gsub(/<img\s++([^>]*+)>/m) do |match|
177
179
  attrs = ::Regexp.last_match(1)
178
180
  src = attrs[/src="([^"]+)"/, 1]
179
181
  next match unless src
@@ -285,7 +285,7 @@ module Abqari
285
285
  # been written straight through, turning one mapping entry into
286
286
  # two. Importer input is a third-party export, so this is
287
287
  # attacker-shaped. Escape them into their YAML forms instead.
288
- if str.match?(/[\x00-\x1F\x7F]/)
288
+ if str.match?(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]/)
289
289
  return %("#{escape_yaml_double_quoted(str)}")
290
290
  end
291
291
 
@@ -301,13 +301,18 @@ module Abqari
301
301
  # quotes, then the control characters that have named escapes,
302
302
  # then anything else remaining as \uXXXX.
303
303
  def escape_yaml_double_quoted(str)
304
- str
305
- .gsub('\\', '\\\\\\\\')
306
- .gsub('"', '\\"')
307
- .gsub("\n", '\\n')
308
- .gsub("\r", '\\r')
309
- .gsub("\t", '\\t')
310
- .gsub(/[\x00-\x1F\x7F]/) { |c| format('\\u%04X', c.ord) }
304
+ # A block returns literal replacement bytes: String#gsub's
305
+ # replacement-string backslash rules cannot reinterpret them.
306
+ str.gsub(/[\\"\u0000-\u001F\u007F-\u009F\u2028\u2029]/) do |char|
307
+ case char
308
+ when '\\' then '\\\\'
309
+ when '"' then '\\"'
310
+ when "\n" then '\\n'
311
+ when "\r" then '\\r'
312
+ when "\t" then '\\t'
313
+ else format('\\u%04X', char.ord)
314
+ end
315
+ end
311
316
  end
312
317
 
313
318
  def block_array(key, items)
@@ -121,7 +121,8 @@ module Abqari
121
121
 
122
122
  minified = stripped
123
123
  .gsub(/\s+/, ' ')
124
- .gsub(/\s*([{};,>])\s*/, '\1')
124
+ # The previous pass leaves at most one ASCII space.
125
+ .gsub(/ ?([{};,>]) ?/, '\1')
125
126
  .gsub(';}', '}')
126
127
  minified = trim(minified)
127
128
 
@@ -810,7 +810,7 @@ module Abqari
810
810
  def rewrite_bundle_urls(html, page)
811
811
  base = page.url
812
812
 
813
- html.gsub(/(<(?:img|a)\s+[^>]*?(?:src|href)=")([^"]+)/) do
813
+ html.gsub(/(<(?:img|a)\s++[^>]*?(?:src|href)=")([^"]+)/) do
814
814
  prefix = ::Regexp.last_match(1)
815
815
  url = ::Regexp.last_match(2)
816
816
 
@@ -14,5 +14,5 @@
14
14
  #
15
15
  # Keep this file dependency-free.
16
16
  module Abqari
17
- VERSION = '1.3.0'
17
+ VERSION = '1.3.1'
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.3.0
4
+ version: 1.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Grant Rayner
@@ -65,6 +65,20 @@ dependencies:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
67
  version: '1.13'
68
+ - !ruby/object:Gem::Dependency
69
+ name: nokolexbor
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: 0.8.0
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: 0.8.0
68
82
  - !ruby/object:Gem::Dependency
69
83
  name: rouge
70
84
  requirement: !ruby/object:Gem::Requirement
@@ -239,6 +253,7 @@ files:
239
253
  - lib/abqari/headers_file.rb
240
254
  - lib/abqari/helpers.rb
241
255
  - lib/abqari/hooks.rb
256
+ - lib/abqari/html.rb
242
257
  - lib/abqari/http_safe.rb
243
258
  - lib/abqari/image_pipeline.rb
244
259
  - lib/abqari/importers.rb