multilocale 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 75db6f0b36c95b3bbcb2c43a76c1764d40bb1cb4d4cb329b7f1ef541915588cb
4
+ data.tar.gz: 17c4ba2fd230cc8078a2ecc47a941ff59c01a89868ca9e2ff8cc96bb3ef0088d
5
+ SHA512:
6
+ metadata.gz: b55ec4bcc946785519ff30b691531acfe4a39179918f6fd89050ef1126c9ea09bcb8f81ff017fc943a45f358df7199ef77cf7c0a44c9777431e14df02a3548a0
7
+ data.tar.gz: 77ac28ae81c76322f0b97caee8be408f7f8554acd248017cf32c0c12b78256745110f5b8df5c43911887dd1479af8e4432a6579472c7136fb66f51afb886273b
data/CHANGELOG.md ADDED
@@ -0,0 +1,33 @@
1
+ # Changelog
2
+
3
+ All notable changes to this gem are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the version is
5
+ the one in `lib/multilocale/version.rb`.
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-08-04
10
+
11
+ First release of the rewritten gem. The repository previously held nothing but
12
+ a licence file; nothing published under this name before.
13
+
14
+ ### Added
15
+
16
+ - `Multilocale::Client` for the REST API: `Authorization: Basic base64(secret)`,
17
+ timeouts, exponential-backoff retries on 429 and 5xx, and errors mapped onto
18
+ `AuthenticationError` / `PermissionError` / `NotFoundError` /
19
+ `RateLimitedError` / `ServerError` / `ConnectionError`.
20
+ - `client.projects` — list, find by id or name, create, update.
21
+ - `client.phrases` — list with filters and paging, upsert (batched), update,
22
+ delete, plus `client.dictionary` / `client.dictionaries`.
23
+ - `Multilocale::LocaleFile` — reads and writes i18n-shaped
24
+ `config/locales/*.yml` (and JSON), nesting dotted keys on the way out and
25
+ flattening them on the way back.
26
+ - `Multilocale::Sync` — `pull` and `push`, driven by `multilocale.json`.
27
+ - `multilocale-ruby` command: `pull`, `push`, `projects`, `phrases`, `version`,
28
+ with `--json` output. Credentials come from the environment only.
29
+ - `example/` — a four-language Sinatra application with its translations
30
+ committed, so a clone runs offline.
31
+
32
+ [unreleased]: https://github.com/multilocale/multilocale-ruby-gem/compare/v0.1.0...HEAD
33
+ [0.1.0]: https://github.com/multilocale/multilocale-ruby-gem/releases/tag/v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022 Multilocale
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,230 @@
1
+ # multilocale (Ruby)
2
+
3
+ Ruby client for [Multilocale](https://www.multilocale.com), the translation
4
+ management platform. It reads and writes projects and phrases over the REST
5
+ API, and syncs them into the locale files the [i18n
6
+ gem](https://github.com/ruby-i18n/i18n) — and therefore Rails — already load.
7
+
8
+ ```ruby
9
+ client = Multilocale.client # MULTILOCALE_API_KEY
10
+ client.dictionary(project: "website", language: "es").to_h
11
+ #=> { "cart.title" => "Carrito", "nav.language" => "Idioma", … }
12
+ ```
13
+
14
+ ```console
15
+ $ multilocale-ruby pull
16
+ website: 412 phrases in 6 languages
17
+ wrote config/locales/en.yml
18
+ wrote config/locales/es.yml
19
+
20
+ ```
21
+
22
+ - No runtime dependencies: `net/http`, `json` and `yaml` from the standard library.
23
+ - No network call while a page is being served. Translations are downloaded at
24
+ build time and committed, so the site renders offline and stays up when
25
+ multilocale.com does not.
26
+ - Ruby >= 3.2.
27
+
28
+ ## Install
29
+
30
+ ```ruby
31
+ # Gemfile
32
+ gem "multilocale", "~> 0.1"
33
+ ```
34
+
35
+ ```console
36
+ gem install multilocale
37
+ ```
38
+
39
+ > **0.1.0 is not on RubyGems yet.** Until it is, install from a checkout —
40
+ > `gem "multilocale", path: "…"` in your Gemfile, or `rake build && gem install
41
+ > pkg/multilocale-0.1.0.gem`. `example/` in this repository already resolves the
42
+ > gem from `path: ".."`, so it needs nothing extra.
43
+
44
+ ## The workflow this gem is for
45
+
46
+ 1. **Phrases live in a project on multilocale.com.** A project has a name, a
47
+ default locale and a complete locale list. A phrase is one
48
+ `{key, value, language}` row, so a key translated into 12 locales is 12 rows.
49
+ 2. **`multilocale-ruby pull` writes them to disk** as
50
+ `config/locales/<locale>.yml`, nested the way i18n expects.
51
+ 3. **You commit the result.** The files are part of the application, reviewed
52
+ in the same pull request as the code that renders them.
53
+ 4. **The i18n gem renders them.** Nothing in the request path talks to
54
+ Multilocale.
55
+
56
+ Editing a locale file by hand and sending it back up is `multilocale-ruby push
57
+ --yes`.
58
+
59
+ ## Authenticate
60
+
61
+ Create a key at [app.multilocale.com](https://app.multilocale.com) → **API
62
+ keys**, and export the secret:
63
+
64
+ ```console
65
+ export MULTILOCALE_API_KEY=…
66
+ ```
67
+
68
+ The API expects `Authorization: Basic base64(secret)` — the secret alone,
69
+ base64'd, **with no colon and no key/secret pair**. This gem does that for you;
70
+ it is worth knowing because a hand-rolled `curl` with `-u key:secret` is the
71
+ single most common first failure.
72
+
73
+ The secret selects the organization *and* the project, so there is no tenant
74
+ parameter to pass, and a key scoped to one project cannot read another's
75
+ phrases even if you ask for it.
76
+
77
+ Scopes are `projects:read`, `projects:write`, `phrases:read`, `phrases:write`.
78
+ New keys get the read scopes only; `pull` needs `projects:read` +
79
+ `phrases:read`, `push` also needs `phrases:write`.
80
+
81
+ There is deliberately **no `--api-key` flag**. A secret on the command line is
82
+ readable in the process table and lives in your shell history forever.
83
+
84
+ ## Configure
85
+
86
+ `multilocale.json` in the repository root, the same file the
87
+ [npm CLI](https://www.npmjs.com/package/multilocale) reads:
88
+
89
+ ```json
90
+ {
91
+ "projectId": "website",
92
+ "defaultLocale": "en",
93
+ "locales": ["en", "es", "fr", "it"],
94
+ "paths": ["config/locales/%lang%.yml"]
95
+ }
96
+ ```
97
+
98
+ - `projectId` accepts an id **or** a name. Names are unique per organization,
99
+ which is what makes this portable between accounts.
100
+ - `paths` are resolved relative to `multilocale.json`, so `rake` from a
101
+ subdirectory writes the same files as `rake` from the root.
102
+ - `%lang%` is replaced with each locale.
103
+
104
+ Every command works with no flags once this file exists. Nothing ever prompts —
105
+ an interactive picker in a CI job is a hung build.
106
+
107
+ ## Command line
108
+
109
+ ```console
110
+ multilocale-ruby pull # API -> config/locales/*.yml
111
+ multilocale-ruby push --yes # files -> API (overwrites remote values)
112
+ multilocale-ruby projects # what this credential can see
113
+ multilocale-ruby phrases --language es # one dictionary, key = value
114
+ multilocale-ruby pull --json # machine-readable report
115
+ ```
116
+
117
+ The executable is `multilocale-ruby`, not `multilocale`: that name belongs to
118
+ the npm CLI, and two tools fighting over one name on `PATH` helps nobody.
119
+
120
+ ## In a Rails application
121
+
122
+ ```ruby
123
+ # lib/tasks/multilocale.rake
124
+ require "multilocale"
125
+
126
+ namespace :multilocale do
127
+ desc "Download the project's phrases into config/locales"
128
+ task :pull do
129
+ result = Multilocale::Sync.new(
130
+ client: Multilocale.client,
131
+ config: Multilocale::ConfigFile.discover(Rails.root)
132
+ ).pull
133
+
134
+ puts "#{result.phrases} phrases in #{result.languages.size} languages"
135
+ end
136
+ end
137
+ ```
138
+
139
+ `config/locales/*.yml` is already on `I18n.load_path`, so `t("cart.title")`
140
+ works with no further wiring. `example/` in this repository is the same thing in
141
+ Sinatra, small enough to read in one sitting.
142
+
143
+ ## Library
144
+
145
+ ```ruby
146
+ client = Multilocale::Client.new(api_key: ENV["MULTILOCALE_API_KEY"])
147
+
148
+ client.projects.list # => [Multilocale::Project]
149
+ client.projects.find("website") # by id or by name
150
+ client.projects.create(name: "docs", default_locale: "en", locales: %w[en fr])
151
+ client.projects.update(id, locales: %w[en fr de]) # replaces the locale list
152
+
153
+ client.phrases.list(project: "website", language: "es")
154
+ client.phrases.list(project: "website", key: "cart.title")
155
+ client.phrases.upsert(key: "cart.title", value: "Cart", language: "en", projects: ["website"])
156
+ client.phrases.update(phrase_id, value: "Basket")
157
+ client.phrases.delete(key: "cart.title", project: "website")
158
+
159
+ client.dictionary(project: "website", language: "es") # => Multilocale::Dictionary
160
+ client.dictionaries(project: "website") # => { "en" => …, "es" => … }
161
+ ```
162
+
163
+ Every call raises a `Multilocale::Error` subclass on failure —
164
+ `AuthenticationError` (401), `PermissionError` (403), `NotFoundError` (404),
165
+ `RateLimitedError` (429), `ServerError` (5xx), `ConnectionError`. 429 and 5xx
166
+ are retried with exponential backoff; 4xx is not, because retrying a missing
167
+ scope only burns rate limit.
168
+
169
+ ### Things worth knowing before you write
170
+
171
+ - **`locales:` on a project update is a replacement, not a merge.** Read the
172
+ project, add to `project.locales`, send the whole list back. Omitting a
173
+ locale removes it.
174
+ - **`delete` removes every language of a key**, not one row — and rows shared
175
+ with other projects are deleted, not detached from this one.
176
+ - **`upsert` overwrites.** It is not a patch: send the whole row.
177
+ - **A phrase needs `projects: [name]`.** Without it the row exists in the
178
+ organization but belongs to no project, and no download will include it.
179
+ - **`list` without `limit` returns everything.** Pass `limit`/`skip` only when
180
+ you want a page; they cap at 2001 and 10000.
181
+
182
+ ## Locale files
183
+
184
+ `config/locales/es.yml`, as written by `pull`:
185
+
186
+ ```yaml
187
+ # Generated by `multilocale-ruby pull`. Edit the phrases on multilocale.com, not here.
188
+ ---
189
+ es:
190
+ cart:
191
+ title: Carrito
192
+ greeting: "¡Hola, %{name}!"
193
+ ```
194
+
195
+ Two details that matter to Ruby specifically:
196
+
197
+ - **Dotted keys are nested.** i18n resolves `t("cart.title")` by walking
198
+ hashes, so a flat `"cart.title":` key would never be found. `push` flattens
199
+ them again on the way back.
200
+ - **No `locale:` entry is injected.** The npm CLI writes one into every
201
+ dictionary it generates; in a Rails locale file it would show up as the
202
+ translation `t("locale")`.
203
+
204
+ `%{name}` and `%{count}` survive machine translation: Multilocale checks that
205
+ every `{…}` placeholder in the source is still present in the translation and
206
+ repairs it if not.
207
+
208
+ Pluralisation is ordinary i18n — store `items.one` and `items.other` as two
209
+ keys and call `t("items", count: 3)`.
210
+
211
+ ## Example application
212
+
213
+ ```console
214
+ cd example
215
+ bundle install
216
+ bundle exec ruby app.rb # http://localhost:4567
217
+ ```
218
+
219
+ Four locales, translations committed, no credentials needed. `/en/`, `/es/`,
220
+ `/fr/`, `/it/`. It resolves the gem from `path: ".."`, so it runs against the
221
+ code in this checkout and works before the gem is on RubyGems.
222
+
223
+ ## Documentation
224
+
225
+ - REST API and CLI guides: <https://www.multilocale.com/developers/>
226
+ - Issues: <https://github.com/multilocale/multilocale-ruby-gem/issues>
227
+
228
+ ## License
229
+
230
+ MIT.
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "multilocale/cli"
5
+
6
+ exit Multilocale::CLI.new.run(ARGV)
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+
6
+ require_relative "../multilocale"
7
+
8
+ module Multilocale
9
+ # `multilocale-ruby` — the thin command wrapper around this gem.
10
+ #
11
+ # The name is not `multilocale` on purpose: that binary belongs to the npm
12
+ # CLI (`npm i -g multilocale`), and two tools fighting over one name on PATH
13
+ # is a support ticket nobody enjoys.
14
+ #
15
+ # Credentials come from the environment only. A secret passed as
16
+ # `--api-key=…` is in the process table for every user on the machine and in
17
+ # ~/.zsh_history forever, so there is no such flag.
18
+ class CLI
19
+ BANNER = <<~USAGE
20
+ multilocale-ruby #{VERSION}
21
+
22
+ Usage:
23
+ multilocale-ruby pull [options] download phrases into locale files
24
+ multilocale-ruby push [options] upload locale files as phrases
25
+ multilocale-ruby projects [options] list the projects this credential can see
26
+ multilocale-ruby phrases [options] print one language as key = value
27
+ multilocale-ruby version
28
+
29
+ Options:
30
+ -p, --project NAME project id or name (default: multilocale.json)
31
+ -c, --config PATH path to multilocale.json (default: nearest one)
32
+ --path TEMPLATE output path containing %lang% (repeatable)
33
+ -l, --language LANG restrict to one language (repeatable)
34
+ --flat write flat "a.b" keys instead of nested hashes
35
+ --json machine-readable output
36
+ --yes required by push, which overwrites remote rows
37
+ -h, --help
38
+
39
+ Environment:
40
+ MULTILOCALE_API_KEY API key secret (app.multilocale.com -> API keys)
41
+ MULTILOCALE_ACCESS_TOKEN operator session token, as an alternative
42
+ MULTILOCALE_API_URL override the API host
43
+ MULTILOCALE_PROJECT default project id or name
44
+ USAGE
45
+
46
+ def initialize(stdout: $stdout, stderr: $stderr)
47
+ @stdout = stdout
48
+ @stderr = stderr
49
+ end
50
+
51
+ # Returns a process exit status: 0 ok, 1 failure, 2 usage.
52
+ def run(argv)
53
+ command = argv.first
54
+
55
+ return usage(0) if command.nil? || %w[-h --help help].include?(command)
56
+ return version if %w[version --version -v].include?(command)
57
+
58
+ options = parse(argv[1..] || [])
59
+
60
+ case command
61
+ when "pull" then pull(options)
62
+ when "push" then push(options)
63
+ when "projects" then projects(options)
64
+ when "phrases" then phrases(options)
65
+ else
66
+ @stderr.puts("Unknown command: #{command}")
67
+ usage(2)
68
+ end
69
+ rescue ConfigurationError, ApiError, ConnectionError, Error, ArgumentError => error
70
+ @stderr.puts(error.message)
71
+ 1
72
+ end
73
+
74
+ private
75
+
76
+ def parse(argv)
77
+ options = { paths: [], languages: [] }
78
+
79
+ parser = OptionParser.new do |parser|
80
+ parser.banner = BANNER
81
+ parser.on("-p", "--project NAME") { |value| options[:project] = value }
82
+ parser.on("-c", "--config PATH") { |value| options[:config] = value }
83
+ parser.on("--path TEMPLATE") { |value| options[:paths] << value }
84
+ parser.on("-l", "--language LANG") { |value| options[:languages] << value }
85
+ parser.on("--flat") { options[:flat] = true }
86
+ parser.on("--json") { options[:json] = true }
87
+ parser.on("--yes") { options[:yes] = true }
88
+ parser.on("-h", "--help") { options[:help] = true }
89
+ end
90
+
91
+ parser.parse(argv)
92
+ options
93
+ end
94
+
95
+ def usage(status)
96
+ (status.zero? ? @stdout : @stderr).puts(BANNER)
97
+ status
98
+ end
99
+
100
+ def version
101
+ @stdout.puts("multilocale-ruby #{VERSION}")
102
+ 0
103
+ end
104
+
105
+ def client(options)
106
+ # Passing `project: nil` would override the client's own MULTILOCALE_PROJECT
107
+ # default with nil, so the keyword is only supplied when there is one.
108
+ options[:project] ? Client.new(project: options[:project]) : Client.new
109
+ end
110
+
111
+ def config(options)
112
+ return ConfigFile.load(options[:config]) if options[:config]
113
+
114
+ ConfigFile.discover
115
+ end
116
+
117
+ def sync(options)
118
+ Sync.new(
119
+ client: client(options),
120
+ config: config(options),
121
+ project: options[:project],
122
+ paths: (options[:paths].empty? ? nil : options[:paths]),
123
+ nested: (options[:flat] ? false : nil)
124
+ )
125
+ end
126
+
127
+ def pull(options)
128
+ return usage(0) if options[:help]
129
+
130
+ result = sync(options).pull(languages: languages(options))
131
+
132
+ if options[:json]
133
+ @stdout.puts(JSON.pretty_generate(
134
+ ok: true,
135
+ project: result.project.name,
136
+ phrases: result.phrases,
137
+ languages: result.languages,
138
+ empty_locales: result.empty_locales,
139
+ files: result.files
140
+ ))
141
+ else
142
+ @stdout.puts("#{result.project.name}: #{result.phrases} phrases in #{result.languages.size} languages")
143
+ result.files.each { |file| @stdout.puts(" wrote #{relative(file)}") }
144
+ unless result.empty_locales.empty?
145
+ @stdout.puts(" no phrases yet for: #{result.empty_locales.join(', ')} (files left untouched)")
146
+ end
147
+ end
148
+
149
+ 0
150
+ end
151
+
152
+ def push(options)
153
+ return usage(0) if options[:help]
154
+
155
+ unless options[:yes]
156
+ @stderr.puts("push overwrites the remote value of every key in the files it reads. Re-run with --yes.")
157
+ return 2
158
+ end
159
+
160
+ rows = sync(options).push(languages: languages(options))
161
+
162
+ if options[:json]
163
+ @stdout.puts(JSON.pretty_generate(ok: true, phrases: rows.size))
164
+ else
165
+ @stdout.puts("pushed #{rows.size} phrases")
166
+ end
167
+
168
+ 0
169
+ end
170
+
171
+ def projects(options)
172
+ return usage(0) if options[:help]
173
+
174
+ list = client(options).projects.list
175
+
176
+ if options[:json]
177
+ @stdout.puts(JSON.pretty_generate(list.map(&:to_h)))
178
+ else
179
+ list.sort_by { |project| project.name.to_s }.each do |project|
180
+ @stdout.puts("#{project.name} #{project.id} #{project.locales.size} locales")
181
+ end
182
+ end
183
+
184
+ 0
185
+ end
186
+
187
+ def phrases(options)
188
+ return usage(0) if options[:help]
189
+
190
+ language = languages(options)&.first
191
+ raise ArgumentError, "phrases needs --language" if language.nil?
192
+
193
+ dictionary = client(options).dictionary(
194
+ project: options[:project] || config(options)&.project,
195
+ language: language
196
+ )
197
+
198
+ if options[:json]
199
+ @stdout.puts(JSON.pretty_generate(dictionary.to_h))
200
+ else
201
+ dictionary.each { |key, value| @stdout.puts("#{key} = #{value}") }
202
+ end
203
+
204
+ 0
205
+ end
206
+
207
+ def languages(options)
208
+ options[:languages].empty? ? nil : options[:languages]
209
+ end
210
+
211
+ def relative(path)
212
+ path.sub("#{Dir.pwd}/", "")
213
+ end
214
+ end
215
+ end