findxpand 0.1.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,628 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The rewrite, and nothing else. No I/O, no framework, no state.
4
+ #
5
+ # Pure, because this is the half that has to agree byte for byte with the Node
6
+ # middleware, the Python middleware and the Cloudflare Worker.
7
+ # `test/conformance_test.rb` runs the engine's shared fixture — the same JSON
8
+ # file the other packages run — so a change made here and not there is a test
9
+ # failure rather than a page with two titles.
10
+ #
11
+ # **Only the head is rewritten, and that is what makes string surgery safe.** A
12
+ # pattern applied to a whole document will happily rewrite a `<title>` inside
13
+ # inline SVG or a code sample, which is a real way to corrupt a page and the
14
+ # reason the edge worker uses a streaming parser instead. Bounding every head
15
+ # edit to the slice between `<head>` and `</head>` removes that class of mistake
16
+ # without asking a client to install a parser. The two body fields, `h1` and
17
+ # `image_alt`, are matched by element rather than by text for the same reason.
18
+ #
19
+ # Standard library only, deliberately — in fact this file requires nothing at
20
+ # all. It runs inside somebody else's application, and a middleware that drags
21
+ # in a dependency tree is a middleware that can break their build.
22
+ #
23
+ # ## Ruby's own ways to get this wrong
24
+ #
25
+ # A port of a working Python file into Ruby is not a translation exercise; four
26
+ # of Ruby's most ordinary idioms fail *silently* here, and each one passes the
27
+ # shared fixture in at least one direction. They are named at the line that
28
+ # avoids them, and collected once so a reader knows what to look for:
29
+ #
30
+ # * **`sub!` and `gsub!` return `nil` when nothing matched.** `out = out.sub!(re)
31
+ # { ... }` therefore assigns `nil` and *deletes the whole document* the first
32
+ # time an element is absent. Nothing below ever assigns from a bang method,
33
+ # and nothing below uses one at all. See `replace_inner`.
34
+ # * **`sub`/`gsub` with a String replacement expand `\0 \1 \& \` \' \k<name>`.**
35
+ # A customer's title reading `Beans \& Brew` would splice the whole matched
36
+ # tag into the page. Every substitution here takes the block form, which
37
+ # interprets nothing, and `esc` uses a Hash replacement, which interprets
38
+ # nothing either.
39
+ # * **`.` does not cross a newline without `/m`.** Real pages are pretty-printed
40
+ # and the naive `/(<title[^>]*>)(.*?)(<\/title>)/i` passes every single-line
41
+ # fixture case, then rewrites nothing on a real page — and worse, the
42
+ # *presence* test answers false, so a second `<title>` is appended to a page
43
+ # that already has one. Every multi-character wildcard here is `[\s\S]`, which
44
+ # needs no flag and cannot be lost in a refactor the way `/m` can.
45
+ # * **`downcase` is Unicode-aware and changes length.** `'İ'.downcase` (U+0130,
46
+ # the Turkish capital I) is two characters, so an offset computed on a
47
+ # downcased copy is off by one against the original. Nothing here downcases
48
+ # anything: every lookup is a case-insensitive `Regexp`, which answers the
49
+ # same question over the string actually being sliced.
50
+ #
51
+ # ## One unit throughout: characters
52
+ #
53
+ # `Regexp#match(str, pos)`, `MatchData#begin`/`#end` and `String#[]` all count in
54
+ # **characters** for a String. `bytesize` and `byteslice` count in bytes and
55
+ # appear nowhere in this file. Mixing the two is not a rounding error: the head
56
+ # of the Arabic document in the fixture is 124 characters and 141 bytes, so a
57
+ # body offset taken in bytes starts the body slice 17 characters late, past
58
+ # `<body><h1>`, and the h1 rule stops applying with nothing raising and nothing
59
+ # counted (§14 — Arabic is the market, not an edge case). The HTTP layer is the
60
+ # opposite: `content-length` is a byte count and is taken with `bytesize` in
61
+ # `middleware.rb`. Each unit belongs to exactly one layer.
62
+ #
63
+ # Frozen string literals are on. Every accumulator below is created with `+''`,
64
+ # which is an unfrozen copy; nothing appends to a literal, which would raise
65
+ # `FrozenError` at the worst possible moment — inside somebody's request.
66
+
67
+ module Findxpand
68
+ module Rewrite
69
+ # One field, and exactly how it is written into a head.
70
+ #
71
+ # `match_attribute` is what the element is *found* by and is not what the
72
+ # value is written to: a description is located by `name="description"` and
73
+ # written at `content`. Collapsing those two is the bug that produces
74
+ # `name="Roasted in Dubai"`.
75
+ HeadField = Struct.new(:key, :tag, :match_attribute, :match_value, :attribute)
76
+
77
+ # Mirrors `engine.fix.origin.HEAD_FIELDS`, in append order. The conformance
78
+ # fixture carries the same table and the test asserts they are identical,
79
+ # so this cannot drift silently.
80
+ HEAD_FIELDS = [
81
+ HeadField.new('title', 'title', '', '', '').freeze,
82
+ HeadField.new('meta_description', 'meta', 'name', 'description', 'content').freeze,
83
+ HeadField.new('canonical', 'link', 'rel', 'canonical', 'href').freeze,
84
+ HeadField.new('meta_robots', 'meta', 'name', 'robots', 'content').freeze,
85
+ HeadField.new('og_title', 'meta', 'property', 'og:title', 'content').freeze,
86
+ HeadField.new('og_description', 'meta', 'property', 'og:description', 'content').freeze,
87
+ HeadField.new('og_image', 'meta', 'property', 'og:image', 'content').freeze,
88
+ HeadField.new('twitter_title', 'meta', 'name', 'twitter:title', 'content').freeze,
89
+ HeadField.new('twitter_description', 'meta', 'name', 'twitter:description', 'content').freeze
90
+ ].freeze
91
+
92
+ # A run of attributes, in which a quoted value is skipped **whole**.
93
+ #
94
+ # This replaces the `[^>]*` that every implementation started with, and the
95
+ # difference is a corrupted page rather than a missed one. `[^>]*` stops at
96
+ # the first `>` in the tag whether or not it is inside quotes, so measured on
97
+ # `<meta name="description" content="Beans > 5 AED">` all three shipped
98
+ # packages emit `<meta name="description" content="New"> 5 AED">` — half an
99
+ # element replaced and the other half left on the page as text. `Home > Shop`
100
+ # is a breadcrumb and `Beans > 5 AED` is a price; neither is exotic, and the
101
+ # `<img>` form of the same bug emits two `alt=` attributes in one tag.
102
+ #
103
+ # The alternation is unambiguous at every position — a `"` can only enter the
104
+ # first branch, a `'` only the second, anything else only the third — so it
105
+ # does not backtrack and stays linear on a page-sized document. That matters:
106
+ # the fixture carries a ~9.7 KB case precisely because a pattern that
107
+ # backtracks super-linearly is invisible at 168 bytes and a stalled worker at
108
+ # 100 KB.
109
+ ATTRS = %q{(?:"[^"]*"|'[^']*'|[^>"'])*}
110
+
111
+ # Where a tag name ends. `` is not this test: it is ASCII in JavaScript
112
+ # and PCRE and Unicode here and in Python, so `<titleé>` was rewritten as a
113
+ # title by Node and PHP and refused by this file and the reference - two
114
+ # implementations writing into an element that does not exist. A tag name
115
+ # ends at whitespace, `/` or `>`, which is the same sentence in every
116
+ # language. Spelled the same way in all four since 2 Sep 2026: this file
117
+ # already gave the right answer, and a right answer reached by a different
118
+ # spelling is the next divergence waiting to happen (§20.1 rule 2).
119
+ NAME_END = '(?=[\s/>])'
120
+
121
+ # An HTML comment, matched so its interior can be blanked before the head is
122
+ # located. Non-greedy; the replacement preserves the character length.
123
+ COMMENT = /<!--[\s\S]*?-->/
124
+
125
+ # Where an attribute name is allowed to begin: at the start of the tag or
126
+ # after whitespace, never after a hyphen, an underscore, a colon or another
127
+ # word character.
128
+ #
129
+ # `\b` is not that test. It puts the boundary *after* the hyphen, so `\balt`
130
+ # matched `data-alt`, `\bsrc` matched `data-src` and `\bname` matched
131
+ # `data-name`. All three were measured on markup real pages ship:
132
+ #
133
+ # * `<img src="/a.jpg" data-alt="lazy">` had our text written into the
134
+ # *placeholder*. The image still went out with no `alt`, the accessibility
135
+ # finding the fix exists for was untouched, and the fix reported as applied.
136
+ # * `<img data-src="/a.jpg" src="/spacer.gif">` was keyed by the lazy URL, so
137
+ # the description of a photograph nobody has loaded yet was written onto
138
+ # the visible spacer GIF — the wrong caption on the wrong image, and on the
139
+ # one image a reader's screen reader will actually reach.
140
+ # * `<meta data-name="description" content="Old">` was read as the page's own
141
+ # description and *replaced*, deleting both the attribute and the content
142
+ # while the real description stayed missing.
143
+ #
144
+ # Lazy-loading markup is on most pages we will ever rewrite. This is the
145
+ # ordinary case, not the exotic one.
146
+ # Where an attribute name may begin - stated positively since 2 Sep 2026.
147
+ # The negative form contained `\w`, which matches `é` and Arabic letters
148
+ # here and in Python and does not in JavaScript or PCRE without `/u`.
149
+ # Measured: `<img éalt="placeholder" src="/a.jpg">` had the caption written
150
+ # into `éalt` by Node and PHP while this file refused. Same answer, said in
151
+ # a way that cannot be read four ways.
152
+ ATTR_START = '(?<=[\s"\'])'
153
+
154
+ # `<head ...>`: any casing, any attributes. `<head profile=…>` is what every
155
+ # WordPress theme descended from the 2009 starter kit emits and
156
+ # `<head prefix="og: …">` is what an Open Graph plugin adds, so a finder
157
+ # matching the literal `<head>` rewrites nothing on precisely the sites we
158
+ # sell Open Graph fixes to.
159
+ HEAD_OPEN = /<head#{NAME_END}#{ATTRS}>/i
160
+
161
+ # The head's own closing tag, searched for **in the original string**.
162
+ #
163
+ # Never `out.downcase.index('</head>')`: that is an offset into a string
164
+ # which is not the one being sliced. `'İ'.downcase` (U+0130, the Turkish
165
+ # capital I with a dot) is two characters in Ruby — as it is in Python and in
166
+ # JavaScript — so one such letter in a title shifts every later offset by one
167
+ # and an appended tag lands as `<title>İstanbul</title><<meta …>/head>`:
168
+ # markup corrupted by a Turkish word. A GCC/Arabic product (§14) meets
169
+ # non-ASCII heads as routine, so this was never an exotic input.
170
+ #
171
+ # `\s*` because `</head >` closes a head too, and a literal lookup that
172
+ # misses it does not merely skip the append — `body_start` stays at zero and
173
+ # every body edit then runs over the head as well.
174
+ HEAD_CLOSE = %r{</head\s*>}i
175
+
176
+ IMG = /<img\b#{ATTRS}>/i
177
+ SRC = /#{ATTR_START}src\s*=\s*["']([^"']+)["']/i
178
+ ALT = /#{ATTR_START}alt\s*=\s*["'][^"']*["']/i
179
+
180
+ # `&` first, always. Escaping it last turns the `&lt;` just written into
181
+ # `&amp;lt;`.
182
+ #
183
+ # One pass over a character class rather than four chained `gsub`s, and the
184
+ # two are equivalent: the chain is order-dependent only because a later pass
185
+ # can re-read what an earlier one wrote, and a single pass reads each
186
+ # original character exactly once and never revisits its replacement. It is
187
+ # also the form that cannot be got wrong by a later edit reordering the
188
+ # chain.
189
+ #
190
+ # **A Hash replacement interprets nothing.** `gsub(re, '\&')` would splice
191
+ # the match back in; `gsub(re, hash)` has no replacement grammar at all,
192
+ # which is the same guarantee the block form gives everywhere else here.
193
+ ESCAPES = { '&' => '&amp;', '<' => '&lt;', '>' => '&gt;', '"' => '&quot;' }.freeze
194
+ ESCAPABLE = /[&<>"]/.freeze
195
+
196
+ # Escapes for both attribute and text context. Identical to the Worker's `esc`.
197
+ #
198
+ # An apostrophe is deliberately left alone: escaping it is visible to a
199
+ # reader — `Dubai&#39;s` in a SERP snippet is a bug a client reports — and it
200
+ # is safe unescaped only because `render_tag` writes double quotes.
201
+ def self.esc(value)
202
+ value.to_s.gsub(ESCAPABLE, ESCAPES)
203
+ end
204
+
205
+ # The pattern that finds one field's element, built once at load.
206
+ #
207
+ # Per-request `Regexp.new` on nine fields is a compile per field per page.
208
+ # Built here instead, keyed by the field's own key, because a table and a
209
+ # cache that can disagree about which pattern belongs to which field is
210
+ # §20.1 rule 2 in miniature.
211
+ # Both branches build through `Regexp.new` rather than one of them through a
212
+ # `/…/` literal. `return /<#{tag}…/i if cond` is legal and reads as a
213
+ # division to about half the people who meet it, and the two patterns are
214
+ # easier to compare when they are written the same way.
215
+ def self.element_pattern(field)
216
+ if field.match_attribute.empty?
217
+ return Regexp.new("<#{field.tag}\\b#{ATTRS}>[\\s\\S]*?<\\/#{field.tag}>",
218
+ Regexp::IGNORECASE)
219
+ end
220
+
221
+ Regexp.new(
222
+ "<#{field.tag}#{NAME_END}#{ATTRS}#{ATTR_START}#{Regexp.escape(field.match_attribute)}" \
223
+ "\\s*=\\s*[\"']#{Regexp.escape(field.match_value)}[\"']#{ATTRS}>",
224
+ Regexp::IGNORECASE
225
+ )
226
+ end
227
+
228
+ ELEMENT_PATTERNS = HEAD_FIELDS.to_h { |field| [field.key, element_pattern(field)] }.freeze
229
+
230
+ # `(<tag …>)(text)(</tag>)`. `[\s\S]*?` and never `(.*?)`: `.` stops at a
231
+ # newline and a template engine indents.
232
+ def self.inner_pattern(tag)
233
+ Regexp.new("(<#{tag}\\b#{ATTRS}>)([\\s\\S]*?)(<\\/#{tag}>)", Regexp::IGNORECASE)
234
+ end
235
+
236
+ INNER_PATTERNS = { 'title' => inner_pattern('title'), 'h1' => inner_pattern('h1') }.freeze
237
+
238
+ # Double quotes around every value, always. Single-quoted attributes parse
239
+ # just as well, and nothing but this decides which of the two we emit — a
240
+ # value carrying an apostrophe is safe only because the quote is a double
241
+ # one, which is why `esc` leaves apostrophes alone.
242
+ def self.render_tag(field, value)
243
+ return "<#{field.tag}>#{esc(value)}</#{field.tag}>" if field.match_attribute.empty?
244
+
245
+ found_by = %(#{field.match_attribute}="#{esc(field.match_value)}")
246
+ written = %(#{field.attribute}="#{esc(value)}")
247
+ "<#{field.tag} #{found_by} #{written}>"
248
+ end
249
+
250
+ # Replace an element's inner text, keeping the opening tag's attributes.
251
+ #
252
+ # **Returns the input unchanged when the element is absent, and that is the
253
+ # single most dangerous line in this file to write in Ruby.** The idiomatic
254
+ # `out.sub!(pattern) { ... }` returns `nil` on a miss, so `out = out.sub!(…)`
255
+ # serves an empty page the first time a rule names an `h1` a page does not
256
+ # have — a page with no heading is not rare, it is most pages. PHP's
257
+ # `preg_replace` returns the subject untouched in the same situation and is
258
+ # fine, which is exactly why this has to be pinned rather than assumed.
259
+ #
260
+ # Sliced rather than substituted so that the *first* match is the one
261
+ # replaced and the value is never read as a replacement pattern.
262
+ def self.replace_inner(html, tag, value)
263
+ pattern = INNER_PATTERNS[tag] || inner_pattern(tag)
264
+ match = pattern.match(html)
265
+ return html if match.nil?
266
+
267
+ # `match.end(0)` can equal the length, and `String#[]` answers `""` there
268
+ # and `nil` past it. `.to_s` costs nothing on a String — it returns self —
269
+ # and removes a class of nil that Ruby reports as `undefined method for
270
+ # nil` three frames away from the cause.
271
+ html[0, match.begin(0)].to_s + match[1] + esc(value) + match[3] +
272
+ html[match.end(0)..-1].to_s
273
+ end
274
+
275
+ # The document with every comment's interior replaced by spaces.
276
+ #
277
+ # **Used only to *locate* the head, never to produce output.** The string
278
+ # returned is the same character length as the one passed in, so an index
279
+ # found in it is valid in the original - which is the whole technique, and
280
+ # the same discipline the rest of this file follows by never indexing one
281
+ # string with an offset found in another.
282
+ #
283
+ # A live defect in all four implementations until 2 Sep 2026: `head_start`
284
+ # landed on the first `<head` anywhere in the document, and a conditional
285
+ # comment or a commented-out block puts one there. Measured on
286
+ # `<!-- <head><title>fake</title></head> --><html><head><title>Real</title>`:
287
+ # the fix was written into the COMMENT and the real title left alone. What
288
+ # makes it worse than an ordinary miss is that §22.3 verification re-fetches,
289
+ # greps the raw HTML, finds the new string sitting in the comment, and
290
+ # passes - so the change reports as deployed and no crawler will ever see it.
291
+ # §3 rule 4 defeated by its own evidence.
292
+ #
293
+ # `gsub` with a BLOCK, never a replacement string: a replacement string
294
+ # expands `` and `\&`, and the one thing this method must not do is let a
295
+ # document's own bytes change its length.
296
+ def self.without_comments(html)
297
+ return html unless html.include?('<!--')
298
+
299
+ html.gsub(COMMENT) { |m| "<!--#{' ' * (m.length - 7)}-->" }
300
+ end
301
+
302
+ def self.rewrite_head(head, rule)
303
+ out = head
304
+ appended = +''
305
+
306
+ HEAD_FIELDS.each do |field|
307
+ value = rule[field.key]
308
+ # A value that is not a non-empty String is not a value. An empty string
309
+ # is "no fix", never "empty the field" — the difference between a fix
310
+ # that did not apply and a title we deleted — and a number is a
311
+ # malformed manifest, which §3 rule 7 fails closed rather than writing
312
+ # `<title>2026</title>` onto somebody's homepage.
313
+ next unless value.is_a?(String) && !value.empty?
314
+
315
+ # Presence is always asked of the *original* head. Asking the partially
316
+ # rewritten copy would let one field's inserted tag answer the next
317
+ # field's question, which is how a page ends up with two descriptions.
318
+ pattern = ELEMENT_PATTERNS.fetch(field.key)
319
+ present = pattern.match?(head)
320
+
321
+ if field.match_attribute.empty?
322
+ # The title keeps whatever attributes it had; only its text is ours.
323
+ if present
324
+ out = replace_inner(out, field.tag, value)
325
+ else
326
+ appended << render_tag(field, value)
327
+ end
328
+ next
329
+ end
330
+
331
+ tag = render_tag(field, value)
332
+ if present
333
+ # Block form. A String replacement would read `\&` and `\1` in a
334
+ # customer's description as backreferences and splice the matched tag
335
+ # into the page.
336
+ out = out.sub(pattern) { tag }
337
+ else
338
+ appended << tag
339
+ end
340
+ end
341
+
342
+ appended << hreflang_tags(head, rule['hreflang'])
343
+
344
+ # Additive, always (§9). Never bound to an existing ld+json element and
345
+ # never replacing one — overwriting somebody's Product block is a price we
346
+ # invented and their liability. Never escaped either: a JSON-LD block is
347
+ # script content, not markup, and `&amp;` inside it is a parse error
348
+ # rather than a safety measure. The two emitters either side of this line
349
+ # have opposite rules on purpose.
350
+ json_ld = rule['json_ld_additive']
351
+ if json_ld.is_a?(String) && !json_ld.empty?
352
+ appended << %(<script type="application/ld+json">#{json_ld}</script>)
353
+ end
354
+
355
+ out + appended
356
+ end
357
+
358
+ # The alternates worth emitting, as markup.
359
+ #
360
+ # Every guard here skips the *entry* and keeps the rest, and that is the
361
+ # whole point: a raise is caught upstream, counted as `rewrite_raised` and
362
+ # the page served untouched — so one malformed entry in one page's rule
363
+ # would turn off every rewrite on the site while `/status` named no rule.
364
+ # Measured, the Node package throws `value.replace is not a function` on a
365
+ # non-string `lang`; Python does not raise and is not therefore right, since
366
+ # it emits `hreflang="7"`, an invalid language tag. §3 rule 7, entry by
367
+ # entry and value by value.
368
+ def self.hreflang_tags(head, alternates)
369
+ out = +''
370
+ return out unless alternates.is_a?(Array)
371
+
372
+ alternates.each do |alternate|
373
+ next unless alternate.is_a?(Hash)
374
+
375
+ lang = alternate['lang']
376
+ href = alternate['href']
377
+ next unless lang.is_a?(String) && !lang.empty?
378
+ next unless href.is_a?(String) && !href.empty?
379
+ # A multilingual plugin may already have emitted this language. A second
380
+ # tag for one language is worse than none, so skip rather than
381
+ # duplicate — and case-insensitively, because BCP 47 is: `AR-ae` and
382
+ # `ar-AE` are the same language to every consumer of the tag.
383
+ # `ATTR_START` here for the reason it is everywhere else, and this one
384
+ # was measured propagating: Node carried the guard, the Python reference
385
+ # did not, and this port inherited the defect from the copy that never
386
+ # learned it - `<link data-hreflang="ar-AE">` suppressed the alternate
387
+ # here and not there (§20.1 rule 2, caught mid-propagation).
388
+ next if /#{ATTR_START}hreflang\s*=\s*["']#{Regexp.escape(lang)}["']/i.match?(head)
389
+
390
+ out << %(<link rel="alternate" hreflang="#{esc(lang)}" href="#{esc(href)}">)
391
+ end
392
+ out
393
+ end
394
+
395
+ def self.rewrite_body(body, rule)
396
+ out = body
397
+
398
+ heading = rule['h1']
399
+ out = replace_inner(out, 'h1', heading) if heading.is_a?(String) && !heading.empty?
400
+
401
+ alts = rule['image_alt']
402
+ return out unless alts.is_a?(Hash)
403
+
404
+ out.gsub(IMG) do |tag|
405
+ src = SRC.match(tag)
406
+ # `MatchData` is `nil`, not `false` and not an exception. An `<img>` with
407
+ # no `src` — a template placeholder, a `<img srcset>` — is left alone.
408
+ next tag if src.nil?
409
+
410
+ # **An exact key, never `src.include?(key)`.** `/a.jpg` is a suffix of
411
+ # `/images/a.jpg`, so a substring lookup captions the roastery counter as
412
+ # a single bean; and the key is the attribute value byte for byte, so an
413
+ # implementation that helpfully strips a resize query or resolves a CDN
414
+ # host looks the rule up under a key the engine never wrote. `Hash#[]`
415
+ # answers `nil` for an unkeyed image, which is left exactly as it was —
416
+ # falling back to the first value in the table would write a confident
417
+ # caption about a different picture.
418
+ alt = alts[src[1]]
419
+ next tag unless alt.is_a?(String) && !alt.empty?
420
+
421
+ if ALT.match?(tag)
422
+ tag.sub(ALT) { %(alt="#{esc(alt)}") }
423
+ elsif tag.end_with?('/>')
424
+ # XHTML. Rails, Blade and Twig emit these constantly, and appending
425
+ # before the slash produces `<img src="/b.jpg" / alt="…">`, which no
426
+ # parser reads as an image at all. The space before `/>` is inserted
427
+ # rather than preserved, so `"/c.jpg"/>` comes back well formed too.
428
+ %(#{tag[0..-3].rstrip} alt="#{esc(alt)}" />)
429
+ else
430
+ %(#{tag[0..-2].rstrip} alt="#{esc(alt)}">)
431
+ end
432
+ end
433
+ end
434
+
435
+ # Apply one page's rules to one HTML document.
436
+ #
437
+ # Returns the input unchanged when there is nothing to do — an empty rule
438
+ # must never read as "blank the field", which is the difference between a fix
439
+ # that did not apply and a page we emptied.
440
+ #
441
+ # The guard is "a non-empty Hash", not "not nil". A manifest arrives over
442
+ # HTTP, so the value here is whatever was sent: `[]` is falsy in the other
443
+ # implementations and is not a mapping, and a guard written `rule.nil?`
444
+ # reaches `rule['title']` on an Array, which raises `TypeError: no implicit
445
+ # conversion of String into Integer` — caught upstream, counted as
446
+ # `rewrite_raised`, and every page on the site served untouched over one bad
447
+ # rule.
448
+ def self.transform(html, rule)
449
+ return html unless rule.is_a?(Hash) && !rule.empty?
450
+
451
+ out = html
452
+
453
+ # **One lookup for `</head>`, anchored at the head's own open tag**, and
454
+ # one index deciding both slices. There were two — one from the open tag
455
+ # to cut the head, one from position zero to decide where the body begins
456
+ # — and they disagreed the moment the string `</head>` appeared earlier in
457
+ # the document, in a comment or a conditional comment. The body slice then
458
+ # began *before* the head, so every body edit ran over the head as well:
459
+ # measured, `<!-- </head> --><html><head><noscript><img src="/a.jpg">…`
460
+ # had the head's own image rewritten, undoing the rule that leaves images
461
+ # in a head alone. §20.1 rule 2 in miniature — one question, two
462
+ # implementations, and only one of them was ever taught where a head ends.
463
+ body_start = 0
464
+ # Located against the masked copy, sliced out of the real one. Same
465
+ # character length, so the offsets carry over - see `without_comments`.
466
+ masked = without_comments(out)
467
+ open_tag = HEAD_OPEN.match(masked)
468
+ unless open_tag.nil?
469
+ head_start = open_tag.end(0)
470
+ closing = HEAD_CLOSE.match(masked, head_start)
471
+ unless closing.nil?
472
+ head = rewrite_head(out[head_start...closing.begin(0)].to_s, rule)
473
+ out = out[0, head_start].to_s + head + out[closing.begin(0)..-1].to_s
474
+ # The rewritten head is a different length from the one that was
475
+ # measured, so the body slice cannot reuse `closing.begin(0)`. It is
476
+ # the same character: the new head ends exactly where it now sits.
477
+ # Counted in characters, like every other offset in this file.
478
+ body_start = head_start + head.length
479
+ end
480
+ end
481
+
482
+ # Body edits still run on a document with no head, and on one whose head is
483
+ # never closed: a fragment rendered by a partial still has an h1 worth
484
+ # correcting, and the head branch above simply had nothing to attach to.
485
+ out[0, body_start].to_s + rewrite_body(out[body_start..-1].to_s, rule)
486
+ end
487
+
488
+ # Trimmed from both ends of a URL before it is read as a key: U+0000-U+0020,
489
+ # which is every C0 control plus the space.
490
+ #
491
+ # **Trim the ends, preserve the middle**, and the asymmetry is the whole
492
+ # rule. A space or a tab wrapped around a path is somebody's typo in a
493
+ # hand-written manifest and never part of the path, so removing it files the
494
+ # rule where the author meant it. A control character *inside* a path is the
495
+ # opposite case: no HTTP request target may carry a raw tab, CR or LF, so a
496
+ # path with one is a key no request can ever produce — and removing the
497
+ # newline would file that rule under `/beans`, rewriting a real page from a
498
+ # manifest entry nobody approved for it. Left alone it matches nothing,
499
+ # which is the direction §3 rule 7 asks a write to fail in.
500
+ #
501
+ # `String#strip` is **not** this set. It removes NUL, tab, newline, vertical
502
+ # tab, form feed, carriage return and space, and leaves U+0001-U+0008 and
503
+ # U+000E-U+001F where they are — so a path wrapped in a stray U+0001 would
504
+ # key differently here than in the engine, which is the one thing this
505
+ # function exists not to do.
506
+ TRIM_ENDS = /\A[\x00-\x20]+|[\x00-\x20]+\z/
507
+
508
+ def self.trim_ends(value)
509
+ value.gsub(TRIM_ENDS) { '' }
510
+ end
511
+
512
+ # The same string, in an encoding the lookup and the patterns can use.
513
+ #
514
+ # **A manifest key is UTF-8 and a request path is bytes, and Ruby will not
515
+ # call those equal.** `JSON.parse` tags every key UTF-8; the Rack SPEC has
516
+ # request values arrive as ASCII-8BIT. For an ASCII path the two compare
517
+ # equal and nothing is wrong. For `/café` — or any Arabic path, which is our
518
+ # market (§14) — `"/café".b.eql?("/café")` is **false**, so `pages[key]`
519
+ # misses, the page is served untouched, no counter moves and `/status`
520
+ # reports health. That is the `normalise_path` disagreement all over again,
521
+ # in one language rather than four, and it would have been invisible for the
522
+ # same reason.
523
+ #
524
+ # Bytes that are not valid UTF-8 are left as bytes rather than re-tagged:
525
+ # they cannot equal a manifest key whatever we do, and a UTF-8 string
526
+ # holding invalid bytes makes every `Regexp` below raise
527
+ # `ArgumentError: invalid byte sequence` — on the read path, which is a 500
528
+ # on the customer's homepage over a rule key.
529
+ def self.readable(url)
530
+ string = url.to_s
531
+ return string if string.encoding == ::Encoding::UTF_8 && string.valid_encoding?
532
+
533
+ candidate = string.dup.force_encoding(::Encoding::UTF_8)
534
+ candidate.valid_encoding? ? candidate : string.dup.force_encoding(::Encoding::BINARY)
535
+ end
536
+
537
+ # The rules table's key for a URL or a request path: the path, and nothing
538
+ # else.
539
+ #
540
+ # No scheme, no host, no query, no fragment, no trailing slash; `""` and
541
+ # `"/"` are both `"/"`; the result always begins with `/`. Percent-escapes
542
+ # are neither decoded nor encoded — `/caf%C3%A9` and `/café` are different
543
+ # keys to a request router, and translating one into the other files a rule
544
+ # under a path no request will ever produce.
545
+ #
546
+ # **This is the one function four implementations have to compute
547
+ # identically, and until 1 Sep 2026 they did not.** The engine files keys
548
+ # from an absolute URL, because that is what a `Fix` carries, while the Node
549
+ # and Python middlewares stripped the query, the fragment and a trailing
550
+ # slash and never touched the scheme and host — under a comment that said
551
+ # "no host". So the engine filed `/beans` and a middleware asked for
552
+ # `https://shop.example/beans`. Seventeen inputs are pinned in
553
+ # `middleware/conformance/cases.json` under `paths` and every suite runs
554
+ # them; nine of the seventeen disagreed.
555
+ #
556
+ # **The failure this produces has no symptom, which is why it survived.** A
557
+ # key that does not match is not an error anywhere: `Store#rule_for` returns
558
+ # nil, the response is passed through untouched, nothing calls
559
+ # `Store#record`, so `considered` stays at zero — and `degraded_reason`
560
+ # reports a problem only when `considered > 0`. A site with a full manifest
561
+ # and not one rule landing therefore reports *healthy* (§20.1 rule 3:
562
+ # nothing examined read as nothing wrong).
563
+ #
564
+ # Hand-split rather than handed to `URI.parse`, which is the Ruby equivalent
565
+ # of the trap the Node comment describes for `new URL()`: `URI.parse` raises
566
+ # `URI::InvalidURIError` on inputs a stranger can send (`//[`, a raw space, a
567
+ # `|`), and this runs on the read path before the application is reached, so
568
+ # a raise here is a 500 on the customer's site over a rule key. It also
569
+ # re-encodes and resolves `.`/`..`, and a rule key must be what the engine
570
+ # saw rather than what it could reduce.
571
+ def self.normalise_path(url)
572
+ # Trimmed once, at the top, so every branch below — the refusal
573
+ # included — answers about the same string.
574
+ raw = trim_ends(readable(url))
575
+ rest = raw
576
+ # Fragment first, then query: a fragment may itself contain a `?`
577
+ # (`/beans#a?b`), and splitting on `?` first leaves `a` behind in the key.
578
+ #
579
+ # `sub` and not `split`: `''.split('#')` is `[]`, so `[0]` is `nil` rather
580
+ # than `''`, and `""` is one of the pinned inputs. `[\s\S]` and not `.`
581
+ # for the same reason as everywhere else in this file.
582
+ rest = rest.sub(/#[\s\S]*\z/) { '' }
583
+ rest = rest.sub(/\?[\s\S]*\z/) { '' }
584
+ # A scheme is one letter followed by letters, digits, `+`, `-` or `.`, then
585
+ # a colon (RFC 3986 §3.1). Nothing else before a colon qualifies, so
586
+ # `/beans:roast` keeps its colon and stays an ordinary path.
587
+ # Case-insensitive because `HTTPS://SHOP.EXAMPLE/Beans` is a shape
588
+ # manifests really carry, and its key must come back `/Beans` with the
589
+ # path's own casing intact — a scheme and a host are case-insensitive, a
590
+ # path is not.
591
+ rest = rest.sub(/\A[a-zA-Z][a-zA-Z0-9+.\-]*:/) { '' }
592
+ if rest.start_with?('//')
593
+ # What is left of an absolute URL once its scheme is gone, or a
594
+ # protocol-relative URL written that way. The authority ends at the
595
+ # first `/` after it; the query and fragment that could otherwise end it
596
+ # are already gone. `String#index` answers `nil`, never `-1`.
597
+ authority = rest[2..-1].to_s.split('/', 2).first.to_s
598
+
599
+ # **A key we cannot compute must never come out as `/`.** Square
600
+ # brackets have to be balanced and the first `]` has to follow the first
601
+ # `[`; `//[` and `//[::1` have no closing bracket and `//]x[` has them
602
+ # the wrong way round. Under the surgery below all three end at "no `/`
603
+ # after the authority", which produces `""` and then `/` — the
604
+ # homepage's rule handed to a malformed request any stranger can send.
605
+ # Refused by returning the trimmed input instead: a string no rule is
606
+ # ever filed under and no request router will match, so it matches
607
+ # nothing and looks like nothing. `//[::1]/beans` is a real IPv6
608
+ # authority and parses normally.
609
+ if authority.count('[') != authority.count(']') ||
610
+ (authority.include?(']') && authority.index(']').to_i < authority.index('[').to_i)
611
+ return raw
612
+ end
613
+
614
+ slash = rest.index('/', 2)
615
+ rest = slash.nil? ? '' : rest[slash..-1].to_s
616
+ end
617
+ trimmed = rest.sub(/\/+\z/) { '' }
618
+ return '/' if trimmed.empty?
619
+
620
+ # A key is a path, and a path starts at the root. `beans` used to key as
621
+ # `/` in the engine — a fake origin prefix swallowed it as a hostname — so
622
+ # a relative path collapsed onto the homepage and a fix for one page
623
+ # rewrote another. Normalised rather than filed under a key no request can
624
+ # produce.
625
+ trimmed.start_with?('/') ? trimmed : "/#{trimmed}"
626
+ end
627
+ end
628
+ end