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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +196 -0
- data/action.yml +83 -0
- data/exe/dialecto-rails +6 -0
- data/lib/dialecto-rails.rb +1 -0
- data/lib/dialecto_rails/config.rb +206 -0
- data/lib/dialecto_rails/errors.rb +21 -0
- data/lib/dialecto_rails/in_context/args.rb +37 -0
- data/lib/dialecto_rails/in_context/assets/overlay.js +2784 -0
- data/lib/dialecto_rails/in_context/backend_hook.rb +39 -0
- data/lib/dialecto_rails/in_context/csp.rb +72 -0
- data/lib/dialecto_rails/in_context/git_context.rb +103 -0
- data/lib/dialecto_rails/in_context/key_index.rb +128 -0
- data/lib/dialecto_rails/in_context/launch_token.rb +78 -0
- data/lib/dialecto_rails/in_context/live_middleware.rb +307 -0
- data/lib/dialecto_rails/in_context/live_runtime.rb +53 -0
- data/lib/dialecto_rails/in_context/live_store.rb +14 -0
- data/lib/dialecto_rails/in_context/marker.rb +106 -0
- data/lib/dialecto_rails/in_context/marking.rb +122 -0
- data/lib/dialecto_rails/in_context/middleware.rb +202 -0
- data/lib/dialecto_rails/in_context/overrides.rb +61 -0
- data/lib/dialecto_rails/in_context/railtie_stub.rb +76 -0
- data/lib/dialecto_rails/in_context/runtime.rb +52 -0
- data/lib/dialecto_rails/in_context/sanitizer.rb +19 -0
- data/lib/dialecto_rails/in_context/store.rb +41 -0
- data/lib/dialecto_rails/scan/catalogs.rb +46 -0
- data/lib/dialecto_rails/scan/cli.rb +339 -0
- data/lib/dialecto_rails/scan/client.rb +117 -0
- data/lib/dialecto_rails/scan/envelope.rb +100 -0
- data/lib/dialecto_rails/scan/git.rb +68 -0
- data/lib/dialecto_rails/scan/worktree.rb +41 -0
- data/lib/dialecto_rails/unmarked.rb +42 -0
- data/lib/dialecto_rails/usages/call_args.rb +78 -0
- data/lib/dialecto_rails/usages/catalog_keys.rb +67 -0
- data/lib/dialecto_rails/usages/check.rb +70 -0
- data/lib/dialecto_rails/usages/haml_extractor.rb +260 -0
- data/lib/dialecto_rails/usages/keys.rb +206 -0
- data/lib/dialecto_rails/usages/known_wrappers.rb +31 -0
- data/lib/dialecto_rails/usages/lazy_scope.rb +52 -0
- data/lib/dialecto_rails/usages/parse_check.rb +75 -0
- data/lib/dialecto_rails/usages/read_by_name.json +28 -0
- data/lib/dialecto_rails/usages/record.rb +91 -0
- data/lib/dialecto_rails/usages/ruby_visitor.rb +628 -0
- data/lib/dialecto_rails/usages/scanner.rb +241 -0
- data/lib/dialecto_rails/usages/summary.rb +191 -0
- data/lib/dialecto_rails/usages/templates.rb +45 -0
- data/lib/dialecto_rails/usages/wrappers.rb +336 -0
- data/lib/dialecto_rails/version.rb +3 -0
- data/lib/dialecto_rails.rb +14 -0
- 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
|