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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0bc3bd06a6b0829e2be97222212825426ca8b48c7fb42ca08cc384abb4995da7
4
+ data.tar.gz: f17efbb2e24fac304505560eb32b3abc069ca8f2d9f504cb281b9655c30eb0ec
5
+ SHA512:
6
+ metadata.gz: 2b8aee37aca8a9a6a02f7ff1a55f0de8b1811dd629787ae9191daa960da6be270a33fe54d8897721f4e501bb176cfbc5e89db6d3c7c2bd3eeaab8f3b52688798
7
+ data.tar.gz: 144aee715796b263966af43415f615f22b1fab37c72bf64fd43c6162c9aa91f566b98605cfb9155fcc5c626fe68f3f94b3b8b7e06974ad5493e2d5f478a1149a
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dialecto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,196 @@
1
+ # dialecto-rails
2
+
3
+ Dialecto for Rails: the customer-side half of minimal-diff translation pull requests. Spec:
4
+ `ddd-plan/engineering/spec-0023-rails-sdk.md`. MIT.
5
+
6
+ Three parts, one gem:
7
+
8
+ | Part | What it does |
9
+ | --- | --- |
10
+ | Scanner | `dialecto-rails scan` (and a GitHub Action) sends `config/locales/**/*.yml` to Dialecto from CI. |
11
+ | Usage scan | Reads where Ruby and ERB code reads each key, so a key rename can rewrite its call sites. Off unless you ask. |
12
+ | In-context editing | A development-only Railtie: click a string in your running app, edit it, open a pull request. |
13
+
14
+ ## In-context editing (development only)
15
+
16
+ ```ruby
17
+ # Gemfile
18
+ group :development do
19
+ gem "dialecto-rails"
20
+ end
21
+ ```
22
+
23
+ Start `rails server`; an "Edit text" button appears on every page. Dialecto finds the project from your git
24
+ remote. The Railtie does nothing outside `development` (not in `test`, `staging` or `production`, even with the
25
+ gem forced into the bundle), and its endpoints answer only a loopback peer. Only strings from your own
26
+ `config/locales` are marked. Keep a key away from the editor with `config.dialecto.skip_keys = ["routes.*",
27
+ /\.slug\z/]`; `DIALECTO_IN_CONTEXT=off` or `config.dialecto.enabled = false` switches it off. Fragment caching
28
+ (`rails dev:cache`) serves cached text without markers.
29
+
30
+ ## Editing the live site
31
+
32
+ The same gem can run in production, inert for visitors and on only for an editor who arrives with a signed launch
33
+ link from Dialecto ("Edit on live site"). An edit made this way is an ordinary staged edit in Dialecto; the site
34
+ never writes anything itself.
35
+
36
+ ### When to use it
37
+
38
+ Live-site editing costs visitors nothing: no Dialecto script, no markers, the same bytes. It suits Rails sites,
39
+ not only static ones.
40
+
41
+ - **Do** wrap in `DialectoRails.unmarked { … }` anything that leaves the page during a marked render: a mail sent
42
+ while an editor's request is being handled, text you memoise or put in `Rails.cache`, translated text you write
43
+ to the database, Action Cable or Turbo Stream broadcasts, and job arguments. Markers are invisible characters;
44
+ they would be stored and sent to everyone.
45
+ - **Know** that fragment caching is switched off for a marked request: it neither reads nor writes fragments (a
46
+ cold fragment rendered for an editor would be cached with markers and served to every visitor), so an editor's
47
+ pages render fresh and a visitor's cache is untouched.
48
+ - **Do** keep the settings below in version control: they are public.
49
+ - **Do** serve the site over HTTPS only, and set `origin` to exactly what the browser shows: `www` or the apex,
50
+ and the port. The launch cookie is `Secure`.
51
+ - **Do** keep the server clock right (NTP): a launch link must arrive within two minutes of being minted.
52
+ - **Don't** put a CDN or page cache in front that stores HTML and ignores cookies. Marked pages are sent
53
+ `Cache-Control: private, no-store` and `Vary: Cookie` (and lose their `ETag` and `Last-Modified`), but a cache
54
+ that disregards them defeats this: leave the site out or do not enable live mode.
55
+ - **Don't** expect it on pages served from a cache without reaching Rails (page caching, a static export): there
56
+ is nothing to mark.
57
+ - **Don't** compress responses inside the app, below Dialecto's middleware: the loader can't be added to a
58
+ compressed body, and editors get marked pages without the editor. Compress outside the app (the reverse proxy),
59
+ or put `Rack::Deflater` before the Dialecto middleware.
60
+ - **Know** that the launch URL, token included, appears in reverse-proxy access logs (Rails filters it from its own
61
+ log). It is worth eight hours of seeing marked pages and no edits. A link to a static file path keeps its token in
62
+ the URL, because `ActionDispatch::Static` answers before the middleware. The editor needs the `Sec-Fetch-Dest`
63
+ header (Safari 16.4 or newer).
64
+
65
+ ### Setup
66
+
67
+ Move the gem out of `group :development` so it is installed in production, and turn it on in
68
+ `config/environments/production.rb` (or an initializer) with the settings from the repo's Dialecto settings page:
69
+
70
+ ```ruby
71
+ config.dialecto.mode = :live
72
+ config.dialecto.repo = "owner/name"
73
+ config.dialecto.origin = "https://www.example.com"
74
+ config.dialecto.public_key = "ed25519:…"
75
+ ```
76
+
77
+ **How a release gets `:live`:** unset, `mode` is `:dev` in development and `:off` in every other environment, so a
78
+ production deploy is inert until its config sets `mode = :live`. In `:live` the Railtie also needs `repo`, `origin`
79
+ and `public_key`; without all three it stays off. `origin` is the site's own origin; it is never read from `Host` or
80
+ `X-Forwarded-*`, which a proxy can rewrite. The middleware goes in after the session middleware.
81
+
82
+ Ed25519 verification uses Ruby's OpenSSL bindings: the `openssl` gem 3.2 or later on OpenSSL 1.1.1 or later. Ruby
83
+ 3.3 and newer ship it. On Ruby 3.2, add `gem "openssl", ">= 3.2"` to the Gemfile; without it, live mode stays off and
84
+ the log says why. `ruby -ropenssl -e 'p OpenSSL::VERSION'` shows what you have.
85
+
86
+ ### What happens
87
+
88
+ - A `GET` with `?dialecto_edit=<token>` is verified inside your app (an Ed25519 signature under the repo's key,
89
+ made for this purpose only, `start_by` within two minutes, the token's origin equal to `origin`). A good one
90
+ becomes the `dialecto_edit` cookie (`HttpOnly; Secure; SameSite=Lax; Path=/`, ending at the token's `exp`, at
91
+ most eight hours), and the answer is `303` to the same URL without the parameter. The session is never touched.
92
+ A bad token is ignored, the parameter is still removed, and the reason (late, expired, bad signature, wrong
93
+ origin, wrong repo) is logged at `info`. Misconfigured live mode logs one warning at boot saying what is missing.
94
+ - While the cookie is valid, `GET` and `HEAD` requests for a document or an iframe, and Turbo Drive visits
95
+ (same-origin fetches that ask for HTML), are marked, carry the overlay
96
+ loader, `Cache-Control: private, no-store`, `Vary: Cookie` and `X-Robots-Tag: noindex`, and Dialecto is added to
97
+ the page's CSP `frame-src` (only on those responses). The overlay is served from your own origin at
98
+ `/__dialecto/overlay.js`, never as a script from Dialecto. `POST`, fetches and JSON responses are never marked.
99
+ The flag lives in the rendering thread, so jobs and other threads never inherit it.
100
+ - `POST /__dialecto/stop` clears the cookie.
101
+ - In `:live` the development endpoints (`/__dialecto/context`, `/overrides`, `/marking`) do not exist and answer
102
+ 404, and the development store is never loaded.
103
+ - A visitor's request reaches your app untouched, and the session is not even read for them. The launch token is
104
+ filtered from Rails' request log (`filter_parameters`).
105
+
106
+ A launch token that leaks is worth at most eight hours of seeing marked pages: it does not let anyone change text.
107
+ Editing needs Dialecto's own sign-in, scoped to the repo and the site.
108
+
109
+ ### Refreshing the overlay copy
110
+
111
+ `lib/dialecto_rails/in_context/assets/overlay.js` is a copy of Dialecto's built overlay. Maintainers build it in the
112
+ Dialecto repository (`mix esbuild in_context`) and run `rake overlay:refresh` here. A test in the Dialecto repository
113
+ fails when the copy differs from a fresh build.
114
+
115
+ ## Scanning from CI
116
+
117
+ ```yaml
118
+ - uses: actions/checkout@v4
119
+ - uses: dialecto-eu/dialecto-rails@v1
120
+ with:
121
+ project: 12 # the number in your project's Dialecto URL /repos/12
122
+ token: ${{ secrets.DIALECTO_SCAN_TOKEN }}
123
+ ```
124
+
125
+ The action pins every third-party action it uses by full commit SHA (the tag stays in a comment); a release bumps
126
+ them by looking up the latest tag with `gh api repos/<owner>/<repo>/releases/latest` and its commit SHA.
127
+
128
+ Or without the action: `DIALECTO_SCAN_TOKEN=… DIALECTO_PROJECT=12 dialecto-rails scan`. It sends the files as
129
+ committed at `HEAD`, never your working tree. `dialecto-rails doctor` checks the setup and says how to fix each
130
+ problem.
131
+
132
+ ## Usage scan
133
+
134
+ `DIALECTO_USAGES=on` (or `scan --usages`) adds, to the same scan, the places your Ruby, ERB and HAML code reads
135
+ a key: `t("a.b")`, `I18n.t`, `translate`, `t!`, lazy keys (`t(".title")` in a view or controller), keys built
136
+ from a literal prefix (`t("nav.#{key}")`), and methods that pass their argument on to `t` (wrappers). It sends a
137
+ short anchor of the call (never a whole file) so a key rename can rewrite its call sites, and it reads committed
138
+ code only. Slim and client-side JavaScript are not scanned: a key they name stays locked. Add your own
139
+ translate helpers with `DIALECTO_I18N_FUNCTIONS=my_t,tt` and skip folders with `DIALECTO_USAGES_EXCLUDE`.
140
+
141
+ Methods called on a constant count too (`Spree.t("foo")`, `::Spree.t`) when they are wrappers: defined with
142
+ `def self.t`, under `class << self`, with `module_function` or `extend self`, or aliased. A method that comes from
143
+ a gem, or adds a scope the scan can't read, is declared: `wrappers: [{call: "Spree.t", scope: "spree"}]` in
144
+ `config.dialecto` or `DIALECTO_WRAPPERS="Spree.t=spree"`. `spree_core` and `solidus_core` in `Gemfile.lock` declare
145
+ `Spree.t` and `Spree.translate` for you. Such a call is recorded with its absolute key but no edit point, so a
146
+ rename of that key is refused, never half done.
147
+
148
+ ```
149
+ dialecto-rails usages [--summary] [--out FILE] [--worktree] # what the scan found, per key class
150
+ dialecto-rails check # CI after a rename: code that reads a key the catalogs no longer hold
151
+ ```
152
+
153
+ `--worktree` reads the folder instead of `HEAD` (to try the scan on code you haven't committed; never use it for
154
+ a scan you send). `--require-usages` fails a scan when the code can't be read, instead of sending the catalogs without call sites. The parse error of a file leaves
155
+ the scan, as `could not be parsed: <200 bytes>`, never an exception message or source text.
156
+
157
+ `dialecto-rails doctor` warns when `WEB_CONCURRENCY` is above 1, because the in-context editor's store is per
158
+ process.
159
+
160
+ If you only want your locale files read, Dialecto's settings page also generates a workflow that needs nothing
161
+ installed (just `git`, `jq` and `curl`).
162
+
163
+ ## Settings
164
+
165
+ An option in code wins over the environment, and the environment wins over the default.
166
+
167
+ | Option | Environment variable | Default |
168
+ | --- | --- | --- |
169
+ | `url` | `DIALECTO_URL` | `https://app.dialecto.eu` |
170
+ | `project` | `DIALECTO_PROJECT` | scan: required (the project's number); in-context: found from the git remote |
171
+ | none (environment only) | `DIALECTO_SCAN_TOKEN` | none |
172
+ | `catalog_paths` | `DIALECTO_CATALOG_PATHS` | `config/locales/**/*.yml,config/locales/**/*.yaml` |
173
+ | `catalogs` | `DIALECTO_CATALOGS` | `config/locales` |
174
+ | `usages` | `DIALECTO_USAGES=on` | off |
175
+ | `require_usages` | `DIALECTO_REQUIRE_USAGES=on` | off |
176
+ | `wrappers` | `DIALECTO_WRAPPERS` | none (the lockfile's gems add known ones) |
177
+ | `i18n_functions` | `DIALECTO_I18N_FUNCTIONS` | none |
178
+ | `usages_exclude` | `DIALECTO_USAGES_EXCLUDE` | none |
179
+ | `default_branch` | `DIALECTO_DEFAULT_BRANCH` | `origin/HEAD` |
180
+ | `enabled` | `DIALECTO_IN_CONTEXT=off` | on in development |
181
+ | `paths` | `DIALECTO_PATHS` | every page |
182
+ | `skip_keys` | `DIALECTO_SKIP_KEYS` | none |
183
+
184
+ `DIALECTO_URL` must be `https://` (or `http://` on localhost): the scanner never sends the token over plain
185
+ HTTP to another host unless `DIALECTO_ALLOW_INSECURE=1`.
186
+
187
+ Exit codes: 0 done, 1 `check` found problems, 2 the command can't start (the message names the setting), 3
188
+ Dialecto refused the scan and a retry can't help, 4 Dialecto or the network is unavailable (rerun; scans are
189
+ idempotent).
190
+
191
+ ## Tests
192
+
193
+ ```
194
+ rake test
195
+ rake corpus[DIR] # the usage scan over the ten pinned apps (heavy; fetch them with ddd-plan's corpus/fetch.sh)
196
+ ```
data/action.yml ADDED
@@ -0,0 +1,83 @@
1
+ name: "Dialecto Rails scan"
2
+ description: "Send a Rails project's config/locales files (and, when asked, where the code reads each key) to Dialecto."
3
+ author: "Dialecto"
4
+
5
+ branding:
6
+ icon: "upload-cloud"
7
+ color: "purple"
8
+
9
+ inputs:
10
+ project:
11
+ description: "The project's number: the <number> in its Dialecto URL /repos/<number>."
12
+ required: true
13
+ token:
14
+ description: "The project's scan token, from a repository secret (Settings → Scan token in Dialecto)."
15
+ required: true
16
+ url:
17
+ description: "Your Dialecto's URL. Set it when you run Dialecto yourself."
18
+ required: false
19
+ default: "https://app.dialecto.eu"
20
+ catalog-paths:
21
+ description: "The project's translation file paths as on its Dialecto settings page, comma-separated."
22
+ required: false
23
+ default: "config/locales/**/*.yml,config/locales/**/*.yaml"
24
+ usages:
25
+ description: "Also send where the code reads each key (call-site anchors from Ruby and ERB files). Needed for key renames."
26
+ required: false
27
+ default: "false"
28
+ require-usages:
29
+ description: "Fail when the usage scan can't run, instead of sending the catalogs without it."
30
+ required: false
31
+ default: "false"
32
+ ruby-version:
33
+ description: "Ruby to install for the scan, independent of the app's own. Empty skips the install and uses the runner's Ruby (3.2 or newer)."
34
+ required: false
35
+ default: "3.4"
36
+ version:
37
+ description: "The dialecto-rails gem version to install."
38
+ required: false
39
+ default: "0.1.0"
40
+ working-directory:
41
+ description: "The repository root. Defaults to the workspace."
42
+ required: false
43
+ default: "."
44
+
45
+ outputs:
46
+ scan-id:
47
+ description: "The scan's id in Dialecto."
48
+ value: ${{ steps.scan.outputs.scan-id }}
49
+ outcome:
50
+ description: "ingested, idempotent or skipped."
51
+ value: ${{ steps.scan.outputs.outcome }}
52
+ files:
53
+ description: "How many catalog files were sent."
54
+ value: ${{ steps.scan.outputs.files }}
55
+
56
+ runs:
57
+ using: "composite"
58
+ steps:
59
+ - if: inputs.ruby-version != ''
60
+ uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
61
+ with:
62
+ ruby-version: ${{ inputs.ruby-version }}
63
+ - name: Install dialecto-rails
64
+ shell: bash
65
+ env:
66
+ DIALECTO_GEM_VERSION: ${{ inputs.version }}
67
+ run: |
68
+ export GEM_HOME="$RUNNER_TEMP/dialecto-rails-gems"
69
+ gem install dialecto-rails -v "$DIALECTO_GEM_VERSION" --no-document
70
+ echo "GEM_HOME=$GEM_HOME" >> "$GITHUB_ENV"
71
+ echo "$GEM_HOME/bin" >> "$GITHUB_PATH"
72
+ - name: Scan
73
+ id: scan
74
+ shell: bash
75
+ working-directory: ${{ inputs.working-directory }}
76
+ env:
77
+ DIALECTO_SCAN_TOKEN: ${{ inputs.token }}
78
+ DIALECTO_PROJECT: ${{ inputs.project }}
79
+ DIALECTO_URL: ${{ inputs.url }}
80
+ DIALECTO_CATALOG_PATHS: ${{ inputs.catalog-paths }}
81
+ DIALECTO_USAGES: ${{ inputs.usages }}
82
+ DIALECTO_REQUIRE_USAGES: ${{ inputs.require-usages }}
83
+ run: dialecto-rails scan
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
3
+ require "dialecto_rails"
4
+ require "dialecto_rails/scan/cli"
5
+
6
+ exit DialectoRails::Scan::Cli.run(ARGV)
@@ -0,0 +1 @@
1
+ require_relative "dialecto_rails"
@@ -0,0 +1,206 @@
1
+ require "uri"
2
+
3
+ module DialectoRails
4
+ # Settings, with sdk/astro's precedence: an option wins over the environment, and the environment wins
5
+ # over the default. `options` is a Hash of symbols (the Railtie's `config.dialecto`, or CLI flags).
6
+ class Config
7
+ DEFAULT_URL = "https://app.dialecto.eu".freeze
8
+ DEFAULT_CATALOG_PATHS = ["config/locales/**/*.yml", "config/locales/**/*.yaml"].freeze
9
+ DEFAULT_CATALOGS = "config/locales".freeze
10
+ OFF = %w[off false 0 no].freeze
11
+ ON = %w[on true 1 yes].freeze
12
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 [::1] ::1].freeze
13
+
14
+ attr_reader :options, :env
15
+
16
+ def initialize(options = {}, env = ENV)
17
+ @options = options.to_h.transform_keys(&:to_sym)
18
+ @env = env
19
+ end
20
+
21
+ def url
22
+ text(:url, "DIALECTO_URL")&.sub(%r{/+\z}, "") || DEFAULT_URL
23
+ end
24
+
25
+ def project = text(:project, "DIALECTO_PROJECT")
26
+
27
+ # The token is never an option: it lives in the environment (a CI secret), not in a file.
28
+ def scan_token = blank_to_nil(env["DIALECTO_SCAN_TOKEN"])
29
+
30
+ def catalog_paths = list(:catalog_paths, "DIALECTO_CATALOG_PATHS") || DEFAULT_CATALOG_PATHS
31
+
32
+ def catalogs = text(:catalogs, "DIALECTO_CATALOGS") || DEFAULT_CATALOGS
33
+
34
+ def source_locale = text(:source_locale, "DIALECTO_SOURCE_LOCALE")
35
+
36
+ def usages_exclude = list(:usages_exclude, "DIALECTO_USAGES_EXCLUDE") || []
37
+
38
+ def i18n_functions = list(:i18n_functions, "DIALECTO_I18N_FUNCTIONS") || []
39
+
40
+ # Translate methods called on a constant that the scan can't read, as `[{call: "Spree.t", scope: "spree"}]`.
41
+ # From the environment: `DIALECTO_WRAPPERS="Spree.t=spree,Admin::I18n.tr"` (a scope after `=`, optional).
42
+ def wrappers
43
+ configured = option(:wrappers)
44
+ return Array(configured).map { |entry| entry.is_a?(Hash) ? entry : {call: entry.to_s} } if configured
45
+
46
+ (list(:wrappers, "DIALECTO_WRAPPERS") || []).map do |item|
47
+ call, scope = item.split("=", 2)
48
+ {call: call, scope: scope}
49
+ end
50
+ end
51
+
52
+ def default_branch = text(:default_branch, "DIALECTO_DEFAULT_BRANCH")
53
+
54
+ def paths = list(:paths, "DIALECTO_PATHS")
55
+
56
+ # Strings (`*` is a wildcard) or, from Ruby config, Regexps; the environment holds strings only.
57
+ def skip_keys
58
+ configured = option(:skip_keys)
59
+ if configured.is_a?(Array) && !configured.empty?
60
+ return configured.map { |item| item.is_a?(Regexp) ? item : item.to_s.strip }.reject { |item| item == "" }
61
+ end
62
+
63
+ list(:skip_keys, "DIALECTO_SKIP_KEYS") || []
64
+ end
65
+
66
+ def usages? = flag(:usages, "DIALECTO_USAGES", default: false)
67
+
68
+ def require_usages? = flag(:require_usages, "DIALECTO_REQUIRE_USAGES", default: false)
69
+
70
+ def enabled? = flag(:enabled, "DIALECTO_IN_CONTEXT", default: true)
71
+
72
+ MODES = %i[dev live off].freeze
73
+
74
+ # `:dev`, `:live` or `:off` (spec-0025 §4). `default` is what an unset mode means: the Railtie passes
75
+ # `:dev` in development and `:off` everywhere else. Options only: the live settings are public and live in
76
+ # the app's config.
77
+ def mode(default = :off)
78
+ value = option(:mode).to_s.strip.downcase.to_sym
79
+ MODES.include?(value) ? value : default
80
+ end
81
+
82
+ # "owner/name": the Dialecto repo editors work on.
83
+ def repo = text(:repo, nil)
84
+
85
+ # The site's own origin, normalised like Dialecto stores it, or nil if unset or not a web URL. Never read
86
+ # from a request header, which a proxy can rewrite.
87
+ def origin = self.class.normalize_origin(option(:origin))
88
+
89
+ # The repo's `ed25519:` key as 32 raw bytes, or nil.
90
+ def public_key
91
+ value = option(:public_key).to_s.strip
92
+ return nil unless value.start_with?("ed25519:")
93
+
94
+ bytes = value.delete_prefix("ed25519:")
95
+ return nil unless bytes.match?(/\A[A-Za-z0-9_-]{43}\z/)
96
+
97
+ (bytes.tr("-_", "+/") + "=").unpack1("m0")
98
+ rescue ArgumentError
99
+ nil
100
+ end
101
+
102
+ # Live mode has everything it needs: switched on, a repo, an origin and a key.
103
+ def live?(default_mode = :off) = live_problems(default_mode).empty? && mode(default_mode) == :live
104
+
105
+ # Why `mode = :live` stays off: what is missing or malformed. Empty when live mode is fine or not asked for.
106
+ def live_problems(default_mode = :off)
107
+ return [] unless mode(default_mode) == :live
108
+
109
+ [
110
+ (!enabled? && "the add-on is switched off (enabled = false or DIALECTO_IN_CONTEXT=off)"),
111
+ (repo.nil? && "repo is not set"),
112
+ (origin.nil? && "origin is not set or is not an http(s) origin"),
113
+ (public_key.nil? && "public_key is not set or is not an ed25519:<base64url> key"),
114
+ (!Config.ed25519? && "the openssl gem is older than 3.2, which Ed25519 keys need: add gem \"openssl\", \">= 3.2\" to the Gemfile")
115
+ ].select { |problem| problem }
116
+ end
117
+
118
+ # Raw Ed25519 public keys arrived in the openssl gem 3.2; Ruby 3.2 ships 3.1.
119
+ def self.ed25519?
120
+ require "openssl"
121
+ ::OpenSSL::PKey.respond_to?(:new_raw_public_key)
122
+ end
123
+
124
+ # For tests: the clock, in Unix seconds, the launch token's times are checked against.
125
+ def clock = option(:clock).respond_to?(:call) ? option(:clock) : -> { Time.now.to_i }
126
+
127
+ def self.normalize_origin(url)
128
+ uri = URI.parse(url.to_s.strip)
129
+ return nil unless %w[http https].include?(uri.scheme) && !uri.host.to_s.empty? && (1..65_535).cover?(uri.port.to_i)
130
+
131
+ host = uri.host.downcase
132
+ host = "[#{host.delete("[]")}]" if host.include?(":")
133
+ default_port = (uri.scheme == "https") ? 443 : 80
134
+ "#{uri.scheme}://#{host}#{":#{uri.port}" unless uri.port == default_port}"
135
+ rescue URI::InvalidURIError
136
+ nil
137
+ end
138
+
139
+ def allow_insecure? = flag(:allow_insecure, "DIALECTO_ALLOW_INSECURE", default: false)
140
+
141
+ def url_valid? = http_url? && (https? || loopback_host? || allow_insecure?)
142
+
143
+ def https? = uri&.scheme == "https"
144
+
145
+ def loopback_host? = uri ? LOOPBACK_HOSTS.include?(uri.host.to_s) : false
146
+
147
+ # Why the URL can't carry a token, or nil.
148
+ def url_problem
149
+ return "DIALECTO_URL isn't an http or https URL: #{url.inspect}" unless http_url?
150
+ return nil if url_valid?
151
+
152
+ "DIALECTO_URL is plain http on #{uri.host}. The scan token would travel unencrypted: use https, " \
153
+ "or set DIALECTO_ALLOW_INSECURE=1 for a Dialecto on a private network."
154
+ end
155
+
156
+ private
157
+
158
+ def uri
159
+ @uri ||= begin
160
+ URI.parse(url)
161
+ rescue URI::InvalidURIError
162
+ nil
163
+ end
164
+ end
165
+
166
+ def http_url? = !uri.nil? && %w[http https].include?(uri.scheme) && !uri.host.to_s.empty?
167
+
168
+ def option(name)
169
+ options[name] if options.key?(name)
170
+ end
171
+
172
+ def text(name, var)
173
+ value = option(name)
174
+ value = value.to_s if value.is_a?(Integer)
175
+ blank_to_nil(value.is_a?(String) ? value : nil) || blank_to_nil(env[var])
176
+ end
177
+
178
+ def list(name, var)
179
+ value = option(name)
180
+ items =
181
+ case value
182
+ when Array then value.map(&:to_s)
183
+ when String then value.split(",")
184
+ end
185
+ items = env[var].to_s.split(",") if items.nil? || items.all? { |item| item.strip.empty? }
186
+ items = items.map(&:strip).reject(&:empty?)
187
+ items.empty? ? nil : items
188
+ end
189
+
190
+ def flag(name, var, default:)
191
+ value = option(name)
192
+ return value if value == true || value == false
193
+
194
+ raw = blank_to_nil(env[var])&.downcase
195
+ return false if OFF.include?(raw)
196
+ return true if ON.include?(raw)
197
+
198
+ default
199
+ end
200
+
201
+ def blank_to_nil(value)
202
+ value = value.to_s.strip
203
+ value.empty? ? nil : value
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,21 @@
1
+ module DialectoRails
2
+ # An expected failure: the CLI prints the message and exits with `exit_code`, never a stack trace.
3
+ class Error < StandardError
4
+ def exit_code = 2
5
+ end
6
+
7
+ # The command can't start: missing token or project, a bad URL, not a git repository, a limit.
8
+ class ConfigError < Error
9
+ def exit_code = 2
10
+ end
11
+
12
+ # The server refused and a retry can't help (401, 403, 413, 422).
13
+ class RefusedError < Error
14
+ def exit_code = 3
15
+ end
16
+
17
+ # Still failing after the retries: 429, 5xx, a timeout, a refused or reset connection.
18
+ class RetryableError < Error
19
+ def exit_code = 4
20
+ end
21
+ end
@@ -0,0 +1,37 @@
1
+ require "json"
2
+ require_relative "marker"
3
+
4
+ module DialectoRails
5
+ module InContext
6
+ # What a marker records about the lookup that produced its text, so the overlay can preview an edit
7
+ # exactly as I18n would render it: the interpolation values (strings, integers and booleans as they are,
8
+ # everything else as its `to_s`; a value that is itself a marked message is `{"$marked": clean text}`),
9
+ # and `__n`, `__form` for a plural lookup and `__html` for an `_html` key.
10
+ module Args
11
+ RESERVED = %i[scope default separator resolve object fallback fallback_in_progress fallback_original_locale
12
+ format cascade throw raise deep_interpolation exception_handler locale].freeze
13
+
14
+ module_function
15
+
16
+ def json(options, extras = {})
17
+ recorded = {}
18
+ options.each do |name, value|
19
+ next if RESERVED.include?(name) || value.is_a?(Hash) || value.is_a?(Array) || value.is_a?(Proc)
20
+
21
+ recorded[name.to_s] = value(value)
22
+ end
23
+ extras.each { |name, value| recorded[name.to_s] = value }
24
+ JSON.generate(recorded)
25
+ end
26
+
27
+ def value(value)
28
+ case value
29
+ when String then Marker.marked?(value) ? {"$marked" => Marker.strip(value)} : value.to_s
30
+ when Integer, true, false then value
31
+ when nil then ""
32
+ else value.to_s
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end