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.
- checksums.yaml +7 -0
- data/README.md +325 -0
- data/exe/findxpand +20 -0
- data/findxpand.gemspec +71 -0
- data/lib/findxpand/admin.rb +157 -0
- data/lib/findxpand/auto.rb +607 -0
- data/lib/findxpand/cli.rb +214 -0
- data/lib/findxpand/encoding.rb +160 -0
- data/lib/findxpand/middleware.rb +597 -0
- data/lib/findxpand/railtie.rb +72 -0
- data/lib/findxpand/rewrite.rb +628 -0
- data/lib/findxpand/signature.rb +127 -0
- data/lib/findxpand/store.rb +657 -0
- data/lib/findxpand.rb +255 -0
- metadata +62 -0
|
@@ -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 `<` just written into
|
|
181
|
+
# `&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 = { '&' => '&', '<' => '<', '>' => '>', '"' => '"' }.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'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 `&` 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
|