i18n-instrument 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: a0463e97ab45fd96ee54d900280a3afa10ee6d95
4
+ data.tar.gz: 83c48ca90a3e6a82cc40c9a8cf33af38c59a4e20
5
+ SHA512:
6
+ metadata.gz: 310fca52c96c4119221e0f285a02a81291324aad34daa0e14ceba50c933446699deb04a0ea97849d57a53245a04afca0aa2fb6b1e5ceb351af172b1bab6b1c47
7
+ data.tar.gz: bf0c3eb4be1e07e60e1aef5aaf45fc7b76c789ce844fa0ca4e3a29ac30c103e4bac23fc2e5621bda42c4d9df58008cc08347b50e6b2b6362ba4e293ff4c85dde
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2016 Lumos Labs, Inc.
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,141 @@
1
+ # i18n-instrument
2
+ Instrument calls to I18n.t in Ruby and JavaScript in your Rails app.
3
+
4
+ ## Installation
5
+
6
+ Add it to your Gemfile:
7
+
8
+ ```ruby
9
+ gem 'i18n-instrument', require: 'i18n/instrument'
10
+ ```
11
+
12
+ ## The Problem
13
+
14
+ Platforms like iOS and Android make it easy to tell which of your localization strings are currently in use - just do a bit of grepping or run a script and voilà! Once you've identified them, you can remove any unused, crufty strings from your translation files and move on with your life.
15
+
16
+ Due to Ruby's dynamic nature and the fact that you can never tell what Rails is actually doing, identifying unused strings is much more difficult. The static analysis that worked so well with iOS and Android projects won't work for your Rails app.
17
+
18
+ Consider this ERB template. It lives in app/views/products/index.html.erb:
19
+
20
+ ```HTML+ERB
21
+ <div class="description">
22
+ <%= I18n.t('.description') %>: <%= @product.description %>
23
+ </div>
24
+ ```
25
+
26
+ Under the hood, the i18n gem (which provides Rails' localization implementation) fully qualifies `.description` into `products.index.description`. Because this qualification is done at runtime, static analysis (i.e. grepping) won't be able to identify all the strings your app is currently using.
27
+
28
+ The problem is compounded by the fact that the key you pass to `I18n.t` is just a string, and can therefore be generated in any way the Ruby language allows, for example:
29
+
30
+ ```HTML+ERB
31
+ <div class="fields">
32
+ <% @product.attributes.each_pair do |attr, value|
33
+ <div class="<%= attr %>">
34
+ <%= I18n.t(".#{attr}") %>: <%= value %>
35
+ </div>
36
+ <% end %>
37
+ </div>
38
+ ```
39
+ First of all, I sincerely hope you never write code like this - it's probably bad practice to loop over *all* the attributes in your model and print them out (security blah blah blah). Hopefully my example illustrates the problem however - namely that the `I18n.t` method can accept any string, including string variables, interpolated strings, and the rest. Unless your static analyzer is very clever, it won't be able to tell you which of your localization strings are currently being used.
40
+
41
+ ## Ok, so what should I do about it?
42
+
43
+ So glad you asked.
44
+
45
+ The only foolproof way to collect information about the localization strings your app is using is during runtime. This gem, i18n-instrument, is capable of annotating calls to `I18n.t` in Ruby/Rails and Javascript (provided you're using the capable [i18n-js gem](https://github.com/fnando/i18n-js)). Whenever one of these methods is called, i18n-instrument will fire the `on_record` callback and pass you some useful information. From there, the possibilities are endless. You could log the information to the console, write it down somewhere useful, save it to a database, you name it.
46
+
47
+ ## Callbacks
48
+
49
+ * **`on_lookup(key : String, value : String)`**: Fired whenever `I18n.t` is called. Yields the localization key (i.e. `es.products.index.description`) and the corresponding translation string.
50
+
51
+ * **`on_record(params : Hash)`**: Similar to `on_lookup` but comes with a bunch of useful information.
52
+
53
+ The `params` hash contains the following keys (all values are strings):
54
+
55
+ * **`controller`**: the controller that served the request.
56
+ * **`action`**: the action that served the request.
57
+ * **`trace`**: the filename and line number from the first application stack frame, i.e. the place in your code `I18n.t` was called.
58
+ * **`key`**: the localization key.
59
+ * **`source`**: either "ruby" or "javascript".
60
+ * **`locale`**: the value of `I18n.locale` in Ruby or Javascript.
61
+
62
+ * **`on_error(e : Exception)`**: Fired whenever an error occurs. The default behavior is to re-raise the exception. You may want to add your own exception handling callback so your app doesn't crash. See the configuration section below for details.
63
+
64
+ * **`on_check_enabled() : Boolean`**: Fired on every lookup. The return value must be `true` if the lookup should be recorded and `false` otherwise. The default behavior is to look for the existence of a file named config/enable\_i18n\_instrumentation. If the file exists, `I18n.t` calls are recorded. If not, calls are not recorded.
65
+
66
+ ## Configuration
67
+
68
+ i18n-instrument is a piece of Rack middleware. It sits in between the Internet and your Rails app. You can configure it any time before your app is finished booting. I'd suggest doing it in a Rails initializer, for example config/initializers/i18n\_instrument.rb:
69
+
70
+ ```ruby
71
+ I18n::Instrument.configure do |config|
72
+ # The first stack trace line that begins with this string will be passed to
73
+ # `on_record` as the `trace` value. Defaults to `Rails.root`.
74
+ config.stack_trace_prefix = 'custom/path/to/app'
75
+
76
+ # The URL you want your app to send js instrumentation requests to. Defaults
77
+ # to '/i18n/instrument.json'.
78
+ config.js_endpoint = '/my_i18n/foo.json'
79
+
80
+ # all of the callback methods are available here
81
+
82
+ config.on_record do |params|
83
+ # print params to the rails log
84
+ Rails.logger.info(params.inspect)
85
+ end
86
+
87
+ config.on_lookup do |key, value|
88
+ puts "Looked up i18n key #{key} and got value #{value}"
89
+ end
90
+
91
+ config.on_error do |e|
92
+ # report errors to our error aggregation service
93
+ Rollbar.error(e)
94
+ end
95
+
96
+ config.on_check_enabled do
97
+ # always enable
98
+ true
99
+ end
100
+ end
101
+ ```
102
+ Be careful not to call `I18n::Instrument.configure` more than once - doing so will replace any existing configuration you may have already done. Instead, try this:
103
+
104
+ ```ruby
105
+ # replace any existing on_record behavior, but leave all other configuration intact
106
+ I18n::Instrument.config.on_record do |params|
107
+ ...
108
+ end
109
+ ```
110
+
111
+ ## Javascript Land
112
+
113
+ Using i18n-instrument in Javascript is pretty straightforward - just include it in your application.js file:
114
+
115
+ ```javascript
116
+ //= require i18n
117
+ //= require i18n/instrument
118
+ ```
119
+ Make sure to include i18n/instrument ***after*** i18n. This is critical since i18n-instrument overrides i18n's `t` method.
120
+
121
+ Also, don't forget to enable Javascript instrumentation by adding this line, probably at the bottom of application.js:
122
+
123
+ ```javascript
124
+ I18n.instrumentation_enabled = true
125
+ ```
126
+
127
+ ## Requirements
128
+
129
+ i18n-instrument is designed to work with Rails 4 and up, although it has not been tested under Rails 5 yet.
130
+
131
+ ## Testing
132
+
133
+ Run the test suite with `bundle exec rspec`.
134
+
135
+ ## Authors
136
+
137
+ * Cameron C. Dutro ([@camertron](https://github.com/camertron)) on behalf of Lumos Labs, Inc.
138
+
139
+ ## License
140
+
141
+ Licensed under the MIT license.
@@ -0,0 +1,22 @@
1
+ $:.unshift File.join(File.dirname(__FILE__), 'lib')
2
+ require 'i18n/instrument/version'
3
+
4
+ Gem::Specification.new do |s|
5
+ s.name = 'i18n-instrument'
6
+ s.version = ::I18n::Instrument::VERSION
7
+ s.authors = ['Cameron Dutro']
8
+ s.email = ['camertron@gmail.com']
9
+ s.homepage = 'https://github.com/lumoslabs/i18n-instrument'
10
+
11
+ s.description = s.summary = 'Instrument calls to I18n.t in Ruby and JavaScript.'
12
+
13
+ s.platform = Gem::Platform::RUBY
14
+ s.has_rdoc = true
15
+
16
+ s.add_dependency 'i18n-debug', '~> 1.0'
17
+ s.add_dependency 'railties', '~> 4'
18
+ s.add_dependency 'request_store', '~> 1.0'
19
+
20
+ s.require_path = 'lib'
21
+ s.files = Dir['{lib,spec}/**/*', 'README.md', 'i18n-instrument.gemspec', 'LICENSE']
22
+ end
@@ -0,0 +1,33 @@
1
+ (function() {
2
+ if (I18n.translate == null) {
3
+ return;
4
+ }
5
+
6
+ var originalFunction = I18n.translate
7
+
8
+ // override original t and translate functions
9
+ I18n.t = I18n.translate = function(scope, options) {
10
+ if (I18n.instrumentation_enabled) {
11
+ // get fully qualified key
12
+ var opts = I18n.prepareOptions(options);
13
+ var key = I18n.getFullScope(scope, opts);
14
+ var data = {
15
+ key: key,
16
+ locale: I18n.locale,
17
+ url: window.location.href
18
+ };
19
+
20
+ // make request to instrumentation middleware
21
+ $.ajax({
22
+ url: '<%= I18n::Instrument.config.js_endpoint %>',
23
+ type: 'post',
24
+ data: JSON.stringify(data),
25
+ dataType: 'json',
26
+ headers: {'Content-Type': 'application/json'}
27
+ });
28
+ }
29
+
30
+ // use `call` to be able to pass in `I18n` as `this`
31
+ return originalFunction.call(I18n, scope, options);
32
+ }
33
+ })();
@@ -0,0 +1,20 @@
1
+ require 'i18n/instrument/railtie'
2
+
3
+ module I18n
4
+ module Instrument
5
+ autoload :Configurator, 'i18n/instrument/configurator'
6
+ autoload :Middleware, 'i18n/instrument/middleware'
7
+
8
+ class << self
9
+ attr_reader :config
10
+
11
+ def configure
12
+ @config = Configurator.new
13
+ yield @config if block_given?
14
+ end
15
+ end
16
+ end
17
+
18
+ # set default config
19
+ Instrument.configure
20
+ end
@@ -0,0 +1,43 @@
1
+ module I18n
2
+ module Instrument
3
+ class Configurator
4
+ DEFAULT_ENABLED_FILE = File.join('config', 'enable_i18n_instrumentation')
5
+ DEFAULT_JS_ENDPOINT = '/i18n/instrument.json'
6
+
7
+ attr_accessor :js_endpoint, :stack_trace_prefix
8
+
9
+ def initialize
10
+ @js_endpoint = DEFAULT_JS_ENDPOINT
11
+ @stack_trace_prefix = Rails.root.to_s
12
+
13
+ @on_check_enabled_proc = -> do
14
+ Rails.root.join(DEFAULT_ENABLED_FILE).exist?
15
+ end
16
+
17
+ @on_error_proc = ->(e) { raise e }
18
+ @on_record_proc = ->(*) { }
19
+ @on_lookup_proc = ->(*) { }
20
+ end
21
+
22
+ def on_lookup(&block)
23
+ block ? @on_lookup_proc = block : @on_lookup_proc
24
+ end
25
+
26
+ def on_record(&block)
27
+ block ? @on_record_proc = block : @on_record_proc
28
+ end
29
+
30
+ def on_error(&block)
31
+ block ? @on_error_proc = block : @on_error_proc
32
+ end
33
+
34
+ def on_check_enabled(&block)
35
+ block ? @on_check_enabled_proc = block : @on_check_enabled_proc
36
+ end
37
+
38
+ def enabled?
39
+ @on_check_enabled_proc.call
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,122 @@
1
+ require 'i18n/debug'
2
+ require 'request_store'
3
+
4
+ module I18n
5
+ module Instrument
6
+ class Middleware
7
+ # used to store request information in the request store
8
+ STORE_KEY = :i18n_instrumentation
9
+
10
+ # canned response sent to js instrumentation requests
11
+ JS_RESPONSE = [
12
+ 200, {
13
+ 'Content-Type' => 'application/json',
14
+ 'Content-Length' => 2
15
+ }, [
16
+ '{}'
17
+ ]
18
+ ]
19
+
20
+ def initialize(app)
21
+ @app = app
22
+
23
+ # this will fire on every call to I18n.t
24
+ I18n::Debug.on_lookup do |key, value|
25
+ next unless enabled?
26
+ config.on_lookup.call(key, value)
27
+
28
+ begin
29
+ # find the first application-specific line in the stack trace
30
+ raw_trace = filter_stack_trace(::Kernel.caller)
31
+ trace = raw_trace.split(":in `").first if raw_trace
32
+
33
+ # grab path params (set in `call` method below)
34
+ url = store.fetch(STORE_KEY, {}).fetch(:url, nil)
35
+
36
+ if url.present? && trace.present?
37
+ record_translation_lookup(
38
+ url: url, trace: trace, key: key,
39
+ locale: I18n.locale.to_s, source: 'ruby'
40
+ )
41
+ end
42
+ rescue => e
43
+ config.on_error.call(e)
44
+ end
45
+ end
46
+ end
47
+
48
+ def call(env)
49
+ if i18n_js_request?(env)
50
+ handle_i18n_js_request(env)
51
+ else
52
+ handle_regular_request(env)
53
+ end
54
+ rescue => e
55
+ config.on_error.call(e)
56
+ @app.call(env)
57
+ end
58
+
59
+ private
60
+
61
+ def filter_stack_trace(trace)
62
+ trace.find { |line| line.start_with?(config.stack_trace_prefix) }
63
+ end
64
+
65
+ def record_translation_lookup(url:, trace:, key:, locale:, source:)
66
+ config.on_record.call({
67
+ url: url, trace: trace, key: key,
68
+ source: source, locale: locale
69
+ })
70
+ end
71
+
72
+ def handle_regular_request(env)
73
+ return @app.call(env) unless enabled?
74
+
75
+ # store params from env in request storage so they can be used in the
76
+ # I18n on_lookup callback above
77
+ store[STORE_KEY] = { url: env['REQUEST_URI'] }
78
+
79
+ @app.call(env)
80
+ end
81
+
82
+ def handle_i18n_js_request(env)
83
+ return JS_RESPONSE unless enabled?
84
+
85
+ body = JSON.parse(env['rack.input'].read)
86
+ url = body['url']
87
+ key = body['key']
88
+ locale = body['locale']
89
+
90
+ if url.present? && key.present?
91
+ record_translation_lookup(
92
+ url: url, trace: nil, key: key,
93
+ locale: locale, source: 'javascript'
94
+ )
95
+ end
96
+
97
+ JS_RESPONSE
98
+ end
99
+
100
+ def i18n_js_request?(env)
101
+ env.fetch('REQUEST_URI', '').include?(js_endpoint) &&
102
+ env.fetch('REQUEST_METHOD', '').upcase == 'POST'
103
+ end
104
+
105
+ def config
106
+ I18n::Instrument.config
107
+ end
108
+
109
+ def js_endpoint
110
+ config.js_endpoint
111
+ end
112
+
113
+ def enabled?
114
+ config.enabled?
115
+ end
116
+
117
+ def store
118
+ RequestStore.store
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,10 @@
1
+ module I18n
2
+ module Instrument
3
+ class Railtie < Rails::Railtie
4
+ initializer 'i18n.instrument.middleware' do |app|
5
+ app.config.assets.paths << File.expand_path('../../../assets/javascripts', __FILE__)
6
+ app.middleware.use(I18n::Instrument::Middleware)
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,5 @@
1
+ module I18n
2
+ module Instrument
3
+ VERSION = '1.0.0'
4
+ end
5
+ end
data/spec/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require 'rails'
2
+ require 'action_controller/railtie'
3
+ require 'action_view/railtie'
4
+
5
+ require File.expand_path('../config/application', __FILE__)
6
+
7
+ I18n::Instrument::DummyApplication.initialize!
8
+ I18n::Instrument::DummyApplication.load_tasks