abqari 1.2.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: b9d5e9a272de668ea9e75b33d47fd252c9a46413c6873c15d077a938f3817004
4
- data.tar.gz: 0d55679d1de17c940765b5a04c81e15980e1929a590fab878b36e65fdc877195
3
+ metadata.gz: 1e61ca715dce737000acb0b0f08033cb7dc92d469c0553481d3bbaf6aec1276a
4
+ data.tar.gz: 3e5bf6cae00b055c9a6bd66fed47217b3a2a32f7f5ddc299e1e92200b23b82ed
5
5
  SHA512:
6
- metadata.gz: 27ecd3e0f352ce9970cd85256badd8973bcd950092fa67cd56de2f9820862eef89740b3995d8c3e7f692259a9f50d49da1d41e3a76d7912c0873d650b46d101a
7
- data.tar.gz: 77b4d04a24911bf1705cb074ce4515bd130ee5db2340e1c988c81a5a707c54014bf5a62cc94ca1d83139747d7c87595a3c7c2607a79ae9b2f953c926b7f16d43
6
+ metadata.gz: 81e34727adb75ca47e012b73579158fd2a203bda6fe517a1d138b4cee563c4bb54d75926ed6597aa0df826861abe68cc7a36321c24701e3469b2cc4ec6854619
7
+ data.tar.gz: b9f6b8ecf943c8fa60048cf2e0bbe7a64b0b8e27592125d7b62bf47bb0eeee4d318b34583129220dd002b72e7bb6427808e79ec61d08b3316095a9553c7f0dbf
data/CHANGELOG.md CHANGED
@@ -9,6 +9,69 @@ 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
+
44
+ ## [1.3.0] — 2026-10-11
45
+
46
+ If CI runs `bin/audit --strict`, run `bin/audit` locally before
47
+ upgrading: a new Security check can fail an audit that passed on
48
+ 1.2.0. Every finding is inline code that is already blocked on any host
49
+ that enforces the site's CSP, so the fix is in the site, not the
50
+ policy.
51
+
52
+ ### Added
53
+
54
+ - **`bin/audit` now fails on inline code the site's own CSP would
55
+ block.** The generated policy has `style-src 'self'` and pins inline
56
+ scripts by hash, so a template that adds `style="…"`, a `<style>`
57
+ block, an `onclick=`, or an unhashed `<script>` breaks on any host
58
+ that enforces the CSP. It hid because nothing local enforces it: the
59
+ dev server doesn't apply `_headers`, the page renders, and the
60
+ browser only logs a console error. One site had 52 inline
61
+ `style="--ratio: …"` attributes that would have collapsed every photo
62
+ pane the moment its CSP went live.
63
+
64
+ The new **Security** category (`Audit::Csp`) checks every page
65
+ against the CSP in the built `_headers`, so a `headers:` override or
66
+ `csp:` extension is what's checked. It reports one error per page per
67
+ kind of inline code, with an example and how to fix it. It follows
68
+ the spec: hashes, `'unsafe-inline'` (ignored once a hash or nonce is
69
+ present), `'unsafe-hashes'` for attributes, the `-elem`/`-attr`
70
+ directives and the `default-src` fallback. `bin/audit --strict` fails
71
+ on these, so a site that passed yesterday can fail today; every
72
+ finding is code that is already broken wherever the policy is
73
+ applied.
74
+
12
75
  ## [1.2.0] — 2026-10-11
13
76
 
14
77
  Upgrade if you serve the generated CSP: Chrome has been blocking
@@ -1110,7 +1173,9 @@ run a real working author / publisher site.
1110
1173
  commonmarker ≥ 2.4 ships precompiled 4.0 binaries, which is what
1111
1174
  the `~> 2.4` floor bought.
1112
1175
 
1113
- [Unreleased]: https://github.com/grantrayner/abqari/compare/v1.2.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
1178
+ [1.3.0]: https://github.com/grantrayner/abqari/compare/v1.2.0...v1.3.0
1114
1179
  [1.2.0]: https://github.com/grantrayner/abqari/compare/v1.1.2...v1.2.0
1115
1180
  [1.1.2]: https://github.com/grantrayner/abqari/compare/v1.1.1...v1.1.2
1116
1181
  [1.1.1]: https://github.com/grantrayner/abqari/compare/v1.1.0...v1.1.1
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.2.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.2.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
@@ -460,7 +462,7 @@ Edit `Gemfile`:
460
462
  ```ruby
461
463
  source 'https://rubygems.org'
462
464
 
463
- gem 'abqari', '~> 1.2'
465
+ gem 'abqari', '~> 1.3'
464
466
 
465
467
  group :development do
466
468
  gem 'listen', '~> 3.8' # optional, for the dev-server file watcher
@@ -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.2.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 |
@@ -2331,6 +2338,7 @@ Rebuilds the site, walks the output, and writes a markdown report to `audit/<YYY
2331
2338
  | **Privacy** | External resource loads (URLs not on the site host) |
2332
2339
  | **Dependencies** | Outdated gems via `bundle outdated`, CVEs via `bundle-audit` (if installed) |
2333
2340
  | **Content** | Posts without descriptions, single-use tags, `series_position` problems that break prev/next silently (non-Integer, duplicate, or missing) |
2341
+ | **Security** | Inline code the built `_headers` CSP would block: `style="…"` attributes, `<style>` blocks, `on…=` handlers, and inline scripts whose hash isn't in `script-src`. Errors, because the page breaks on any host that enforces the policy |
2334
2342
 
2335
2343
  The report opens with a summary table, a **Suggested fixes** code block listing every `bundle update <gem>` command needed to clear the dependency findings, and then groups findings by category with severity (`error`, `warn`, `info`) and a suggestion where applicable. Reports accumulate in `audit/` — useful to commit so you have a diffable history of site quality over time.
2336
2344
 
@@ -2365,7 +2373,7 @@ GitHub Dependabot does three things abqari's audit does **not**:
2365
2373
 
2366
2374
  What `bin/audit` does that Dependabot doesn't:
2367
2375
 
2368
- - **Same report covers links, SEO, accessibility, privacy, and content.** Dependabot only watches dependencies.
2376
+ - **Same report covers links, SEO, accessibility, privacy, content, and the CSP.** Dependabot only watches dependencies.
2369
2377
  - **Uses the same `ruby-advisory-db`** Dependabot's Ruby ecosystem coverage uses, just at a single point in time.
2370
2378
  - **Outputs to git-trackable markdown.** A diffable `audit/` history of every check you've run.
2371
2379
 
@@ -2558,14 +2566,14 @@ bin/audit # verify nothing regressed
2558
2566
  ```
2559
2567
 
2560
2568
  `abqari new` scaffolds the Gemfile pinned to the current major.minor
2561
- (`gem 'abqari', '~> 1.2'` as of this release). Bundler reads that as
2562
- `>= 1.2, < 2.0`, so `bundle update abqari` takes minor and patch
2569
+ (`gem 'abqari', '~> 1.3'` as of this release). Bundler reads that as
2570
+ `>= 1.3, < 2.0`, so `bundle update abqari` takes minor and patch
2563
2571
  releases but never a major one, which is the release that can need
2564
2572
  changes to your site. To track a fork or an unreleased branch
2565
2573
  instead, swap the line for a git source:
2566
2574
 
2567
2575
  ```ruby
2568
- gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.2.0'
2576
+ gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.3.1'
2569
2577
  ```
2570
2578
 
2571
2579
  The cadence: read [CHANGELOG.md](https://github.com/grantrayner/abqari/blob/main/CHANGELOG.md) before each upgrade,
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'digest'
5
+ require_relative '../html'
6
+
7
+ module Abqari
8
+ class Audit
9
+ # CSP rule. Checks every page's inline code against the
10
+ # Content-Security-Policy the same build wrote to `_headers`: inline
11
+ # `<script>` and `<style>` blocks, `style="…"` attributes and `on…=`
12
+ # event handlers.
13
+ #
14
+ # The policy is strict on purpose (`style-src 'self'`, scripts pinned
15
+ # by hash), so anything a template adds inline is blocked wherever
16
+ # the CSP is enforced — and nothing else notices. The dev server
17
+ # doesn't apply `_headers`, the page still renders, and the browser
18
+ # only logs a console error. One site carried 52 inline
19
+ # `style="--ratio: …"` attributes that would have collapsed every
20
+ # photo pane, and the engine's own speculation-rules block shipped in
21
+ # 1.1.x allowed only by a keyword Chrome ignores.
22
+ #
23
+ # Reads the BUILT `_headers`, not the engine's defaults, so a
24
+ # `headers:` override or `csp:` extension in site.yml is what gets
25
+ # checked. Error-severity: on a host that enforces the policy the
26
+ # page is broken. A site on a host that ignores `_headers` (GitHub
27
+ # Pages) will see these too; the code is still blocked the day the
28
+ # policy is applied.
29
+ class Csp < Rule
30
+ # Script types the browser executes, so CSP governs them. Any other
31
+ # `type` is a data block (JSON-LD, templates) and CSP ignores it.
32
+ EXECUTABLE_TYPES = %w[
33
+ text/javascript application/javascript application/ecmascript text/ecmascript
34
+ module importmap speculationrules
35
+ ].freeze
36
+ HASH_ALGORITHMS = { 'sha256' => Digest::SHA256, 'sha384' => Digest::SHA384, 'sha512' => Digest::SHA512 }.freeze
37
+
38
+ # Most specific directive first, as browsers resolve them.
39
+ DIRECTIVES = {
40
+ script: %w[script-src-elem script-src default-src],
41
+ script_attr: %w[script-src-attr script-src default-src],
42
+ style: %w[style-src-elem style-src default-src],
43
+ style_attr: %w[style-src-attr style-src default-src]
44
+ }.freeze
45
+
46
+ LABELS = {
47
+ script: 'inline <script>',
48
+ script_attr: 'inline event handler',
49
+ style: '<style> block',
50
+ style_attr: 'style attribute'
51
+ }.freeze
52
+
53
+ ADVICE = {
54
+ script: 'Move it to a file under app/assets/js/, or add its sha256 to the CSP. ' \
55
+ 'For the engine\'s own scripts, upgrade abqari and re-copy the CSP.',
56
+ script_attr: 'Attach the handler from a script file with addEventListener instead.',
57
+ style: 'Move the rules into app/assets/css/site.css.',
58
+ style_attr: 'Move the styles into app/assets/css/site.css as a class, or a data ' \
59
+ 'attribute matched by an attribute selector for per-element values.'
60
+ }.freeze
61
+
62
+ def run
63
+ policies = csp_policies
64
+ return if policies.empty?
65
+
66
+ html_files.each do |file|
67
+ url = page_url(file)
68
+ applicable = policies.select { |prefix, _| url.start_with?(prefix) }.map(&:last)
69
+ next if applicable.empty?
70
+
71
+ check_page(url, File.read(file, encoding: 'UTF-8'), applicable)
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ # `[[path_prefix, { directive => [sources] }]]` for every CSP rule in
78
+ # the built `_headers`. More than one can apply to a page; the
79
+ # browser enforces all of them, so inline code has to pass each.
80
+ def csp_policies
81
+ path = File.join(@site.output_dir, '_headers')
82
+ return [] unless File.file?(path)
83
+
84
+ HeadersFile.parse(File.read(path, encoding: 'UTF-8'))
85
+ .select { |rule| rule['name'].casecmp?('Content-Security-Policy') }
86
+ .map { |rule| [rule['path'].delete_suffix('*'), parse_policy(rule['value'])] }
87
+ end
88
+
89
+ def parse_policy(value)
90
+ value.split(';').each_with_object({}) do |directive, policy|
91
+ name, *sources = directive.strip.split(/\s+/)
92
+ policy[name.downcase] = sources if name
93
+ end
94
+ end
95
+
96
+ def check_page(url, html, policies)
97
+ blocked = Hash.new { |h, k| h[k] = [] }
98
+ inline_code(html).each do |kind, content, type|
99
+ policy = policies.find { |pol| !allowed?(pol, kind, content, type) }
100
+ blocked[kind] << [content, policy] if policy
101
+ end
102
+
103
+ blocked.each do |kind, hits|
104
+ content, policy = hits.first
105
+ directive = DIRECTIVES[kind].find { |d| policy.key?(d) }
106
+ count = hits.size
107
+ add(category: 'security', severity: 'error', location: url,
108
+ message: "#{count} #{LABELS[kind]}#{'s' unless count == 1} blocked by the CSP " \
109
+ "(#{directive} #{policy[directive].join(' ')}), e.g. #{example(kind, content)}",
110
+ suggestion: ADVICE[kind])
111
+ end
112
+ end
113
+
114
+ # Each piece of inline code as `[kind, content, script_type]`, where
115
+ # content is exactly what the browser hashes.
116
+ def inline_code(html)
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
129
+
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/)
134
+
135
+ code << [name == 'style' ? :style_attr : :script_attr, attribute.value]
136
+ end
137
+ end
138
+ code
139
+ end
140
+
141
+ # Mirrors the CSP spec's inline checks: a hash or nonce in the list
142
+ # switches `'unsafe-inline'` off; attribute hashes count only with
143
+ # `'unsafe-hashes'`. `'inline-speculation-rules'` is honoured only
144
+ # when the list has no hash or nonce: Chrome reports a block allowed
145
+ # by nothing but the keyword as a violation once hashes are present.
146
+ def allowed?(policy, kind, content, type = nil)
147
+ directive = DIRECTIVES[kind].find { |d| policy.key?(d) }
148
+ return true unless directive
149
+
150
+ sources = policy[directive]
151
+ hashed = sources.any? { |s| s.match?(/\A'(sha256|sha384|sha512|nonce)-/i) }
152
+ hashed ||= kind == :script && sources.include?("'strict-dynamic'")
153
+ return true if sources.include?("'unsafe-inline'") && !hashed
154
+ return true if type == 'speculationrules' && sources.include?("'inline-speculation-rules'") && !hashed
155
+ return false if %i[style_attr script_attr].include?(kind) && !sources.include?("'unsafe-hashes'")
156
+
157
+ HASH_ALGORITHMS.any? do |alg, digest|
158
+ sources.include?("'#{alg}-#{Base64.strict_encode64(digest.digest(content))}'")
159
+ end
160
+ end
161
+
162
+ def example(kind, content)
163
+ snippet = content.strip.gsub(/\s+/, ' ')
164
+ snippet = "#{snippet[0, 60]}…" if snippet.length > 60
165
+ case kind
166
+ when :style_attr then %(style="#{snippet}")
167
+ when :script_attr then %(handler "#{snippet}")
168
+ else %(`#{snippet}`)
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
data/lib/abqari/audit.rb CHANGED
@@ -12,6 +12,7 @@ require_relative 'audit/privacy'
12
12
  require_relative 'audit/performance'
13
13
  require_relative 'audit/dependencies'
14
14
  require_relative 'audit/content'
15
+ require_relative 'audit/csp'
15
16
 
16
17
  module Abqari
17
18
  # Site audit orchestrator. Each check lives in its own rule class
@@ -36,14 +37,15 @@ module Abqari
36
37
  'privacy' => 'Privacy',
37
38
  'perf' => 'Performance',
38
39
  'deps' => 'Dependencies',
39
- 'content' => 'Content'
40
+ 'content' => 'Content',
41
+ 'security' => 'Security'
40
42
  }.freeze
41
43
 
42
44
  # Rule classes, in execution order. Order matters only for the
43
45
  # output report — findings are grouped by category, not insertion
44
46
  # order — but Links first keeps the report scan-friendly: errors
45
47
  # appear before warnings.
46
- RULES = [Links, SEO, A11y, Privacy, Performance, Dependencies, Content].freeze
48
+ RULES = [Links, SEO, A11y, Privacy, Performance, Dependencies, Content, Csp].freeze
47
49
 
48
50
  attr_reader :findings
49
51
 
@@ -47,21 +47,8 @@ module Abqari
47
47
  true
48
48
  end
49
49
 
50
- # `_headers` blocks: an unindented path line, then indented
51
- # `Name: value` lines. Same shape HeadersFile writes, for both the
52
- # generated policy and a `headers:` override in site.yml.
53
50
  def parse(text)
54
- path = nil
55
- text.each_line.with_object([]) do |line, rules|
56
- next if line.strip.empty? || line.lstrip.start_with?('#')
57
-
58
- if line.match?(/\A\S/)
59
- path = line.strip
60
- elsif path
61
- name, value = line.strip.split(/:\s*/, 2)
62
- rules << { 'path' => path, 'name' => name, 'value' => value.to_s } unless name.to_s.empty?
63
- end
64
- end
51
+ HeadersFile.parse(text)
65
52
  end
66
53
 
67
54
  private
@@ -31,6 +31,24 @@ module Abqari
31
31
  'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains'
32
32
  }.freeze
33
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
+
34
52
  def initialize(site)
35
53
  @site = site
36
54
  end
@@ -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.2.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.2.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
@@ -220,6 +234,7 @@ files:
220
234
  - lib/abqari/audit.rb
221
235
  - lib/abqari/audit/a11y.rb
222
236
  - lib/abqari/audit/content.rb
237
+ - lib/abqari/audit/csp.rb
223
238
  - lib/abqari/audit/dependencies.rb
224
239
  - lib/abqari/audit/links.rb
225
240
  - lib/abqari/audit/performance.rb
@@ -238,6 +253,7 @@ files:
238
253
  - lib/abqari/headers_file.rb
239
254
  - lib/abqari/helpers.rb
240
255
  - lib/abqari/hooks.rb
256
+ - lib/abqari/html.rb
241
257
  - lib/abqari/http_safe.rb
242
258
  - lib/abqari/image_pipeline.rb
243
259
  - lib/abqari/importers.rb