dialecto-rails 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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +196 -0
  4. data/action.yml +83 -0
  5. data/exe/dialecto-rails +6 -0
  6. data/lib/dialecto-rails.rb +1 -0
  7. data/lib/dialecto_rails/config.rb +206 -0
  8. data/lib/dialecto_rails/errors.rb +21 -0
  9. data/lib/dialecto_rails/in_context/args.rb +37 -0
  10. data/lib/dialecto_rails/in_context/assets/overlay.js +2784 -0
  11. data/lib/dialecto_rails/in_context/backend_hook.rb +39 -0
  12. data/lib/dialecto_rails/in_context/csp.rb +72 -0
  13. data/lib/dialecto_rails/in_context/git_context.rb +103 -0
  14. data/lib/dialecto_rails/in_context/key_index.rb +128 -0
  15. data/lib/dialecto_rails/in_context/launch_token.rb +78 -0
  16. data/lib/dialecto_rails/in_context/live_middleware.rb +307 -0
  17. data/lib/dialecto_rails/in_context/live_runtime.rb +53 -0
  18. data/lib/dialecto_rails/in_context/live_store.rb +14 -0
  19. data/lib/dialecto_rails/in_context/marker.rb +106 -0
  20. data/lib/dialecto_rails/in_context/marking.rb +122 -0
  21. data/lib/dialecto_rails/in_context/middleware.rb +202 -0
  22. data/lib/dialecto_rails/in_context/overrides.rb +61 -0
  23. data/lib/dialecto_rails/in_context/railtie_stub.rb +76 -0
  24. data/lib/dialecto_rails/in_context/runtime.rb +52 -0
  25. data/lib/dialecto_rails/in_context/sanitizer.rb +19 -0
  26. data/lib/dialecto_rails/in_context/store.rb +41 -0
  27. data/lib/dialecto_rails/scan/catalogs.rb +46 -0
  28. data/lib/dialecto_rails/scan/cli.rb +339 -0
  29. data/lib/dialecto_rails/scan/client.rb +117 -0
  30. data/lib/dialecto_rails/scan/envelope.rb +100 -0
  31. data/lib/dialecto_rails/scan/git.rb +68 -0
  32. data/lib/dialecto_rails/scan/worktree.rb +41 -0
  33. data/lib/dialecto_rails/unmarked.rb +42 -0
  34. data/lib/dialecto_rails/usages/call_args.rb +78 -0
  35. data/lib/dialecto_rails/usages/catalog_keys.rb +67 -0
  36. data/lib/dialecto_rails/usages/check.rb +70 -0
  37. data/lib/dialecto_rails/usages/haml_extractor.rb +260 -0
  38. data/lib/dialecto_rails/usages/keys.rb +206 -0
  39. data/lib/dialecto_rails/usages/known_wrappers.rb +31 -0
  40. data/lib/dialecto_rails/usages/lazy_scope.rb +52 -0
  41. data/lib/dialecto_rails/usages/parse_check.rb +75 -0
  42. data/lib/dialecto_rails/usages/read_by_name.json +28 -0
  43. data/lib/dialecto_rails/usages/record.rb +91 -0
  44. data/lib/dialecto_rails/usages/ruby_visitor.rb +628 -0
  45. data/lib/dialecto_rails/usages/scanner.rb +241 -0
  46. data/lib/dialecto_rails/usages/summary.rb +191 -0
  47. data/lib/dialecto_rails/usages/templates.rb +45 -0
  48. data/lib/dialecto_rails/usages/wrappers.rb +336 -0
  49. data/lib/dialecto_rails/version.rb +3 -0
  50. data/lib/dialecto_rails.rb +14 -0
  51. metadata +119 -0
@@ -0,0 +1,307 @@
1
+ require "cgi"
2
+ require "json"
3
+ require_relative "../config"
4
+ require_relative "../unmarked"
5
+ require_relative "../version"
6
+ require_relative "launch_token"
7
+ require_relative "csp"
8
+
9
+ module DialectoRails
10
+ module InContext
11
+ # Live-site editing for the Rack stack (spec-0025 §4): inert for visitors, on only for an editor who
12
+ # arrived with a launch link. Insert it after the session middleware.
13
+ #
14
+ # * Start: a `GET` with `?dialecto_edit=<token>` is verified (signature, `start_by`, origin). A good token
15
+ # becomes the `dialecto_edit` cookie (`HttpOnly; Secure; SameSite=Lax; Path=/`, `Max-Age` to `exp`). Either
16
+ # way the answer is `303` to this request's own path and query without the parameter. The session is never
17
+ # touched: nothing reads a copy, and a token in a cookie-store session risks CookieOverflow.
18
+ # * Each request: a valid cookie marks a document `GET` or `HEAD` (the thread's flag is on while the app
19
+ # renders), injects the overlay loader and sets `Cache-Control: private, no-store`, `Vary: Cookie` and
20
+ # `X-Robots-Tag: noindex`, and widens `frame-src` to Dialecto. An invalid cookie is cleared. A visitor's
21
+ # request goes to the app untouched.
22
+ # * `POST /__dialecto/stop` clears the cookie.
23
+ #
24
+ # `/__dialecto/context`, `/overrides` and `/marking` do not exist here: they answer 404, and nothing in
25
+ # this class consults the development store, the loopback checks, or any header for the origin.
26
+ class LiveMiddleware
27
+ PREFIX = "/__dialecto".freeze
28
+ PARAM = "dialecto_edit".freeze
29
+ COOKIE = "dialecto_edit".freeze
30
+ OVERLAY = File.expand_path("assets/overlay.js", __dir__)
31
+ JSON_HEADERS = {"content-type" => "application/json", "cache-control" => "no-store"}.freeze
32
+
33
+ # `settings`: a DialectoRails::Config with live mode on. `clock` returns Unix seconds.
34
+ def initialize(app, settings:, logger: nil)
35
+ @app = app
36
+ @settings = settings
37
+ @logger = logger
38
+ @origin = settings.origin
39
+ @key = settings.public_key
40
+ @clock = settings.clock
41
+ end
42
+
43
+ def call(env)
44
+ # One thread serves many requests: never inherit a flag.
45
+ ::DialectoRails.live_marking = false
46
+
47
+ early = intercept(env)
48
+ return early if early
49
+
50
+ cookie, valid = session_state(env)
51
+ return @app.call(env) unless cookie
52
+
53
+ valid ? editor(env, cookie, valid) : visitor_with_stale_cookie(env)
54
+ end
55
+
56
+ private
57
+
58
+ ## Answers that never reach the app
59
+
60
+ def intercept(env)
61
+ path = env["PATH_INFO"].to_s
62
+ return overlay(env) if path == "#{PREFIX}/overlay.js"
63
+ return stop(env) if path == "#{PREFIX}/stop"
64
+ return json(404, error: "not_found") if path == PREFIX || path.start_with?("#{PREFIX}/")
65
+
66
+ token = start_token(env)
67
+ token ? start(env, token) : nil
68
+ rescue => error
69
+ @logger&.debug("dialecto-rails: live request skipped (#{error.class})")
70
+ nil
71
+ end
72
+
73
+ ## 1. Start
74
+
75
+ def start_token(env)
76
+ return nil unless %w[GET HEAD].include?(env["REQUEST_METHOD"]) && !env["QUERY_STRING"].to_s.empty?
77
+
78
+ env["QUERY_STRING"].split("&").each do |pair|
79
+ name, value = pair.split("=", 2)
80
+ return CGI.unescape(value.to_s) if CGI.unescape(name.to_s) == PARAM
81
+ end
82
+ nil
83
+ end
84
+
85
+ def start(env, token)
86
+ headers = {"location" => own_url_without_param(env), "cache-control" => "private, no-store", "content-length" => "0"}
87
+ begin
88
+ now = @clock.call
89
+ result = verify(token, now, :start)
90
+ if result.ok?
91
+ cookie = "#{COOKIE}=#{token}; path=/; max-age=#{result.claims["exp"] - now}; secure; httponly; samesite=lax"
92
+ add_set_cookie(headers, cookie)
93
+ else
94
+ @logger&.info("dialecto-rails: launch link refused (#{result.reason})")
95
+ end
96
+ rescue => error
97
+ # Whatever goes wrong, the answer still takes the token out of the address bar and the Referer.
98
+ headers.delete("set-cookie")
99
+ @logger&.debug("dialecto-rails: launch link not started (#{error.class})")
100
+ end
101
+ [303, headers, []]
102
+ end
103
+
104
+ # This request's own path and query, never a URL taken from input. A path that a browser could read as
105
+ # another host ("//host", "/\host") is collapsed to the root.
106
+ def own_url_without_param(env)
107
+ path = "#{env["SCRIPT_NAME"]}#{env["PATH_INFO"]}"
108
+ path = "/" unless path.match?(%r{\A/(?![/\\])[^\\\x00-\x1f\x7f]*\z})
109
+ query = env["QUERY_STRING"].to_s.split("&").reject { |pair| CGI.unescape(pair.split("=", 2).first.to_s) == PARAM }.join("&")
110
+ query.empty? ? path : "#{path}?#{query}"
111
+ end
112
+
113
+ ## 2. Each request
114
+
115
+ # `[cookie, claims]`: the cookie's token and its verified claims, or `[token, nil]` when invalid.
116
+ def session_state(env)
117
+ token = cookies(env)[COOKIE]
118
+ return nil unless token
119
+
120
+ result = verify(token, @clock.call, :session)
121
+ [token, result.ok? ? result.claims : nil]
122
+ end
123
+
124
+ def verify(token, now, phase) = LaunchToken.verify(token, @key, @origin, now, phase, repo: @settings.repo)
125
+
126
+ def cookies(env)
127
+ env["HTTP_COOKIE"].to_s.split(/;\s*/).each_with_object({}) do |pair, found|
128
+ name, value = pair.split("=", 2)
129
+ found[name] ||= value
130
+ end
131
+ end
132
+
133
+ def editor(env, _token, claims)
134
+ marked = marking_request?(env)
135
+ ::DialectoRails.live_marking = marked
136
+ response = begin
137
+ @app.call(env)
138
+ ensure
139
+ ::DialectoRails.live_marking = false
140
+ end
141
+ marked ? finish(env, response, claims["exp"]) : response
142
+ end
143
+
144
+ def visitor_with_stale_cookie(env)
145
+ status, headers, body = @app.call(env)
146
+ headers = headers.dup
147
+ add_set_cookie(headers, "#{COOKIE}=; path=/; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT; secure; httponly; samesite=lax")
148
+ [status, headers, body]
149
+ end
150
+
151
+ # Navigations (a document or an iframe) and Turbo-style visits (a same-origin fetch that asks for HTML),
152
+ # never forms, cross-site fetches or JSON.
153
+ def marking_request?(env)
154
+ %w[GET HEAD].include?(env["REQUEST_METHOD"]) && navigation?(env) &&
155
+ env["HTTP_ACCEPT"].to_s.include?("text/html") && path_allowed?(env["PATH_INFO"].to_s)
156
+ end
157
+
158
+ def navigation?(env)
159
+ case env["HTTP_SEC_FETCH_DEST"]
160
+ when "document", "iframe" then true
161
+ when "empty" then env["HTTP_SEC_FETCH_SITE"] == "same-origin"
162
+ else false
163
+ end
164
+ end
165
+
166
+ def path_allowed?(path)
167
+ prefixes = @settings.paths
168
+ return true if prefixes.nil?
169
+
170
+ prefixes.any? do |prefix|
171
+ base = prefix.chomp("/")
172
+ path == base || path.start_with?(base + "/")
173
+ end
174
+ end
175
+
176
+ # The cache headers come first and depend on nothing after them: a marked page must never reach a
177
+ # shared cache, even when the editor can't be added to it.
178
+ def finish(env, response, exp)
179
+ status, headers, body = response
180
+ headers = headers.dup
181
+ set_header(headers, "cache-control", "private, no-store")
182
+ vary_cookie(headers)
183
+ set_header(headers, "x-robots-tag", "noindex")
184
+ # A validator from a response with markers must never revalidate another.
185
+ headers.delete_if { |name, _| name.casecmp?("etag") || name.casecmp?("last-modified") }
186
+ [status, headers, editor_page(env, headers, body, exp)]
187
+ end
188
+
189
+ # The CSP and the loader. If either fails, the page is served marked and uncached, without the editor.
190
+ def editor_page(env, headers, body, exp)
191
+ widen_csp(headers)
192
+ inject(env, headers, body, exp)
193
+ rescue => error
194
+ @logger&.debug("dialecto-rails: editor not added to a marked response (#{error.class})")
195
+ body
196
+ end
197
+
198
+ # Buffers an HTML page to put the loader before `</head>`; anything else passes through.
199
+ def inject(env, headers, body, exp)
200
+ return body unless header(headers, "content-type").to_s.start_with?("text/html") && header(headers, "content-encoding").nil?
201
+
202
+ html = +""
203
+ body.each { |part| html << part.to_s }
204
+ body.close if body.respond_to?(:close)
205
+ [with_loader(env, headers, html.force_encoding(Encoding::UTF_8), exp)]
206
+ end
207
+
208
+ # Once the body is buffered, a failure must return the buffer, not the spent body.
209
+ def with_loader(env, headers, html, exp)
210
+ at = html.index("</head>")
211
+ return html unless at
212
+
213
+ out = html.dup.insert(at, loader_tag(env, headers, exp))
214
+ key = headers.keys.find { |name| name.casecmp?("content-length") }
215
+ headers[key] = out.bytesize.to_s if key
216
+ out
217
+ rescue => error
218
+ @logger&.debug("dialecto-rails: loader not added (#{error.class})")
219
+ html
220
+ end
221
+
222
+ def loader_tag(env, headers, exp)
223
+ base = "#{env["SCRIPT_NAME"]}#{PREFIX}"
224
+ nonce = Csp.script_nonce(header(headers, "content-security-policy"))
225
+ attributes = {
226
+ "src" => "#{base}/overlay.js?v=#{DialectoRails::VERSION}",
227
+ "data-url" => @settings.url,
228
+ "data-stop" => "#{base}/stop",
229
+ "data-project" => @settings.repo,
230
+ "data-exp" => exp.to_s,
231
+ "nonce" => nonce
232
+ }.compact.map { |name, value| %( #{name}="#{CGI.escapeHTML(value.to_s)}") }.join
233
+ "<script defer#{attributes}></script>"
234
+ end
235
+
236
+ def widen_csp(headers)
237
+ key = headers.keys.find { |name| name.casecmp?("content-security-policy") }
238
+ return unless key
239
+
240
+ origin = Config.normalize_origin(@settings.url) || "https://app.dialecto.eu"
241
+ headers[key] = Csp.widen_frame_src(headers[key], origin)
242
+ end
243
+
244
+ def vary_cookie(headers)
245
+ key = headers.keys.find { |name| name.casecmp?("vary") }
246
+ values = key ? headers[key].to_s.split(",").map(&:strip) : []
247
+ return if values.include?("*") || values.any? { |value| value.casecmp?("cookie") }
248
+
249
+ set_header(headers, "vary", (values + ["Cookie"]).join(", "))
250
+ end
251
+
252
+ ## 3. Stop, the overlay, and the routes that don't exist
253
+
254
+ def stop(env)
255
+ return json(405, error: "method_not_allowed") unless env["REQUEST_METHOD"] == "POST"
256
+ return json(403, error: "forbidden_origin") unless same_origin?(env)
257
+
258
+ headers = {"cache-control" => "no-store", "content-length" => "0"}
259
+ add_set_cookie(headers, "#{COOKIE}=; path=/; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT; secure; httponly; samesite=lax")
260
+ [204, headers, []]
261
+ end
262
+
263
+ def same_origin?(env)
264
+ origin = env["HTTP_ORIGIN"]
265
+ origin.nil? ? env["HTTP_SEC_FETCH_SITE"] == "same-origin" : origin == @origin
266
+ end
267
+
268
+ def overlay(env)
269
+ return json(405, error: "method_not_allowed") unless %w[GET HEAD].include?(env["REQUEST_METHOD"])
270
+ return json(404, error: "not_found") unless File.file?(OVERLAY)
271
+
272
+ source = File.binread(OVERLAY)
273
+ [200, {"content-type" => "text/javascript; charset=utf-8", "cache-control" => "public, max-age=31536000, immutable",
274
+ "x-content-type-options" => "nosniff", "content-length" => source.bytesize.to_s},
275
+ [(env["REQUEST_METHOD"] == "HEAD") ? "" : source]]
276
+ end
277
+
278
+ def json(status, body)
279
+ text = JSON.generate(body)
280
+ [status, JSON_HEADERS.merge("content-length" => text.bytesize.to_s), [text]]
281
+ end
282
+
283
+ ## Headers
284
+
285
+ def header(headers, name)
286
+ key = headers.keys.find { |candidate| candidate.casecmp?(name) }
287
+ key && headers[key]
288
+ end
289
+
290
+ def set_header(headers, name, value)
291
+ headers.delete_if { |key, _| key.casecmp?(name) }
292
+ headers[name] = value
293
+ end
294
+
295
+ def add_set_cookie(headers, cookie)
296
+ key = headers.keys.find { |name| name.casecmp?("set-cookie") } || "set-cookie"
297
+ existing = headers[key]
298
+ headers[key] =
299
+ case existing
300
+ when nil then cookie
301
+ when Array then existing + [cookie]
302
+ else "#{existing}\n#{cookie}"
303
+ end
304
+ end
305
+ end
306
+ end
307
+ end
@@ -0,0 +1,53 @@
1
+ require_relative "../config"
2
+ require_relative "../unmarked"
3
+ require_relative "live_store"
4
+ require_relative "key_index"
5
+ require_relative "marking"
6
+ require_relative "backend_hook"
7
+ require_relative "sanitizer"
8
+ require_relative "live_middleware"
9
+
10
+ module DialectoRails
11
+ module InContext
12
+ # The production machinery of live-site editing, built only after the Railtie has checked that live mode
13
+ # is configured (spec-0025 §4). It holds none of the development pieces: no store of drafts, no git
14
+ # context, no endpoints.
15
+ class LiveRuntime
16
+ # A marked request neither reads nor writes fragments: a cold fragment rendered for an editor would be
17
+ # cached with its markers and served to every visitor.
18
+ module NoFragmentCaching
19
+ def perform_caching = ::DialectoRails.live_marking? ? false : super
20
+ end
21
+
22
+ attr_reader :config, :marking
23
+
24
+ def initialize(root:, config:, logger: nil, sanitizer: Sanitizer.build)
25
+ @config = config
26
+ @logger = logger
27
+ index = KeyIndex.new(root: root, catalog_paths: config.catalog_paths)
28
+ @marking = Marking.new(store: LiveStore.new, index: index, skip_keys: -> { config.skip_keys }, sanitizer: sanitizer)
29
+ end
30
+
31
+ def middleware_options = {settings: config, logger: @logger}
32
+
33
+ # Prepends the hook on each file-based backend, gated on the thread's flag so a visitor's lookups take
34
+ # the plain path.
35
+ def install_hook(backend = ::I18n.backend)
36
+ BackendHook.marking = marking
37
+ BackendHook.logger = @logger
38
+ BackendHook.gate = -> { ::DialectoRails.live_marking? }
39
+ ::ActiveSupport.on_load(:action_controller_base) do
40
+ prepend NoFragmentCaching unless ancestors.include?(NoFragmentCaching)
41
+ end
42
+ backends = backend.is_a?(::I18n::Backend::Chain) ? backend.backends : [backend]
43
+ backends.filter_map do |member|
44
+ next unless member.is_a?(::I18n::Backend::Simple)
45
+
46
+ klass = member.class
47
+ klass.prepend(BackendHook) unless klass.ancestors.include?(BackendHook)
48
+ klass
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,14 @@
1
+ module DialectoRails
2
+ module InContext
3
+ # What `Marking` reads in live mode, in the shape of the development `Store`: marking is the rendering
4
+ # thread's own flag (`DialectoRails.live_marking?`), never a process-wide switch, and there are no saved
5
+ # drafts. Nothing here can be written to, so nothing a stranger sends can change a visitor's text.
6
+ class LiveStore
7
+ def marking? = ::DialectoRails.live_marking?
8
+
9
+ def override(_domain, _key, _locale) = nil
10
+
11
+ def overrides? = false
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,106 @@
1
+ require "json"
2
+
3
+ module DialectoRails
4
+ module InContext
5
+ # The in-context marker codec v1 (spec-0016 §3), byte-identical to the overlay's `codec.js`, the Astro
6
+ # add-on's encoder and `DialectoInContext.Marker`: `OPEN + header + HEADER_END + text + CLOSE`, where
7
+ # the header is the UTF-8 payload `domain US key US locale` (optionally `RS argsJSON`) with each byte
8
+ # written as four base-4 digits, most significant first. Every marker codepoint is Default_Ignorable, so
9
+ # the text renders unchanged. The codepoints are written by number, never as literal invisible characters.
10
+ module Marker
11
+ OPEN = [0x2062].pack("U").freeze
12
+ HEADER_END = [0x2063].pack("U").freeze
13
+ CLOSE = [0x2064].pack("U").freeze
14
+ DIGITS = [0x200C, 0x200D, 0x2060, 0x2061].map { |code| [code].pack("U").freeze }.freeze
15
+ FIELD_SEP = "\u001F".freeze
16
+ RECORD_SEP = "\u001E".freeze
17
+
18
+ DIGIT_VALUES = DIGITS.each_with_index.to_h.freeze
19
+ Segment = Struct.new(:domain, :key, :locale, :args, :text, :open, keyword_init: true)
20
+
21
+ module_function
22
+
23
+ # Everything that precedes the marked text: OPEN, the header digits, HEADER_END.
24
+ def prefix(domain, key, locale, args_json = nil)
25
+ identity = [domain, key, locale].join(FIELD_SEP)
26
+ payload = args_json ? identity + RECORD_SEP + args_json : identity
27
+ digits = payload.b.each_byte.flat_map { |byte| [byte >> 6, (byte >> 4) & 3, (byte >> 2) & 3, byte & 3] }
28
+ OPEN + digits.map { |digit| DIGITS[digit] }.join + HEADER_END
29
+ end
30
+
31
+ def mark(domain, key, locale, text, args_json = nil)
32
+ prefix(domain, key, locale, args_json) + text + CLOSE
33
+ end
34
+
35
+ def marked?(text) = text.is_a?(String) && text.include?(OPEN)
36
+
37
+ # `text` without markers. Digit codepoints outside a header (a real ZWJ in an emoji) are kept.
38
+ def strip(text)
39
+ return text unless text.include?(OPEN) || text.include?(HEADER_END) || text.include?(CLOSE)
40
+
41
+ chars = text.chars
42
+ out = +""
43
+ index = 0
44
+ while index < chars.length
45
+ char = chars[index]
46
+ if char == OPEN && (header = read_header(chars, index + 1))
47
+ index = header[:next]
48
+ elsif char == OPEN || char == HEADER_END || char == CLOSE
49
+ index += 1
50
+ else
51
+ out << char
52
+ index += 1
53
+ end
54
+ end
55
+ out
56
+ end
57
+
58
+ # Every well-formed marked segment, outer before inner, with its clean text. Malformed headers and
59
+ # unbalanced CLOSEs are ignored; an OPEN never closed extends to the end and is flagged `open`.
60
+ def parse(text)
61
+ chars = text.chars
62
+ done = []
63
+ stack = []
64
+ index = 0
65
+ while index < chars.length
66
+ char = chars[index]
67
+ if char == OPEN && (header = read_header(chars, index + 1))
68
+ stack << {header: header, order: index, text: +""}
69
+ index = header[:next]
70
+ elsif char == CLOSE && !stack.empty?
71
+ done << segment(stack.pop, false)
72
+ index += 1
73
+ else
74
+ stack.each { |open| open[:text] << char } unless char == HEADER_END
75
+ index += 1
76
+ end
77
+ end
78
+ (done + stack.reverse.map { |open| segment(open, true) }).sort_by { |entry| entry[:order] }.map { |entry| entry[:segment] }
79
+ end
80
+
81
+ def segment(open, unclosed)
82
+ identity, _sep, args = open[:header][:payload].partition(RECORD_SEP)
83
+ domain, key, locale = identity.split(FIELD_SEP, 3)
84
+ parsed = args.empty? ? nil : (JSON.parse(args) rescue nil)
85
+ {order: open[:order],
86
+ segment: Segment.new(domain: domain, key: key, locale: locale, args: parsed, text: strip(open[:text]),
87
+ open: unclosed)}
88
+ end
89
+
90
+ # The header after an OPEN at `from`: digits up to HEADER_END, a multiple of four of them.
91
+ def read_header(chars, from)
92
+ digits = []
93
+ index = from
94
+ while index < chars.length && (value = DIGIT_VALUES[chars[index]])
95
+ digits << value
96
+ index += 1
97
+ end
98
+ return nil unless chars[index] == HEADER_END && (digits.length % 4).zero?
99
+
100
+ bytes = digits.each_slice(4).map { |a, b, c, d| (a << 6) | (b << 4) | (c << 2) | d }
101
+ payload = bytes.pack("C*").force_encoding(Encoding::UTF_8)
102
+ payload.valid_encoding? ? {payload: payload, next: index + 1} : nil
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,122 @@
1
+ require "cgi"
2
+ require_relative "marker"
3
+ require_relative "args"
4
+
5
+ module DialectoRails
6
+ module InContext
7
+ # Turns an I18n lookup's result into a marked string (spec-0023 §7.2, §7.4, §7.5). Pure apart from the
8
+ # store and index it is given, so it is tested without a Rails app.
9
+ class Marking
10
+ CATEGORIES = %i[zero one two few many other].freeze
11
+ SKIPPED_PREFIXES = %w[date. datetime. time. number. support. i18n.].freeze
12
+ SKIPPED_SUFFIX = /(\.(url|path|href|format)|_format)\z/
13
+
14
+ def initialize(store:, index:, skip_keys: -> { [] }, fallbacks: nil, sanitizer: nil)
15
+ @store = store
16
+ @index = index
17
+ @skip_keys = skip_keys
18
+ @fallbacks = fallbacks || method(:i18n_fallbacks)
19
+ @sanitizer = sanitizer
20
+ end
21
+
22
+ # `plural` is `[entry, chosen_text]` recorded by the pluralize wrapper, or nil.
23
+ def call(locale, key, options, result, plural)
24
+ return result unless @store.marking? && result.is_a?(String) && !result.start_with?(Marker::OPEN)
25
+ return result if key.is_a?(Array) || key.nil?
26
+
27
+ absolute = absolute_key(locale, key, options)
28
+ return result if absolute.empty? || skipped?(absolute)
29
+
30
+ found_locale, domain = resolve(locale, absolute)
31
+ return result unless domain
32
+
33
+ form, count = plural_form(options, plural)
34
+ html = html_key?(absolute) || safe?(result)
35
+ text = draft(domain, absolute, found_locale, options, form, html) || result
36
+ extras = {}
37
+ extras["__n"] = count if form
38
+ extras["__form"] = form if form
39
+ extras["__html"] = true if html_key?(absolute)
40
+ marked = Marker.mark(domain, absolute, found_locale, text, Args.json(options, extras))
41
+ safe?(result) && marked.respond_to?(:html_safe) ? marked.html_safe : marked
42
+ end
43
+
44
+ def absolute_key(locale, key, options)
45
+ parts = ::I18n.normalize_keys(locale, key, options[:scope], options[:separator] || ::I18n.default_separator)
46
+ parts.drop(1).join(".")
47
+ end
48
+
49
+ def skipped?(absolute)
50
+ SKIPPED_PREFIXES.any? { |prefix| absolute.start_with?(prefix) } || absolute.match?(SKIPPED_SUFFIX) ||
51
+ @skip_keys.call.any? { |pattern| skip_match?(pattern, absolute) }
52
+ end
53
+
54
+ def html_key?(absolute) = absolute.end_with?("_html") || absolute.split(".").last == "html"
55
+
56
+ private
57
+
58
+ # The first of the locale and its fallbacks whose files hold the key, so a page showing English
59
+ # fallback text is marked `en`.
60
+ def resolve(locale, absolute)
61
+ candidates = [locale.to_s] + Array(@fallbacks.call(locale)).map(&:to_s)
62
+ candidates.uniq.each do |candidate|
63
+ domain = @index.domain(candidate, absolute)
64
+ return [candidate, domain] if domain
65
+ end
66
+ nil
67
+ end
68
+
69
+ def i18n_fallbacks(locale)
70
+ ::I18n.respond_to?(:fallbacks) ? ::I18n.fallbacks[locale] : []
71
+ end
72
+
73
+ # The index of the chosen category among the entry's categories in canonical order (what the sidebar
74
+ # lists for the plural), and the count.
75
+ def plural_form(options, plural)
76
+ return nil unless options.key?(:count) && plural
77
+
78
+ entry, chosen = plural
79
+ categories = CATEGORIES.select { |category| entry.key?(category) || entry.key?(category.to_s) }
80
+ chosen_key = entry.find { |_name, value| value.equal?(chosen) }&.first ||
81
+ entry.find { |_name, value| value == chosen }&.first
82
+ index = categories.index(chosen_key&.to_sym)
83
+ index ? [index, options[:count]] : nil
84
+ end
85
+
86
+ # A draft is not yet reviewed, and may come from another person in the org. Where the page will treat the
87
+ # text as HTML (an `_html` key, or a result that is already html_safe) it is sanitized after
88
+ # interpolation, and escaped when there is no sanitizer: it fails closed.
89
+ def draft(domain, absolute, locale, options, form, html)
90
+ override = @store.override(domain, absolute, locale)
91
+ return nil unless override
92
+
93
+ template = form && override[:forms] ? override[:forms][form] : override[:to]
94
+ template ||= override[:forms]&.first
95
+ return nil unless template
96
+
97
+ values = options.reject { |name, _value| Args::RESERVED.include?(name) }
98
+ text = begin
99
+ ::I18n.interpolate(template, values)
100
+ rescue ::I18n::ArgumentError
101
+ template
102
+ end
103
+ html ? clean_html(text) : text
104
+ end
105
+
106
+ def clean_html(text)
107
+ @sanitizer ? @sanitizer.call(text).to_s : CGI.escapeHTML(text)
108
+ rescue StandardError
109
+ CGI.escapeHTML(text)
110
+ end
111
+
112
+ def safe?(result) = result.respond_to?(:html_safe?) && result.html_safe?
113
+
114
+ def skip_match?(pattern, absolute)
115
+ case pattern
116
+ when Regexp then pattern.match?(absolute)
117
+ else Regexp.new("\\A" + Regexp.escape(pattern.to_s).gsub("\\*", ".*") + "\\z").match?(absolute)
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end