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
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
|
data/exe/dialecto-rails
ADDED
|
@@ -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
|