localio 0.2.0 → 0.2.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ab5c7db7901943df5125445d1151851ba8831d9407dca76a43f08f7127499639
4
- data.tar.gz: a2ad9aab66aea7dcea8f41b0c7653d7cde6b8e78b3bdb0390fe1e8ae474f67c2
3
+ metadata.gz: f1f31b993473369d48f4c412c59ac04e9614ff3d0d614b68e041fa5856c91657
4
+ data.tar.gz: 502a7d85bb17426d0985328f89488de086fc08692be41c951dc22bc16a517bad
5
5
  SHA512:
6
- metadata.gz: 04d467b401399b5a7810bdbd2fa1d95cb820c0333942ee36a68bd5ec298596229649d4a665c2c35deae8e9fff5ebead2aa941433960b3fb83410be6d9297a4cd
7
- data.tar.gz: 7e3a0348a4d4cc70f8aca67fd52e5bba3b0986df6622e7c000beb8fe6690a28c8bec9c500935868eda2d468263fdcedf1e5996b458cc37ddfc88528bea9d5cc7
6
+ metadata.gz: 10742946a6d8928b4f636bdec770d31364281a4b5070537342656f376500e4a17322c89d8dc755ec91c48d0d0e8e0fe74535a799ee6d990e099b9ec02f9fda3a
7
+ data.tar.gz: c2135d3b03e5c82250715d03fc4c6354279bc08ec7394bf31ca4edc9b11535dc53efd1dcdafb5591a9be4c2900ce06da2d1325dd0aae8906c1d2263e618d59d7
@@ -0,0 +1,42 @@
1
+ name: Release
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ publish:
12
+ name: Publish to RubyGems
13
+ runs-on: ubuntu-latest
14
+
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - uses: ruby/setup-ruby@v1
19
+ with:
20
+ ruby-version: "3.3"
21
+ bundler-cache: true
22
+
23
+ - name: Run tests
24
+ run: bundle exec rspec
25
+
26
+ - name: Verify version matches tag
27
+ run: |
28
+ GEM_VERSION=$(ruby -r ./lib/localio/version -e 'puts Localio::VERSION')
29
+ TAG_VERSION="${GITHUB_REF_NAME#v}"
30
+ if [ "$GEM_VERSION" != "$TAG_VERSION" ]; then
31
+ echo "::error::Version mismatch: gem=$GEM_VERSION tag=$TAG_VERSION"
32
+ exit 1
33
+ fi
34
+ echo "Publishing localio v$GEM_VERSION"
35
+
36
+ - name: Build gem
37
+ run: gem build localio.gemspec
38
+
39
+ - name: Publish to RubyGems
40
+ run: gem push localio-*.gem
41
+ env:
42
+ GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
data/.gitignore CHANGED
@@ -19,4 +19,5 @@ tmp
19
19
  .idea/
20
20
  testing/
21
21
  .vscode/launch.json
22
- .worktrees/
22
+ .worktrees/
23
+ vendor/
data/AGENTS.md ADDED
@@ -0,0 +1 @@
1
+ CLAUDE.md
data/CLAUDE.md ADDED
@@ -0,0 +1,79 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Commands
6
+
7
+ ```bash
8
+ # Run all tests
9
+ bundle exec rspec
10
+
11
+ # Run a single spec file
12
+ bundle exec rspec spec/localio_spec.rb
13
+
14
+ # Run a single example by line number
15
+ bundle exec rspec spec/localio_spec.rb:42
16
+
17
+ # Standard gem tasks (build, install, release)
18
+ bundle exec rake
19
+ ```
20
+
21
+ ## What This Gem Does
22
+
23
+ Localio reads translation data from spreadsheets (Google Drive, XLS, XLSX, CSV) and generates platform-specific localization files. Supported output platforms: Android (`strings.xml`), iOS/Swift (`.strings` + header/constants), JSON, Rails YAML, Java `.properties`, `.resx`, and Twine format.
24
+
25
+ The entry point is `bin/localize`, which reads a `Locfile` (Ruby DSL) from the current directory.
26
+
27
+ ## Architecture
28
+
29
+ ### Data Flow
30
+
31
+ ```
32
+ Locfile (DSL config)
33
+ → Processor (reads spreadsheet source)
34
+ → Filter (regex-based key filtering)
35
+ → LocalizableWriter (dispatches to platform writer)
36
+ → ERB templates → output files
37
+ ```
38
+
39
+ ### Key Abstractions
40
+
41
+ **`Locfile`** (`lib/localio/locfile.rb`) — DSL parser using `instance_eval`. Stores platform, source credentials, output path, formatter, and filters.
42
+
43
+ **`Processor`** (`lib/localio/processor.rb`) — Routes to the correct reader based on `:platform` config. Returns a hash of `language => [Segment]` pairs.
44
+
45
+ **`LocalizableWriter`** (`lib/localio/localizable_writer.rb`) — Routes to the correct writer class. Writers live in `lib/localio/writers/` and use ERB templates from `lib/localio/templates/`.
46
+
47
+ **`Segment`** — A single translation unit: `{key, value, language}`. **`Term`** — A key with a hash of all language values.
48
+
49
+ **`Filter`** (`lib/localio/filter.rb`) — Applied after loading; supports `:only` (allowlist) and `:except` (denylist) regex patterns.
50
+
51
+ **`Formatter`** (`lib/localio/formatter.rb`) — Transforms key names: `:smart`, `:snake_case`, `:camel_case`, `:none`.
52
+
53
+ ### Spreadsheet Format Convention
54
+
55
+ Spreadsheets must follow a specific structure:
56
+ - A `[key]` marker row with language codes as column headers (default language marked with `*`)
57
+ - Data rows with key in column A, translations in subsequent columns
58
+ - An `[end]` marker row to stop parsing
59
+ - Optional `[comment]` rows for documentation (skipped during parsing)
60
+
61
+ ### Adding a New Platform
62
+
63
+ 1. Create `lib/localio/writers/<platform>_writer.rb` with a `write_localizables(holder, path)` class method
64
+ 2. Create corresponding ERB template(s) in `lib/localio/templates/`
65
+ 3. Add a case branch in `LocalizableWriter`
66
+ 4. Add a case branch in the `platform` DSL accessor in `Locfile`
67
+ 5. Add specs in `spec/writers/`
68
+
69
+ ### Adding a New Source Format
70
+
71
+ 1. Create `lib/localio/processors/<format>_processor.rb` with a `load_localizables(config)` class method returning `{language => [Segment]}`
72
+ 2. Add a case branch in `Processor`
73
+ 3. Add specs in `spec/processors/`
74
+
75
+ ## Notes
76
+
77
+ - `String` is monkey-patched in `lib/localio/string_helper.rb` with color helpers and case-conversion methods used throughout
78
+ - The `ConfigStore` (`lib/localio/config_store.rb`) persists OAuth tokens/config to a local YAML file (`.localio.yml`)
79
+ - Tests use fixture spreadsheet files in `spec/` directories alongside spec files
data/Gemfile.lock CHANGED
@@ -1,8 +1,8 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- localio (0.1.8)
5
- csv (>= 3.2)
4
+ localio (0.2.2)
5
+ csv (~> 3.2)
6
6
  google_drive (~> 3.0)
7
7
  nokogiri (~> 1.16)
8
8
  simple_xlsx_reader (~> 2.0)
@@ -18,29 +18,25 @@ GEM
18
18
  csv (3.3.5)
19
19
  declarative (0.0.20)
20
20
  diff-lcs (1.6.2)
21
- faraday (1.10.5)
21
+ faraday (1.8.0)
22
22
  faraday-em_http (~> 1.0)
23
23
  faraday-em_synchrony (~> 1.0)
24
24
  faraday-excon (~> 1.1)
25
- faraday-httpclient (~> 1.0)
26
- faraday-multipart (~> 1.0)
25
+ faraday-httpclient (~> 1.0.1)
27
26
  faraday-net_http (~> 1.0)
28
- faraday-net_http_persistent (~> 1.0)
27
+ faraday-net_http_persistent (~> 1.1)
29
28
  faraday-patron (~> 1.0)
30
29
  faraday-rack (~> 1.0)
31
- faraday-retry (~> 1.0)
30
+ multipart-post (>= 1.2, < 3)
32
31
  ruby2_keywords (>= 0.0.4)
33
32
  faraday-em_http (1.0.0)
34
33
  faraday-em_synchrony (1.0.1)
35
34
  faraday-excon (1.1.0)
36
35
  faraday-httpclient (1.0.1)
37
- faraday-multipart (1.2.0)
38
- multipart-post (~> 2.0)
39
36
  faraday-net_http (1.0.2)
40
37
  faraday-net_http_persistent (1.2.0)
41
38
  faraday-patron (1.0.0)
42
39
  faraday-rack (1.0.0)
43
- faraday-retry (1.0.3)
44
40
  google-apis-core (0.11.3)
45
41
  addressable (~> 2.5, >= 2.5.1)
46
42
  googleauth (>= 0.16.2, < 2.a)
@@ -131,7 +127,7 @@ PLATFORMS
131
127
 
132
128
  DEPENDENCIES
133
129
  localio!
134
- rake
130
+ rake (~> 13.0)
135
131
  rspec (~> 3.0)
136
132
 
137
133
  BUNDLED WITH
data/README.md CHANGED
@@ -73,6 +73,7 @@ Option | Description
73
73
  * `:json` for an easy JSON format for localizables. The `output_path` is yours to decide :)
74
74
  * `:java_properties` for .properties files used mainly in Java. Files named language_(lang).properties will be generated in `output_path`'s root directory.
75
75
  * `:resx` for .resx files used by .NET projects, e.g. Windows Forms, Windows Phone or Xamarin.
76
+ * `:twine` for [Twine](https://github.com/scelis/twine)-compatible `strings.txt` files containing all languages in a single file. The `output_path` is the directory where the file will be written.
76
77
 
77
78
  #### Extra platform parameters
78
79
 
@@ -103,6 +104,14 @@ platform :resx, :resource_file => "WebResources"
103
104
  # ... rest of your Locfile ...
104
105
  ````
105
106
 
107
+ ##### Twine - :twine
108
+
109
+ By default the output file is named `strings.txt`. Use `:output_file` to override:
110
+
111
+ ````ruby
112
+ platform :twine, :output_file => 'MyApp.strings'
113
+ ````
114
+
106
115
  #### Supported sources
107
116
 
108
117
  ##### Google Drive
@@ -0,0 +1,72 @@
1
+ # Twine Writer Design
2
+
3
+ **Date:** 2026-02-23
4
+ **Status:** Approved
5
+
6
+ ## Goal
7
+
8
+ Add a `:twine` platform writer that generates a [Twine](https://github.com/scelis/twine)-compatible `strings.txt` file containing all languages in a single file.
9
+
10
+ ## Output Format
11
+
12
+ Standard Twine format with tab indentation. All languages are written per key, not per file.
13
+
14
+ ```
15
+ [[section_name]]
16
+ [key_name]
17
+ en = English value
18
+ es = Spanish value
19
+ comment = Optional comment
20
+
21
+ [another_key]
22
+ en = Another value
23
+ es = Otro valor
24
+ ```
25
+
26
+ ## Term Mapping
27
+
28
+ | Localio term | Twine output |
29
+ |---|---|
30
+ | `[init-node]` | `[[section_name]]` (value from default language) |
31
+ | `[end-node]` | blank line (sections close implicitly) |
32
+ | `[comment]` | buffered; written as `comment = ...` under the next real key |
33
+ | regular key | `[key]` block with one `lang = value` line per language |
34
+
35
+ ## Architecture
36
+
37
+ **Approach:** Pure Ruby writer (no ERB template). The comment-buffering logic and single-file-all-languages structure don't fit ERB well.
38
+
39
+ ### Writer class
40
+
41
+ - **File:** `lib/localio/writers/twine_writer.rb`
42
+ - **Class:** `TwineWriter`
43
+ - **Interface:** `self.write(languages, terms, path, formatter, options)` — matches all existing writers
44
+
45
+ ### Algorithm (single pass)
46
+
47
+ 1. Open output file (`strings.txt` or `options[:output_file]`)
48
+ 2. `pending_comment = nil`
49
+ 3. For each term:
50
+ - `[comment]` → store `pending_comment` from default language value
51
+ - `[init-node]` → write `[[value]]`, reset `pending_comment`
52
+ - `[end-node]` → write blank line
53
+ - regular key → write `[key]` block with all lang translations; if `pending_comment` is set, append `\t\tcomment = ...` and clear it
54
+
55
+ ### Platform registration
56
+
57
+ - Registered in `lib/localio.rb` as `:twine`
58
+ - Locfile usage: `platform :twine` or `platform :twine, :output_file => 'custom.txt'`
59
+
60
+ ### Key formatting
61
+
62
+ - Smart formatter defaults to snake_case (consistent with android/rails)
63
+
64
+ ## Testing
65
+
66
+ `spec/localio/writers/twine_writer_spec.rb` using the existing `standard terms` shared context and `Dir.mktmpdir` isolation. Cases:
67
+
68
+ - Creates `strings.txt` in the output path
69
+ - All languages present in each key block
70
+ - `[init-node]` produces `[[section]]` header
71
+ - `[comment]` row attaches as `comment = ...` to the following key
72
+ - `:output_file` option overrides the default filename
@@ -0,0 +1,267 @@
1
+ # Twine Writer Implementation Plan
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Add a `:twine` platform writer that generates a single Twine-compatible `strings.txt` containing all languages.
6
+
7
+ **Architecture:** Pure Ruby writer (no ERB template). Single-pass over terms: buffers `[comment]` rows and attaches them to the next real key, maps `[init-node]`/`[end-node]` to Twine `[[section]]` headers, writes all language translations per key. Registered in `localizable_writer.rb` alongside the existing 7 writers.
8
+
9
+ **Tech Stack:** Ruby 3.2+, RSpec 3.x, stdlib FileUtils
10
+
11
+ ---
12
+
13
+ ## Task 1: Write the TwineWriter spec (failing)
14
+
15
+ **Files:**
16
+ - Create: `spec/localio/writers/twine_writer_spec.rb`
17
+
18
+ **Step 1: Create the spec file**
19
+
20
+ ```ruby
21
+ require 'localio/string_helper'
22
+ require 'localio/term'
23
+ require 'localio/formatter'
24
+ require 'localio/writers/twine_writer'
25
+
26
+ RSpec.describe TwineWriter do
27
+ include_context 'standard terms'
28
+ # standard terms provides: languages {'en'=>1,'es'=>2,'fr'=>3},
29
+ # default_language 'en', and terms:
30
+ # [comment] "Section General", app_name, greeting, dots_test, ampersand_test
31
+
32
+ let(:options) { { default_language: 'en' } }
33
+
34
+ describe '.write' do
35
+ it 'creates strings.txt in the output path' do
36
+ Dir.mktmpdir do |tmpdir|
37
+ Dir.chdir(tmpdir) { TwineWriter.write(languages, terms, tmpdir, :smart, options) }
38
+ expect(File).to exist(File.join(tmpdir, 'strings.txt'))
39
+ end
40
+ end
41
+
42
+ it 'uses a custom filename when :output_file is specified' do
43
+ Dir.mktmpdir do |tmpdir|
44
+ Dir.chdir(tmpdir) do
45
+ TwineWriter.write(languages, terms, tmpdir, :smart, options.merge(output_file: 'translations.txt'))
46
+ end
47
+ expect(File).to exist(File.join(tmpdir, 'translations.txt'))
48
+ expect(File).not_to exist(File.join(tmpdir, 'strings.txt'))
49
+ end
50
+ end
51
+
52
+ it 'includes all languages for each key' do
53
+ Dir.mktmpdir do |tmpdir|
54
+ Dir.chdir(tmpdir) { TwineWriter.write(languages, terms, tmpdir, :smart, options) }
55
+ content = File.read(File.join(tmpdir, 'strings.txt'))
56
+ expect(content).to include('en = My App')
57
+ expect(content).to include('es = Mi Aplicación')
58
+ expect(content).to include('fr = Mon Application')
59
+ end
60
+ end
61
+
62
+ it 'writes [init-node] terms as [[section]] headers' do
63
+ section_terms = [
64
+ Term.new('[init-node]').tap { |t| t.values['en'] = 'General'; t.values['es'] = 'General'; t.values['fr'] = 'General' },
65
+ Term.new('app_name').tap { |t| t.values['en'] = 'My App'; t.values['es'] = 'Mi App'; t.values['fr'] = 'Mon App' },
66
+ Term.new('[end-node]').tap { |t| t.values['en'] = 'end'; t.values['es'] = 'end'; t.values['fr'] = 'end' },
67
+ ]
68
+ Dir.mktmpdir do |tmpdir|
69
+ Dir.chdir(tmpdir) { TwineWriter.write(languages, section_terms, tmpdir, :smart, options) }
70
+ content = File.read(File.join(tmpdir, 'strings.txt'))
71
+ expect(content).to include('[[General]]')
72
+ end
73
+ end
74
+
75
+ it 'attaches [comment] value as comment = on the following key' do
76
+ Dir.mktmpdir do |tmpdir|
77
+ Dir.chdir(tmpdir) { TwineWriter.write(languages, terms, tmpdir, :smart, options) }
78
+ content = File.read(File.join(tmpdir, 'strings.txt'))
79
+ # [comment] "Section General" appears before app_name and attaches to it
80
+ expect(content).to include("\t\tcomment = Section General")
81
+ # The comment block appears before the greeting key block
82
+ comment_pos = content.index('comment = Section General')
83
+ greeting_pos = content.index('[greeting]')
84
+ expect(comment_pos).to be < greeting_pos
85
+ end
86
+ end
87
+ end
88
+ end
89
+ ```
90
+
91
+ **Step 2: Run the spec to confirm it fails with "uninitialized constant TwineWriter"**
92
+
93
+ ```bash
94
+ bundle exec rspec spec/localio/writers/twine_writer_spec.rb --format documentation 2>&1
95
+ ```
96
+
97
+ Expected: `LoadError` or `NameError: uninitialized constant TwineWriter`
98
+
99
+ ---
100
+
101
+ ## Task 2: Implement TwineWriter
102
+
103
+ **Files:**
104
+ - Create: `lib/localio/writers/twine_writer.rb`
105
+
106
+ **Step 1: Create the writer**
107
+
108
+ ```ruby
109
+ require 'fileutils'
110
+ require 'localio/formatter'
111
+
112
+ class TwineWriter
113
+ def self.write(languages, terms, path, formatter, options)
114
+ puts 'Writing Twine translations...'
115
+
116
+ default_language = options[:default_language]
117
+ output_filename = options[:output_file] || 'strings.txt'
118
+
119
+ FileUtils.mkdir_p(path)
120
+
121
+ File.open(File.join(path, output_filename), 'w') do |f|
122
+ pending_comment = nil
123
+
124
+ terms.each do |term|
125
+ if term.is_comment?
126
+ pending_comment = term.values[default_language]
127
+ elsif term.keyword == '[init-node]'
128
+ f.puts "[[#{term.values[default_language]}]]"
129
+ pending_comment = nil
130
+ elsif term.keyword == '[end-node]'
131
+ f.puts ''
132
+ pending_comment = nil
133
+ else
134
+ key = Formatter.format(term.keyword, formatter, method(:twine_key_formatter))
135
+ f.puts "\t[#{key}]"
136
+ languages.keys.each do |lang|
137
+ f.puts "\t\t#{lang} = #{term.values[lang]}"
138
+ end
139
+ if pending_comment
140
+ f.puts "\t\tcomment = #{pending_comment}"
141
+ pending_comment = nil
142
+ end
143
+ f.puts ''
144
+ end
145
+ end
146
+ end
147
+
148
+ puts " > #{output_filename.yellow}"
149
+ end
150
+
151
+ private
152
+
153
+ def self.twine_key_formatter(key)
154
+ key.space_to_underscore.strip_tag.downcase
155
+ end
156
+ end
157
+ ```
158
+
159
+ **Step 2: Run the spec to confirm all 5 tests pass**
160
+
161
+ ```bash
162
+ bundle exec rspec spec/localio/writers/twine_writer_spec.rb --format documentation 2>&1
163
+ ```
164
+
165
+ Expected: `5 examples, 0 failures`
166
+
167
+ **Step 3: Run the full suite to confirm no regressions**
168
+
169
+ ```bash
170
+ bundle exec rspec --format progress 2>&1 | tail -5
171
+ ```
172
+
173
+ Expected: `112 examples, 0 failures`
174
+
175
+ **Step 4: Commit**
176
+
177
+ ```bash
178
+ git add lib/localio/writers/twine_writer.rb spec/localio/writers/twine_writer_spec.rb
179
+ git commit -m "feat: add TwineWriter for Twine-compatible strings.txt output"
180
+ ```
181
+
182
+ ---
183
+
184
+ ## Task 3: Register :twine in LocalizableWriter
185
+
186
+ **Files:**
187
+ - Modify: `lib/localio/localizable_writer.rb`
188
+
189
+ **Step 1: Add the require and case branch**
190
+
191
+ At the top of `lib/localio/localizable_writer.rb`, add after the last `require` line:
192
+
193
+ ```ruby
194
+ require 'localio/writers/twine_writer'
195
+ ```
196
+
197
+ In the `case platform` block, add before the `else`:
198
+
199
+ ```ruby
200
+ when :twine
201
+ TwineWriter.write languages, terms, path, formatter, options
202
+ ```
203
+
204
+ Also update the error message in the `else` branch to include `:twine`:
205
+
206
+ ```ruby
207
+ raise ArgumentError, 'Platform not supported! Current possibilities are :android, :ios, :json, :rails, :java_properties, :resx, :twine'
208
+ ```
209
+
210
+ **Step 2: Run the full suite to confirm nothing broke**
211
+
212
+ ```bash
213
+ bundle exec rspec --format progress 2>&1 | tail -5
214
+ ```
215
+
216
+ Expected: `112 examples, 0 failures`
217
+
218
+ **Step 3: Commit**
219
+
220
+ ```bash
221
+ git add lib/localio/localizable_writer.rb
222
+ git commit -m "feat: register :twine platform in LocalizableWriter"
223
+ ```
224
+
225
+ ---
226
+
227
+ ## Task 4: Update README
228
+
229
+ **Files:**
230
+ - Modify: `README.md`
231
+
232
+ **Step 1: Add :twine to the supported platforms list**
233
+
234
+ Find the `#### Supported platforms` section and add after the `:resx` bullet:
235
+
236
+ ```markdown
237
+ * `:twine` for [Twine](https://github.com/scelis/twine)-compatible `strings.txt` files containing all languages in a single file. The `output_path` is the directory where the file will be written.
238
+ ```
239
+
240
+ **Step 2: Add a Twine source section after the ResX platform parameters section**
241
+
242
+ Find the `#### Supported sources` section header and add a new platform parameters sub-section before it (after the ResX section):
243
+
244
+ ```markdown
245
+ ##### Twine - :twine
246
+
247
+ By default the output file is named `strings.txt`. Use `:output_file` to override:
248
+
249
+ ````ruby
250
+ platform :twine, :output_file => 'MyApp.strings'
251
+ ````
252
+ ```
253
+
254
+ **Step 3: Run the full suite one final time**
255
+
256
+ ```bash
257
+ bundle exec rspec --format progress 2>&1 | tail -5
258
+ ```
259
+
260
+ Expected: `112 examples, 0 failures`
261
+
262
+ **Step 4: Commit**
263
+
264
+ ```bash
265
+ git add README.md
266
+ git commit -m "docs: document :twine platform in README"
267
+ ```
@@ -5,6 +5,7 @@ require 'localio/writers/json_writer'
5
5
  require 'localio/writers/rails_writer'
6
6
  require 'localio/writers/java_properties_writer'
7
7
  require 'localio/writers/resx_writer'
8
+ require 'localio/writers/twine_writer'
8
9
 
9
10
  module LocalizableWriter
10
11
  def self.write(platform, languages, terms, path, formatter, options)
@@ -23,8 +24,10 @@ module LocalizableWriter
23
24
  JavaPropertiesWriter.write languages, terms, path, formatter, options
24
25
  when :resx
25
26
  ResXWriter.write languages, terms, path, formatter, options
27
+ when :twine
28
+ TwineWriter.write languages, terms, path, formatter, options
26
29
  else
27
- raise ArgumentError, 'Platform not supported! Current possibilities are :android, :ios, :json, :rails, :java_properties, :resx'
30
+ raise ArgumentError, 'Platform not supported! Current possibilities are :android, :ios, :json, :rails, :java_properties, :resx, :twine'
28
31
  end
29
32
  end
30
33
  end
@@ -1,3 +1,3 @@
1
1
  module Localio
2
- VERSION = "0.2.0"
2
+ VERSION = "0.2.2"
3
3
  end
@@ -0,0 +1,48 @@
1
+ require 'fileutils'
2
+ require 'localio/formatter'
3
+
4
+ class TwineWriter
5
+ def self.write(languages, terms, path, formatter, options)
6
+ puts 'Writing Twine translations...'
7
+
8
+ default_language = options[:default_language]
9
+ output_filename = options[:output_file] || 'strings.txt'
10
+
11
+ FileUtils.mkdir_p(path)
12
+
13
+ File.open(File.join(path, output_filename), 'w') do |f|
14
+ pending_comment = nil
15
+
16
+ terms.each do |term|
17
+ if term.is_comment?
18
+ pending_comment = term.values[default_language]
19
+ elsif term.keyword == '[init-node]'
20
+ f.puts "[[#{term.values[default_language]}]]"
21
+ pending_comment = nil
22
+ elsif term.keyword == '[end-node]'
23
+ f.puts ''
24
+ pending_comment = nil
25
+ else
26
+ key = Formatter.format(term.keyword, formatter, method(:twine_key_formatter))
27
+ f.puts "\t[#{key}]"
28
+ languages.keys.each do |lang|
29
+ f.puts "\t\t#{lang} = #{term.values[lang]}"
30
+ end
31
+ if pending_comment
32
+ f.puts "\t\tcomment = #{pending_comment}"
33
+ pending_comment = nil
34
+ end
35
+ f.puts ''
36
+ end
37
+ end
38
+ end
39
+
40
+ puts " > #{output_filename.yellow}"
41
+ end
42
+
43
+ private
44
+
45
+ def self.twine_key_formatter(key)
46
+ key.space_to_underscore.strip_tag.downcase
47
+ end
48
+ end
@@ -0,0 +1,68 @@
1
+ require 'localio/string_helper'
2
+ require 'localio/term'
3
+ require 'localio/formatter'
4
+ require 'localio/writers/twine_writer'
5
+
6
+ RSpec.describe TwineWriter do
7
+ include_context 'standard terms'
8
+ # standard terms provides: languages {'en'=>1,'es'=>2,'fr'=>3},
9
+ # default_language 'en', and terms:
10
+ # [comment] "Section General", app_name, greeting, dots_test, ampersand_test
11
+
12
+ let(:options) { { default_language: 'en' } }
13
+
14
+ describe '.write' do
15
+ it 'creates strings.txt in the output path' do
16
+ Dir.mktmpdir do |tmpdir|
17
+ Dir.chdir(tmpdir) { TwineWriter.write(languages, terms, tmpdir, :smart, options) }
18
+ expect(File).to exist(File.join(tmpdir, 'strings.txt'))
19
+ end
20
+ end
21
+
22
+ it 'uses a custom filename when :output_file is specified' do
23
+ Dir.mktmpdir do |tmpdir|
24
+ Dir.chdir(tmpdir) do
25
+ TwineWriter.write(languages, terms, tmpdir, :smart, options.merge(output_file: 'translations.txt'))
26
+ end
27
+ expect(File).to exist(File.join(tmpdir, 'translations.txt'))
28
+ expect(File).not_to exist(File.join(tmpdir, 'strings.txt'))
29
+ end
30
+ end
31
+
32
+ it 'includes all languages for each key' do
33
+ Dir.mktmpdir do |tmpdir|
34
+ Dir.chdir(tmpdir) { TwineWriter.write(languages, terms, tmpdir, :smart, options) }
35
+ content = File.read(File.join(tmpdir, 'strings.txt'))
36
+ expect(content).to include('en = My App')
37
+ expect(content).to include('es = Mi Aplicación')
38
+ expect(content).to include('fr = Mon Application')
39
+ end
40
+ end
41
+
42
+ it 'writes [init-node] terms as [[section]] headers' do
43
+ section_terms = [
44
+ Term.new('[init-node]').tap { |t| t.values['en'] = 'General'; t.values['es'] = 'General'; t.values['fr'] = 'General' },
45
+ Term.new('app_name').tap { |t| t.values['en'] = 'My App'; t.values['es'] = 'Mi App'; t.values['fr'] = 'Mon App' },
46
+ Term.new('[end-node]').tap { |t| t.values['en'] = 'end'; t.values['es'] = 'end'; t.values['fr'] = 'end' },
47
+ ]
48
+ Dir.mktmpdir do |tmpdir|
49
+ Dir.chdir(tmpdir) { TwineWriter.write(languages, section_terms, tmpdir, :smart, options) }
50
+ content = File.read(File.join(tmpdir, 'strings.txt'))
51
+ expect(content).to include('[[General]]')
52
+ end
53
+ end
54
+
55
+ it 'attaches [comment] value as comment = on the following key' do
56
+ Dir.mktmpdir do |tmpdir|
57
+ Dir.chdir(tmpdir) { TwineWriter.write(languages, terms, tmpdir, :smart, options) }
58
+ content = File.read(File.join(tmpdir, 'strings.txt'))
59
+ # [comment] "Section General" appears before app_name and attaches to it
60
+ expect(content).to include("\t\tcomment = Section General")
61
+ # The comment block appears before the greeting key block
62
+ comment_pos = content.index('comment = Section General')
63
+ greeting_pos = content.index('[greeting]')
64
+ expect(comment_pos).to be < greeting_pos
65
+ end
66
+ end
67
+ end
68
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: localio
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nacho Lopez
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-02-23 00:00:00.000000000 Z
11
+ date: 2026-02-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
@@ -117,9 +117,12 @@ extensions: []
117
117
  extra_rdoc_files: []
118
118
  files:
119
119
  - ".github/workflows/ci.yml"
120
+ - ".github/workflows/release.yml"
120
121
  - ".gitignore"
121
122
  - ".rspec"
122
123
  - ".ruby-version"
124
+ - AGENTS.md
125
+ - CLAUDE.md
123
126
  - CONTRIBUTING.md
124
127
  - Gemfile
125
128
  - Gemfile.lock
@@ -129,6 +132,8 @@ files:
129
132
  - bin/localize
130
133
  - docs/plans/2026-02-23-modernization-design.md
131
134
  - docs/plans/2026-02-23-modernization.md
135
+ - docs/plans/2026-02-23-twine-writer-design.md
136
+ - docs/plans/2026-02-23-twine-writer.md
132
137
  - lib/localio.rb
133
138
  - lib/localio/config_store.rb
134
139
  - lib/localio/filter.rb
@@ -162,6 +167,7 @@ files:
162
167
  - lib/localio/writers/rails_writer.rb
163
168
  - lib/localio/writers/resx_writer.rb
164
169
  - lib/localio/writers/swift_writer.rb
170
+ - lib/localio/writers/twine_writer.rb
165
171
  - localio.gemspec
166
172
  - spec/fixtures/sample.csv
167
173
  - spec/localio/filter_spec.rb
@@ -182,6 +188,7 @@ files:
182
188
  - spec/localio/writers/rails_writer_spec.rb
183
189
  - spec/localio/writers/resx_writer_spec.rb
184
190
  - spec/localio/writers/swift_writer_spec.rb
191
+ - spec/localio/writers/twine_writer_spec.rb
185
192
  - spec/localio_spec.rb
186
193
  - spec/spec_helper.rb
187
194
  - spec/support/shared_terms.rb