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,202 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "ipaddr"
|
|
3
|
+
require "cgi"
|
|
4
|
+
require_relative "overrides"
|
|
5
|
+
require_relative "../version"
|
|
6
|
+
|
|
7
|
+
module DialectoRails
|
|
8
|
+
module InContext
|
|
9
|
+
# The dev server's side of the in-context editor (spec-0023 §7.6; spec-0017 §1.4 for the endpoints). Pure
|
|
10
|
+
# Rack, inserted at position 0, so it sees every request and every response.
|
|
11
|
+
#
|
|
12
|
+
# Loopback only, by two independent checks: the socket's peer (`REMOTE_ADDR`, never `X-Forwarded-For`)
|
|
13
|
+
# must be a loopback address, and the `Host` must be `localhost`, `127.0.0.1` or `[::1]`, which also
|
|
14
|
+
# defeats DNS rebinding. A POST must carry this site's own `Origin`; a foreign `Origin` or
|
|
15
|
+
# `Sec-Fetch-Site: cross-site` is refused.
|
|
16
|
+
class Middleware
|
|
17
|
+
PREFIX = "/__dialecto".freeze
|
|
18
|
+
MAX_BODY = 256 * 1024
|
|
19
|
+
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 [::1] ::1].freeze
|
|
20
|
+
LOOPBACK_NETS = [IPAddr.new("127.0.0.0/8"), IPAddr.new("::1"), IPAddr.new("::ffff:127.0.0.0/104")].freeze
|
|
21
|
+
JSON_HEADERS = {"content-type" => "application/json", "cache-control" => "no-store"}.freeze
|
|
22
|
+
|
|
23
|
+
# `settings`: url, url_valid?, project, paths (array or nil), catalogs. `store`, `git_context`: the
|
|
24
|
+
# runtime's. `logger` may be nil.
|
|
25
|
+
def initialize(app, settings:, store:, git_context:, logger: nil)
|
|
26
|
+
@app = app
|
|
27
|
+
@settings = settings
|
|
28
|
+
@store = store
|
|
29
|
+
@git_context = git_context
|
|
30
|
+
@logger = logger
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call(env)
|
|
34
|
+
path = env["PATH_INFO"].to_s
|
|
35
|
+
return endpoint(env, path.delete_prefix(PREFIX)) if path == PREFIX || path.start_with?(PREFIX + "/")
|
|
36
|
+
|
|
37
|
+
response = @app.call(env)
|
|
38
|
+
document_request?(env) && path_allowed?(path) ? inject(env, response) : response
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
## Endpoints
|
|
44
|
+
|
|
45
|
+
def endpoint(env, route)
|
|
46
|
+
case route
|
|
47
|
+
when "/context" then context(env)
|
|
48
|
+
when "/overrides" then post(env) { |payload| overrides(payload) }
|
|
49
|
+
when "/marking" then post(env) { |payload| marking(payload) }
|
|
50
|
+
else json(404, error: "not_found")
|
|
51
|
+
end
|
|
52
|
+
rescue => error
|
|
53
|
+
@logger&.debug("dialecto-rails: endpoint failed (#{error.class})")
|
|
54
|
+
json(500, error: "internal")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def context(env)
|
|
58
|
+
return json(405, error: "method_not_allowed") unless %w[GET HEAD].include?(env["REQUEST_METHOD"])
|
|
59
|
+
return json(403, error: "forbidden_origin") unless read_allowed?(env)
|
|
60
|
+
|
|
61
|
+
json(200, @git_context.read)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def post(env)
|
|
65
|
+
return json(405, error: "method_not_allowed") unless env["REQUEST_METHOD"] == "POST"
|
|
66
|
+
return json(403, error: "forbidden_origin") unless write_allowed?(env)
|
|
67
|
+
|
|
68
|
+
body = env["rack.input"]&.read(MAX_BODY + 1).to_s
|
|
69
|
+
return json(413, error: "too_large") if body.bytesize > MAX_BODY
|
|
70
|
+
|
|
71
|
+
payload = begin
|
|
72
|
+
JSON.parse(body)
|
|
73
|
+
rescue JSON::ParserError
|
|
74
|
+
return json(400, error: "invalid_json")
|
|
75
|
+
end
|
|
76
|
+
status, result = yield payload
|
|
77
|
+
json(status, result)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def overrides(payload)
|
|
81
|
+
parsed = Overrides.parse(payload)
|
|
82
|
+
return [400, {error: "invalid_edits"}] unless parsed
|
|
83
|
+
|
|
84
|
+
@store.replace_overrides(parsed.applied)
|
|
85
|
+
[200, {ok: true, applied: parsed.applied.length, ignored: parsed.ignored}]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def marking(payload)
|
|
89
|
+
on = payload.is_a?(Hash) ? payload["on"] : nil
|
|
90
|
+
return [400, {error: "invalid_marking"}] unless on == true || on == false
|
|
91
|
+
|
|
92
|
+
[200, {ok: true, on: @store.set_marking(on), ttl_ms: @store.marking_ttl_ms}]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def json(status, body)
|
|
96
|
+
text = JSON.generate(body)
|
|
97
|
+
[status, JSON_HEADERS.merge("content-length" => text.bytesize.to_s), [text]]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
## Who may call
|
|
101
|
+
|
|
102
|
+
def read_allowed?(env)
|
|
103
|
+
loopback?(env) && origin_ok?(env, required: false) && [nil, "same-origin"].include?(env["HTTP_SEC_FETCH_SITE"])
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def write_allowed?(env)
|
|
107
|
+
loopback?(env) && origin_ok?(env, required: true) && env["HTTP_SEC_FETCH_SITE"] != "cross-site"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def loopback?(env) = LOOPBACK_HOSTS.include?(host(env)) && loopback_peer?(env)
|
|
111
|
+
|
|
112
|
+
# `Host` is the client's to choose, so the peer address must be loopback too: a LAN peer of a server
|
|
113
|
+
# bound to 0.0.0.0 can send `Host: localhost`.
|
|
114
|
+
def loopback_peer?(env)
|
|
115
|
+
address = IPAddr.new(env["REMOTE_ADDR"].to_s)
|
|
116
|
+
LOOPBACK_NETS.any? { |net| net.family == address.family && net.include?(address) }
|
|
117
|
+
rescue IPAddr::Error
|
|
118
|
+
false
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def host(env)
|
|
122
|
+
value = (env["HTTP_HOST"] || env["SERVER_NAME"]).to_s.downcase
|
|
123
|
+
value.start_with?("[") ? value[/\A\[[^\]]*\]/].to_s : value.sub(/:\d+\z/, "")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def origin_ok?(env, required:)
|
|
127
|
+
origin = env["HTTP_ORIGIN"]
|
|
128
|
+
return !required if origin.nil?
|
|
129
|
+
|
|
130
|
+
origin == "#{env["rack.url_scheme"] || "http"}://#{env["HTTP_HOST"]}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
## The loader
|
|
134
|
+
|
|
135
|
+
def document_request?(env)
|
|
136
|
+
env["REQUEST_METHOD"] == "GET" && @settings.url_valid? && loopback?(env) &&
|
|
137
|
+
[nil, "document"].include?(env["HTTP_SEC_FETCH_DEST"]) &&
|
|
138
|
+
env["HTTP_X_REQUESTED_WITH"].to_s.empty? && env["HTTP_TURBO_FRAME"].to_s.empty?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def path_allowed?(path)
|
|
142
|
+
prefixes = @settings.paths
|
|
143
|
+
return true if prefixes.nil?
|
|
144
|
+
|
|
145
|
+
prefixes.any? do |prefix|
|
|
146
|
+
base = prefix.chomp("/")
|
|
147
|
+
path == base || path.start_with?(base + "/")
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def inject(env, response)
|
|
152
|
+
status, headers, body = response
|
|
153
|
+
return response unless html?(headers) && header(headers, "content-encoding").nil?
|
|
154
|
+
|
|
155
|
+
html = +""
|
|
156
|
+
body.each { |part| html << part.to_s }
|
|
157
|
+
body.close if body.respond_to?(:close)
|
|
158
|
+
html.force_encoding(Encoding::UTF_8)
|
|
159
|
+
add_loader(env, status, headers, html)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Never breaks the page: any failure here returns the buffered document as it was.
|
|
163
|
+
def add_loader(env, status, headers, html)
|
|
164
|
+
at = html.index("</head>")
|
|
165
|
+
return [status, headers, [html]] unless at
|
|
166
|
+
|
|
167
|
+
out = html.dup.insert(at, loader_tag(env, headers))
|
|
168
|
+
headers = headers.dup
|
|
169
|
+
key = headers.keys.find { |name| name.casecmp?("content-length") }
|
|
170
|
+
headers[key] = out.bytesize.to_s if key
|
|
171
|
+
[status, headers, [out]]
|
|
172
|
+
rescue => error
|
|
173
|
+
@logger&.debug("dialecto-rails: loader skipped (#{error.class})")
|
|
174
|
+
[status, headers, [html]]
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def html?(headers) = header(headers, "content-type").to_s.start_with?("text/html")
|
|
178
|
+
|
|
179
|
+
def header(headers, name)
|
|
180
|
+
key = headers.keys.find { |candidate| candidate.casecmp?(name) }
|
|
181
|
+
key && headers[key]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def loader_tag(env, headers)
|
|
185
|
+
base = "#{env["SCRIPT_NAME"]}#{PREFIX}"
|
|
186
|
+
csp = header(headers, "content-security-policy").to_s + header(headers, "content-security-policy-report-only").to_s
|
|
187
|
+
nonce = csp[/'nonce-([A-Za-z0-9+\/=_-]+)'/, 1]
|
|
188
|
+
attributes = {
|
|
189
|
+
"src" => @settings.url + "/assets/in-context/overlay.js",
|
|
190
|
+
"data-url" => @settings.url,
|
|
191
|
+
"data-context" => base + "/context",
|
|
192
|
+
"data-overrides" => base + "/overrides",
|
|
193
|
+
"data-marking" => base + "/marking",
|
|
194
|
+
"data-project" => @settings.project,
|
|
195
|
+
"nonce" => nonce
|
|
196
|
+
}.compact.map { |name, value| %( #{name}="#{CGI.escapeHTML(value.to_s)}") }.join
|
|
197
|
+
# Deferred: the page renders even when Dialecto is slow or unreachable.
|
|
198
|
+
"<script defer#{attributes}></script>"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module DialectoRails
|
|
2
|
+
module InContext
|
|
3
|
+
# The saved-drafts payload the overlay posts to `/__dialecto/overrides` (spec-0017 §1.2):
|
|
4
|
+
# `{edits: [{domain, key, locale, to}]}`, where a plural entry carries `forms: [text, …]` (in the entry's
|
|
5
|
+
# categories in canonical order, matching a marker's `__form`) instead of, or beside, `to`.
|
|
6
|
+
module Overrides
|
|
7
|
+
MAX_EDITS = 5_000
|
|
8
|
+
MAX_TEXT = 20_000
|
|
9
|
+
MAX_KEY = 20_000
|
|
10
|
+
MAX_FORMS = 10
|
|
11
|
+
LOCALE = /\A[a-z]{2,3}([-_][A-Za-z0-9]{2,8})*\z/
|
|
12
|
+
|
|
13
|
+
Result = Struct.new(:applied, :ignored, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# A Result, or nil when the payload isn't a well-formed edit list.
|
|
18
|
+
def parse(payload)
|
|
19
|
+
edits = payload.is_a?(Hash) ? payload["edits"] : nil
|
|
20
|
+
return nil unless edits.is_a?(Array) && edits.length <= MAX_EDITS
|
|
21
|
+
|
|
22
|
+
applied = []
|
|
23
|
+
ignored = 0
|
|
24
|
+
edits.each do |edit|
|
|
25
|
+
case (entry = edit_entry(edit))
|
|
26
|
+
when :error then return nil
|
|
27
|
+
when :ignored then ignored += 1
|
|
28
|
+
else applied << entry
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
Result.new(applied: applied, ignored: ignored)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def edit_entry(edit)
|
|
35
|
+
return :error unless edit.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
domain, key, locale = edit.values_at("domain", "key", "locale")
|
|
38
|
+
return :error unless [domain, key, locale].all?(String)
|
|
39
|
+
return :error unless domain.length <= 512 && !key.empty? && key.length <= MAX_KEY && locale.length <= 64
|
|
40
|
+
|
|
41
|
+
override = override_for(edit["to"], edit["forms"])
|
|
42
|
+
return :error unless override
|
|
43
|
+
|
|
44
|
+
locale.match?(LOCALE) ? {domain: domain, key: key, locale: locale, override: override} : :ignored
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def override_for(to, forms)
|
|
48
|
+
return nil unless to.nil? || (to.is_a?(String) && to.length <= MAX_TEXT)
|
|
49
|
+
return nil unless forms.nil? || forms?(forms)
|
|
50
|
+
return nil if to.nil? && forms.nil?
|
|
51
|
+
|
|
52
|
+
{to: to, forms: forms}.compact
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def forms?(forms)
|
|
56
|
+
forms.is_a?(Array) && !forms.empty? && forms.length <= MAX_FORMS &&
|
|
57
|
+
forms.all? { |form| form.is_a?(String) && form.length <= MAX_TEXT }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
module DialectoRails
|
|
2
|
+
# The Rails integration (spec-0023 §7.1). This file is all a production process loads: every initializer
|
|
3
|
+
# begins with a guard, and the in-context code is required only after one passes.
|
|
4
|
+
class Railtie < ::Rails::Railtie
|
|
5
|
+
config.dialecto = ActiveSupport::OrderedOptions.new
|
|
6
|
+
|
|
7
|
+
# The development guard: development only, so test, staging and any custom environment get nothing, and
|
|
8
|
+
# the environment or `config.dialecto.enabled = false` can switch it off. The mode may also say `:live` or
|
|
9
|
+
# `:off`; unset it is `:dev` here.
|
|
10
|
+
def self.active?(app)
|
|
11
|
+
return false unless ::Rails.env.development?
|
|
12
|
+
|
|
13
|
+
require "dialecto_rails/config"
|
|
14
|
+
config = DialectoRails::Config.new(app.config.dialecto.to_h)
|
|
15
|
+
config.enabled? && config.mode(:dev) == :dev
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The live guard (spec-0025 §4): any environment, but only when `mode` is `:live` and the repo, origin and
|
|
19
|
+
# public key are set. Unset, the mode is `:off` outside development, so a production app is inert until its
|
|
20
|
+
# config says otherwise.
|
|
21
|
+
def self.live?(app)
|
|
22
|
+
require "dialecto_rails/config"
|
|
23
|
+
DialectoRails::Config.new(app.config.dialecto.to_h).live?(:off)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
initializer "dialecto_rails.middleware" do |app|
|
|
27
|
+
next unless DialectoRails::Railtie.active?(app)
|
|
28
|
+
|
|
29
|
+
require "dialecto_rails/in_context/runtime"
|
|
30
|
+
runtime = DialectoRails::InContext::Runtime.new(
|
|
31
|
+
root: app.root, config: DialectoRails::Config.new(app.config.dialecto.to_h), logger: ::Rails.logger
|
|
32
|
+
)
|
|
33
|
+
app.config.dialecto.runtime = runtime
|
|
34
|
+
app.middleware.insert_before 0, DialectoRails::InContext::Middleware, **runtime.middleware_options
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
initializer "dialecto_rails.live_middleware" do |app|
|
|
38
|
+
unless DialectoRails::Railtie.live?(app)
|
|
39
|
+
problems = DialectoRails::Config.new(app.config.dialecto.to_h).live_problems(:off)
|
|
40
|
+
unless problems.empty?
|
|
41
|
+
::Rails.logger&.warn("dialecto-rails: mode :live stays off: #{problems.join("; ")}")
|
|
42
|
+
end
|
|
43
|
+
next
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The launch token travels in the URL of the first request; keep it out of the request log.
|
|
47
|
+
app.config.filter_parameters += [:dialecto_edit]
|
|
48
|
+
|
|
49
|
+
require "dialecto_rails/in_context/live_runtime"
|
|
50
|
+
runtime = DialectoRails::InContext::LiveRuntime.new(
|
|
51
|
+
root: app.root, config: DialectoRails::Config.new(app.config.dialecto.to_h), logger: ::Rails.logger
|
|
52
|
+
)
|
|
53
|
+
app.config.dialecto.live_runtime = runtime
|
|
54
|
+
# After the session middleware, which the middleware reads and writes; an API-only app has none.
|
|
55
|
+
if defined?(::ActionDispatch::Flash) && !app.config.api_only
|
|
56
|
+
app.middleware.insert_before ::ActionDispatch::Flash, DialectoRails::InContext::LiveMiddleware, **runtime.middleware_options
|
|
57
|
+
else
|
|
58
|
+
app.middleware.use DialectoRails::InContext::LiveMiddleware, **runtime.middleware_options
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
config.after_initialize do |app|
|
|
63
|
+
if DialectoRails::Railtie.active?(app) && (runtime = app.config.dialecto.runtime)
|
|
64
|
+
runtime.install_hook
|
|
65
|
+
runtime.allow_overlay_in([app.config.content_security_policy, app.config.content_security_policy_report_only]
|
|
66
|
+
.select { |policy| policy.respond_to?(:directives) })
|
|
67
|
+
if app.config.action_controller.perform_caching
|
|
68
|
+
::Rails.logger.warn("dialecto-rails: fragment caching is on, so cached fragments carry no markers. " \
|
|
69
|
+
"Run `bin/rails dev:cache` to turn it off while editing.")
|
|
70
|
+
end
|
|
71
|
+
elsif (runtime = app.config.dialecto.live_runtime)
|
|
72
|
+
runtime.install_hook
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require_relative "../config"
|
|
2
|
+
require_relative "store"
|
|
3
|
+
require_relative "key_index"
|
|
4
|
+
require_relative "marking"
|
|
5
|
+
require_relative "git_context"
|
|
6
|
+
require_relative "backend_hook"
|
|
7
|
+
require_relative "sanitizer"
|
|
8
|
+
require_relative "middleware"
|
|
9
|
+
require_relative "csp"
|
|
10
|
+
|
|
11
|
+
module DialectoRails
|
|
12
|
+
module InContext
|
|
13
|
+
# The development-only machinery, built once the Railtie's guard has passed (spec-0023 §7.1).
|
|
14
|
+
# Nothing in this folder is required before that, so a production process holds none of it.
|
|
15
|
+
class Runtime
|
|
16
|
+
attr_reader :config, :store, :index, :marking, :git_context
|
|
17
|
+
|
|
18
|
+
def self.html_sanitizer = Sanitizer.build
|
|
19
|
+
|
|
20
|
+
def initialize(root:, config:, logger: nil, sanitizer: self.class.html_sanitizer)
|
|
21
|
+
@config = config
|
|
22
|
+
@logger = logger
|
|
23
|
+
@store = Store.new
|
|
24
|
+
@index = KeyIndex.new(root: root, catalog_paths: config.catalog_paths)
|
|
25
|
+
@marking = Marking.new(store: @store, index: @index, skip_keys: -> { config.skip_keys }, sanitizer: sanitizer)
|
|
26
|
+
@git_context = GitContext.new(root: root, project: config.project, catalogs: config.catalogs)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def middleware_options = {settings: config, store: store, git_context: git_context, logger: @logger}
|
|
30
|
+
|
|
31
|
+
# Prepends the hook on the class of each file-based backend. Returns the classes hooked.
|
|
32
|
+
def install_hook(backend = ::I18n.backend)
|
|
33
|
+
BackendHook.marking = marking
|
|
34
|
+
BackendHook.logger = @logger
|
|
35
|
+
backends = backend.is_a?(::I18n::Backend::Chain) ? backend.backends : [backend]
|
|
36
|
+
backends.filter_map do |member|
|
|
37
|
+
unless member.is_a?(::I18n::Backend::Simple)
|
|
38
|
+
@logger&.info("dialecto-rails: in-context editing skipped #{member.class}: it isn't a file backend")
|
|
39
|
+
next
|
|
40
|
+
end
|
|
41
|
+
klass = member.class
|
|
42
|
+
klass.prepend(BackendHook) unless klass.ancestors.include?(BackendHook)
|
|
43
|
+
klass
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def allow_overlay_in(policies)
|
|
48
|
+
policies.each { |policy| Csp.allow(policy, config.url) if policy }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module DialectoRails
|
|
2
|
+
module InContext
|
|
3
|
+
# The safe-list sanitizer for `_html` drafts. ActionView depends on rails-html-sanitizer, but loads it
|
|
4
|
+
# lazily, so it is required here rather than found by `defined?`; if it can't load, the marking escapes
|
|
5
|
+
# drafts instead (it fails closed).
|
|
6
|
+
module Sanitizer
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def build
|
|
10
|
+
require "rails-html-sanitizer"
|
|
11
|
+
klass = defined?(::Rails::HTML5::SafeListSanitizer) ? ::Rails::HTML5::SafeListSanitizer : ::Rails::Html::SafeListSanitizer
|
|
12
|
+
sanitizer = klass.new
|
|
13
|
+
->(text) { sanitizer.sanitize(text) }
|
|
14
|
+
rescue LoadError, NameError
|
|
15
|
+
nil
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module DialectoRails
|
|
2
|
+
module InContext
|
|
3
|
+
# The dev server's in-context state: whether marking is on (spec-0017 §1.3) and the saved drafts the
|
|
4
|
+
# overlay sent (§1.2). Marking lasts `MARKING_TTL_MS` after the last `set_marking(true)`: the overlay
|
|
5
|
+
# heartbeats while the editor is open, so a closed tab or a crashed overlay can't leave markers on.
|
|
6
|
+
class Store
|
|
7
|
+
MARKING_TTL_MS = 90_000
|
|
8
|
+
|
|
9
|
+
def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) })
|
|
10
|
+
@clock = clock
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
@marking_until = nil
|
|
13
|
+
@overrides = {}.freeze
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def marking_ttl_ms = MARKING_TTL_MS
|
|
17
|
+
|
|
18
|
+
def marking?
|
|
19
|
+
until_ms = @marking_until
|
|
20
|
+
!until_ms.nil? && until_ms > @clock.call
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def set_marking(on, ttl_ms = MARKING_TTL_MS)
|
|
24
|
+
@mutex.synchronize { @marking_until = on ? @clock.call + ttl_ms : nil }
|
|
25
|
+
on
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The draft for `{domain, key, locale}`, or nil: `{to: String}` and/or `{forms: [String]}`.
|
|
29
|
+
def override(domain, key, locale) = @overrides[[domain, key, locale]]
|
|
30
|
+
|
|
31
|
+
def overrides? = !@overrides.empty?
|
|
32
|
+
|
|
33
|
+
# Replaces every draft: the overlay always sends the full set, so an empty list restores the catalogs.
|
|
34
|
+
def replace_overrides(entries)
|
|
35
|
+
table = entries.to_h { |entry| [[entry[:domain], entry[:key], entry[:locale]], entry[:override]] }.freeze
|
|
36
|
+
@mutex.synchronize { @overrides = table }
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module DialectoRails
|
|
2
|
+
module Scan
|
|
3
|
+
# Which files are catalogs: the project's catalog paths, matched the way Dialecto matches them
|
|
4
|
+
# (ADR-0020): `**` spans folders, `*` stays in one folder or file name, `?` is one character, and no
|
|
5
|
+
# wildcard reaches a folder or file whose name starts with a dot.
|
|
6
|
+
class Catalogs
|
|
7
|
+
def initialize(patterns)
|
|
8
|
+
@patterns = patterns.map { |pattern| pattern.split("/") }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def match?(path)
|
|
12
|
+
segments = path.split("/")
|
|
13
|
+
@patterns.any? { |pattern| match_segments?(pattern, segments) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def match_segments?(pattern, path)
|
|
19
|
+
return path.empty? if pattern.empty?
|
|
20
|
+
|
|
21
|
+
head, *rest = pattern
|
|
22
|
+
if head == "**"
|
|
23
|
+
(0..path.length).any? do |taken|
|
|
24
|
+
path.first(taken).none? { |segment| segment.start_with?(".") } &&
|
|
25
|
+
match_segments?(rest, path.drop(taken))
|
|
26
|
+
end
|
|
27
|
+
else
|
|
28
|
+
!path.empty? && match_segment?(head, path.first) && match_segments?(rest, path.drop(1))
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def match_segment?(pattern, segment)
|
|
33
|
+
return false if segment.start_with?(".") && pattern.start_with?("*", "?")
|
|
34
|
+
|
|
35
|
+
regex = pattern.each_char.map do |char|
|
|
36
|
+
case char
|
|
37
|
+
when "*" then "[^/]*"
|
|
38
|
+
when "?" then "[^/]"
|
|
39
|
+
else Regexp.escape(char)
|
|
40
|
+
end
|
|
41
|
+
end.join
|
|
42
|
+
Regexp.new("\\A#{regex}\\z").match?(segment)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|