usernames 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/README.md +154 -7
  4. data/app/assets/javascripts/usernames/index.js +16 -0
  5. data/app/assets/javascripts/usernames/username_check_controller.js +191 -0
  6. data/app/controllers/usernames/availability_controller.rb +177 -0
  7. data/config/importmap.rb +8 -0
  8. data/config/locales/usernames.en.yml +6 -2
  9. data/config/locales/usernames.es.yml +2 -0
  10. data/config/locales/usernames.pt.yml +2 -0
  11. data/lib/generators/usernames/availability_generator.rb +85 -24
  12. data/lib/generators/usernames/install_generator.rb +24 -0
  13. data/lib/generators/usernames/install_messages.rb +41 -2
  14. data/lib/generators/usernames/javascript_generator.rb +38 -0
  15. data/lib/generators/usernames/javascript_setup.rb +124 -0
  16. data/lib/generators/usernames/templates/availability/_field.html.erb.tt +5 -15
  17. data/lib/generators/usernames/templates/usernames.rb +8 -2
  18. data/lib/usernames/configuration.rb +93 -3
  19. data/lib/usernames/engine.rb +74 -13
  20. data/lib/usernames/form_helpers.rb +215 -0
  21. data/lib/usernames/locale.rb +39 -0
  22. data/lib/usernames/model.rb +137 -26
  23. data/lib/usernames/reserved/listing.rb +1 -0
  24. data/lib/usernames/reserved.rb +18 -8
  25. data/lib/usernames/routes.rb +26 -0
  26. data/lib/usernames/rules/options.rb +9 -1
  27. data/lib/usernames/rules.rb +9 -1
  28. data/lib/usernames/targets.rb +65 -0
  29. data/lib/usernames/unique_violation.rb +0 -1
  30. data/lib/usernames/version.rb +1 -1
  31. data/lib/usernames.rb +3 -0
  32. data/sig/usernames.rbs +33 -6
  33. metadata +11 -3
  34. data/lib/generators/usernames/templates/availability/availability_controller.rb.tt +0 -107
  35. data/lib/generators/usernames/templates/availability/username_check_controller.js.tt +0 -115
@@ -1,7 +1,7 @@
1
1
  <%%# locals: (form:, attribute: :<%= column %>, label: nil) -%>
2
2
  <%%#
3
- A username field with live hints and an availability check.
4
- Generated by `rails generate usernames:availability`. It's yours: edit it.
3
+ A username field with live hints and the availability check.
4
+ Generated by `rails generate usernames:availability --copy`. It's yours: edit it.
5
5
 
6
6
  Render it inside a form, handing it the form builder:
7
7
 
@@ -10,16 +10,6 @@
10
10
  Hints are advisory: nothing here stops the form from submitting, and the
11
11
  model's validations have the last word.
12
12
  -%>
13
- <div data-controller="username-check"
14
- data-username-check-url-value="<%%= usernames_availability_path %>"
15
- data-username-check-param-value="<%= column %>"
16
- data-username-check-rules-value="<%%= <%= model_name %>.<%= column %>_rules_for_browser.to_json %>"
17
- data-username-check-lowercase-value="<%%= <%= model_name %>.<%= column %>_options[:case] != :preserve %>">
18
- <%%= form.label attribute, label %>
19
- <%%= form.text_field attribute,
20
- autocomplete: "username", autocapitalize: "none", autocorrect: "off", spellcheck: false,
21
- aria: { describedby: "#{form.field_id(attribute)}_hint #{form.field_id(attribute)}_status" },
22
- data: { username_check_target: "input", action: "input->username-check#check" } %>
23
- <p id="<%%= form.field_id(attribute) %>_hint" data-username-check-target="hint" aria-live="polite"></p>
24
- <p id="<%%= form.field_id(attribute) %>_status" data-username-check-target="status" aria-live="polite"></p>
25
- </div>
13
+ <%%= form.label attribute, label %>
14
+ <%%= form.username_field attribute %>
15
+ <%%= form.username_status attribute %>
@@ -1,11 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Every line below shows the default: uncomment one to change it.
3
+ # Every commented line below shows the default: uncomment one to change it.
4
4
  # The README documents the less common options (first_character,
5
5
  # reserve_routes, app_name, offensive.adapters, on_adapter_failure, generator,
6
- # unique_index). Every option is also accepted per model:
6
+ # unique_index, and the rest of config.availability). Every rule is also
7
+ # accepted per model:
7
8
  # has_username :handle, length: 2..24, case: :preserve
8
9
  Usernames.configure do |config|
10
+ # The username columns the live availability check answers for, by the
11
+ # alias the browser sends (`f.username_field` fills it in). Nothing else is
12
+ # ever looked up: a model with has_username is not public until it is here.
13
+ config.availability.targets = { <%= target_alias.inspect %> => <%= target.inspect %> }
14
+
9
15
  # How long a username may be, in characters.
10
16
  # config.length = 3..30
11
17
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/core_ext/integer/time"
4
+
3
5
  module Usernames
4
6
  # `Usernames.configure { |config| … }`. The generated initializer shows ten
5
7
  # of these options; the README documents the rest.
@@ -88,6 +90,89 @@ module Usernames
88
90
  end
89
91
  end
90
92
 
93
+ # config.availability: the built-in "is this name free?" endpoint. None
94
+ # of it is a rule, so nothing here touches the compiled rules.
95
+ class Availability
96
+ # The route drawn for the endpoint; nil or false draws none.
97
+ attr_accessor :path
98
+
99
+ # Which username columns the endpoint answers for, by an alias the
100
+ # browser sends: { "user" => "User#handle" }. Nothing else is ever
101
+ # looked up, so a model with has_username is not published by
102
+ # declaring it. The install generator writes yours.
103
+ attr_accessor :targets
104
+
105
+ # The class the endpoint inherits from, by name. nil: your
106
+ # ApplicationController when there is one (its locale and
107
+ # authentication callbacks apply), else ActionController::Base.
108
+ # "ActionController::API" works too.
109
+ attr_accessor :parent_controller
110
+
111
+ # Rails' rate_limit, per client IP: { to: 30, within: 1.minute }.
112
+ attr_accessor :rate_limit
113
+
114
+ # Where the rate limit counts. nil: Rails.cache. Read once, when the
115
+ # endpoint's class loads, so a change needs a restart.
116
+ attr_accessor :rate_limit_store
117
+
118
+ ALIAS = /\A[a-z0-9][a-z0-9_-]*\z/
119
+ TARGET = /\A[A-Z]\w*(?:::[A-Z]\w*)*#[a-z_]\w*\z/
120
+
121
+ def initialize
122
+ @path = Usernames::Routes::DEFAULT_PATH
123
+ @targets = {}.freeze
124
+ @parent_controller = nil
125
+ @rate_limit = { to: 30, within: 1.minute }.freeze
126
+ @rate_limit_store = nil
127
+ end
128
+
129
+ # The endpoint's superclass (see parent_controller). :nodoc:
130
+ def parent_class
131
+ name = parent_controller
132
+ name ||= Object.const_defined?(:ApplicationController) ? "ApplicationController" : "ActionController::Base"
133
+ Object.const_get(name)
134
+ end
135
+
136
+ def validate!
137
+ valid_path!
138
+ valid_targets!
139
+ unless parent_controller.nil? || (parent_controller.is_a?(String) && !parent_controller.empty?)
140
+ invalid!(:parent_controller, parent_controller, "a class name as a String, like \"ApplicationController\"")
141
+ end
142
+ valid_rate_limit!
143
+ end
144
+
145
+ private
146
+
147
+ def valid_path!
148
+ return if [nil, false].include?(path) || (path.is_a?(String) && path.match?(%r{\A/[^/?#\s]}))
149
+
150
+ invalid!(:path, path, "a path like \"/usernames/availability\", or nil to draw no route")
151
+ end
152
+
153
+ def valid_targets!
154
+ valid = targets.is_a?(Hash) && targets.all? do |name, target|
155
+ name.is_a?(String) && name.match?(ALIAS) && target.is_a?(String) && target.match?(TARGET)
156
+ end
157
+ return if valid
158
+
159
+ invalid!(:targets, targets, "a Hash of aliases to \"Model#column\" Strings, like { \"user\" => \"User#handle\" }")
160
+ end
161
+
162
+ def valid_rate_limit!
163
+ limit = rate_limit
164
+ valid = limit.is_a?(Hash) && limit.keys.sort == %i[to within] && limit[:to].is_a?(Integer) &&
165
+ limit[:to].positive? && limit[:within].is_a?(Numeric) && limit[:within].positive?
166
+ return if valid
167
+
168
+ invalid!(:rate_limit, limit, "{ to: 30, within: 1.minute }: a whole number of requests per period")
169
+ end
170
+
171
+ def invalid!(option, value, expected)
172
+ raise ConfigurationError, "config.availability.#{option} must be #{expected} (got #{value.inspect})"
173
+ end
174
+ end
175
+
91
176
  # Per-call word lists ADD to the configured ones: a model that passes
92
177
  # `reserved: %w[recipe]` keeps every bundled reservation. Every other
93
178
  # per-call option replaces the configured value. Adding is the choice
@@ -100,9 +185,9 @@ module Usernames
100
185
  CACHE_LIMIT = 64
101
186
 
102
187
  option :length, :separators, :edge_separators, :case, :first_character, :reserve_routes, :app_name,
103
- :reserved, :forbidden, :allowed, :on_adapter_failure, :unique_index
188
+ :reserved, :forbidden, :allowed, :reserved_if, :on_adapter_failure, :unique_index
104
189
 
105
- attr_reader :offensive, :generator
190
+ attr_reader :offensive, :generator, :availability
106
191
 
107
192
  def initialize
108
193
  @length = 3..30
@@ -115,10 +200,14 @@ module Usernames
115
200
  @reserved = Data.reserved
116
201
  @forbidden = Data.impersonation
117
202
  @allowed = [].freeze
203
+ # ->(name) { Page.exists?(slug: name) }: names reserved by your data
204
+ # rather than a list. nil: none.
205
+ @reserved_if = nil
118
206
  @on_adapter_failure = :allow
119
207
  @unique_index = nil
120
208
  @offensive = Offensive.new(self)
121
209
  @generator = Generator.new(self)
210
+ @availability = Availability.new
122
211
  end
123
212
 
124
213
  # `config.generator = ->(attempt) { … }` replaces the default shape;
@@ -140,6 +229,7 @@ module Usernames
140
229
 
141
230
  def validate!
142
231
  rules_for
232
+ availability.validate!
143
233
  self
144
234
  end
145
235
 
@@ -147,7 +237,7 @@ module Usernames
147
237
  {
148
238
  length: length, separators: separators, edge_separators: edge_separators, case: @case,
149
239
  first_character: first_character, reserve_routes: reserve_routes, app_name: app_name,
150
- reserved: reserved, forbidden: forbidden, allowed: allowed, offensive: offensive.to_h,
240
+ reserved: reserved, forbidden: forbidden, allowed: allowed, reserved_if: reserved_if, offensive: offensive.to_h,
151
241
  on_adapter_failure: on_adapter_failure, generator: generator.to_h, unique_index: unique_index
152
242
  }
153
243
  end
@@ -1,32 +1,93 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "form_helpers"
4
+ require_relative "routes"
5
+
3
6
  module Usernames
4
- # Not isolated, and no routes: the gem mounts nothing behind your back
5
- # (the availability check is a generator that copies files into the app).
7
+ # Not isolated. What it wires into the app, and how to turn each piece off:
6
8
  #
7
- # What it does do: its config/locales load BEFORE the app's — Rails puts
8
- # engines first and the application last — so any key in the app's own
9
- # locale files wins; `has_username` reaches every Active Record model; the
10
- # app's own routes are reserved and re-read whenever they reload; and a bad
11
- # configuration value fails at boot, not at the first sign-up.
9
+ # * `has_username` on every Active Record model.
10
+ # * Its config/locales, loaded BEFORE the app's — Rails puts engines first
11
+ # and the application last — so any key in the app's own locale files
12
+ # wins.
13
+ # * The app's own routes reserved, re-read whenever they reload
14
+ # (config.reserve_routes = false turns it off).
15
+ # * The availability endpoint, Usernames::AvailabilityController, at
16
+ # config.availability.path: one route appended after the app's own, and
17
+ # left out when the app already has one named usernames_availability
18
+ # (path = nil draws none).
19
+ # * `f.username_field` / `f.username_status` on every form builder.
20
+ # * With importmap-rails, the pins `usernames` and `usernames/username-check`;
21
+ # with Propshaft or Sprockets, the JavaScript behind them. `import
22
+ # "usernames"` in app/javascript/application.js is what uses them.
23
+ # * A bad configuration value fails at boot, not at the first sign-up.
12
24
  #
13
25
  # The generators (`usernames:install`, `usernames:availability`,
14
- # `usernames:locale`) need no wiring: Rails finds them in
15
- # lib/generators/usernames/ on the load path.
26
+ # `usernames:javascript`, `usernames:locale`) need no wiring: Rails finds
27
+ # them in lib/generators/usernames/ on the load path.
16
28
  class Engine < ::Rails::Engine
29
+ JAVASCRIPTS = %w[usernames/index.js usernames/username_check_controller.js].freeze
30
+
17
31
  initializer "usernames.active_record" do
18
32
  ActiveSupport.on_load(:active_record) { extend Usernames::Model }
19
33
  end
20
34
 
35
+ initializer "usernames.form_helpers" do
36
+ ActiveSupport.on_load(:action_view) { ActionView::Helpers::FormBuilder.include(Usernames::FormBuilder) }
37
+ end
38
+
39
+ # Once, before the routes are first drawn. The block runs on every
40
+ # draw (and redraw in development), reading the configuration as it is
41
+ # by then.
42
+ initializer "usernames.routes" do |app|
43
+ ActionDispatch::Routing::Mapper.include(Usernames::Routes)
44
+ routes = app.routes
45
+ routes.append { Usernames::Engine.draw_availability_route(self, routes) }
46
+ end
47
+
48
+ # Before importmap-rails reads its paths; nothing at all without it.
49
+ initializer "usernames.importmap", before: "importmap" do |app|
50
+ next unless defined?(::Importmap) && app.config.respond_to?(:importmap)
51
+
52
+ app.config.importmap.paths << root.join("config/importmap.rb")
53
+ app.config.importmap.cache_sweepers << root.join("app/assets/javascripts")
54
+ end
55
+
56
+ # Propshaft and Sprockets both find an engine's app/assets on their own;
57
+ # Sprockets also needs to be told to precompile them.
58
+ initializer "usernames.assets" do |app|
59
+ Usernames::Engine.add_assets(app.config.assets) if app.config.respond_to?(:assets) && app.config.assets
60
+ end
61
+
21
62
  # After every initializer, including the app's config/initializers/usernames.rb.
22
63
  initializer "usernames.validate_configuration" do |app|
23
64
  app.config.after_initialize { Usernames.configuration.validate! }
24
65
  end
25
66
 
26
- # The app's static top-level paths are reserved. They are
27
- # collected lazily, on the first check after the routes load; in
28
- # development the routes reload when config/routes.rb changes, and a
29
- # page added there is reserved from the next check on.
67
+ # The app's static top-level paths are reserved — the endpoint's own
68
+ # first segment (`usernames`) among them. They are collected lazily, on
69
+ # the first check after the routes load; in development the routes
70
+ # reload when config/routes.rb changes, and a page added there is
71
+ # reserved from the next check on.
30
72
  config.after_routes_loaded { Usernames::Reserved.reset_routes! }
73
+
74
+ class << self
75
+ # The appended route: drawn unless the path is off or the app drew one
76
+ # of that name itself. :nodoc:
77
+ def draw_availability_route(mapper, routes)
78
+ return unless Usernames.configuration.availability.path
79
+ return if routes.named_routes.key?(:usernames_availability)
80
+
81
+ mapper.usernames_availability
82
+ end
83
+
84
+ # :nodoc:
85
+ def add_assets(assets)
86
+ javascripts = root.join("app/assets/javascripts").to_s
87
+ paths = assets.paths
88
+ paths << javascripts if paths.is_a?(Array) && paths.none? { |path| path.to_s == javascripts }
89
+ assets.precompile |= JAVASCRIPTS if assets.precompile.is_a?(Array)
90
+ end
91
+ end
31
92
  end
32
93
  end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Usernames
6
+ # A username field with live hints and the availability check:
7
+ #
8
+ # <%= form_with model: @user do |f| %>
9
+ # <%= f.label :handle %>
10
+ # <%= f.username_field :handle %>
11
+ # <%= f.username_status :handle %>
12
+ # <% end %>
13
+ #
14
+ # The field is a text field the `username-check` Stimulus controller
15
+ # watches; the status is the live region it writes to. While someone
16
+ # types, the field shows the first format rule the name breaks, then
17
+ # whether it is free. Nothing blocks submission: the model decides.
18
+ #
19
+ # f.username_field :handle, availability: false # format hints only, no request
20
+ # f.username_field :handle, static: true # a plain text field
21
+ #
22
+ # Every other option is the text field's, `class:` included.
23
+ # `data: { controller:, action: }` and `aria: { describedby: }` are joined
24
+ # with the field's own as token lists; the rest of `data:` and `aria:` is
25
+ # merged in, and the field's own `username-check-*` values are the field's.
26
+ #
27
+ # The input's id is `field_id(:handle)` (`user_handle`), the status's
28
+ # `field_id(:handle, :status)`. Two forms for the same record on one page
29
+ # need different ones: pass `namespace:` to form_with, or the same `id:`
30
+ # to both helpers.
31
+ module FormBuilder
32
+ def username_field(column = :username, availability: true, static: false, **html)
33
+ column = column.to_sym
34
+ html = Field.attributes(html)
35
+ return text_field(column, Field::TYPING.merge(html)) if static
36
+
37
+ own, input = username_field_ids(column, html)
38
+ check = Field.check(@template, object, column, id: input, availability: availability)
39
+ return text_field(column, Field::TYPING.merge(html)) if check.nil?
40
+
41
+ text_field(column, Field.combine(Field::TYPING.merge(html), check).merge(id: own))
42
+ end
43
+
44
+ # <output id="user_handle_status" for="user_handle" aria-live="polite">,
45
+ # empty until someone types; the controller sets its data-state
46
+ # (checking, free, taken, invalid, unchanged, error) for styling.
47
+ # Pass the field's `id:`, `namespace:` or `index:` here too if you gave it them.
48
+ def username_status(column = :username, **html)
49
+ html = Field.attributes(html)
50
+ _own, input = username_field_ids(column, html)
51
+ own = { id: "#{input}_status", for: input, aria: { "live" => "polite" } }
52
+ @template.tag.output("", **Field.combine(html.except(:id, :namespace, :index), own))
53
+ end
54
+
55
+ private
56
+
57
+ # The id the field gives Rails, and the id Rails renders from it. Given
58
+ # explicitly, so the input has the id the status points at even where
59
+ # form_with generates none; Rails prefixes the namespace to it (the
60
+ # field's own `namespace:`, else the form's), so this does too, and a
61
+ # field's own `index:` is in its id as in its name.
62
+ def username_field_ids(column, html)
63
+ own = html.fetch(:id) { field_id(column, namespace: nil, index: html.fetch(:index) { options[:index] }) }
64
+ [own, username_input_id(own, html.fetch(:namespace) { options[:namespace] })]
65
+ end
66
+
67
+ # An input's id as Rails renders it: the namespace, then the id.
68
+ def username_input_id(id, namespace)
69
+ namespace ? "#{namespace}_#{id}" : id
70
+ end
71
+ end
72
+
73
+ # The attributes behind the helpers. :nodoc:
74
+ module Field
75
+ # What tells a browser this is a username, not prose. Overridable:
76
+ # `autocomplete: "nickname"` for a public handle that isn't a login.
77
+ TYPING = { autocomplete: "username", autocapitalize: "none", autocorrect: "off", spellcheck: false }.freeze
78
+
79
+ # Space-separated lists, where the caller's values join the field's own.
80
+ TOKEN_LISTS = %w[data-controller data-action aria-describedby].freeze
81
+
82
+ NESTED = %w[data aria].freeze
83
+
84
+ MISSING_TARGET_LOGGED = Concurrent::AtomicBoolean.new(false)
85
+
86
+ class << self
87
+ # The data and aria attributes for a checked field, or nil when the
88
+ # field can only be plain (a production form whose column is not a target).
89
+ def check(template, object, column, id:, availability:)
90
+ model = model_of!(object, column)
91
+ endpoint = availability ? endpoint(template, model, column) : {}
92
+ return if endpoint == :missing
93
+
94
+ data = hints(object, model, column).merge("username-check-status-id-value" => "#{id}_status", **endpoint)
95
+ { data: data.compact, aria: { "describedby" => "#{id}_status" } }
96
+ end
97
+
98
+ # The form's model, which must have declared has_username for `column`.
99
+ def model_of!(object, column)
100
+ model = object.class
101
+ return model if model.respond_to?(:_usernames) && model._usernames.key?(column)
102
+
103
+ raise ArgumentError, "username_field :#{column} needs a form for a model with has_username :#{column} " \
104
+ "(got #{object.inspect})"
105
+ end
106
+
107
+ # `data-…` and `aria-…` given as plain keys, moved into data: and
108
+ # aria:, so there is one place to merge them.
109
+ def attributes(html)
110
+ html.each_with_object({}) do |(key, value), attributes|
111
+ nested, name = key.to_s.split("-", 2)
112
+ if value.is_a?(Hash)
113
+ nested = key.to_s
114
+ value = value.transform_keys { |each| each.to_s.dasherize }
115
+ end
116
+ next attributes[key.to_sym] = value unless NESTED.include?(nested)
117
+
118
+ (attributes[nested.to_sym] ||= {}).merge!(name ? { name => value } : value)
119
+ end
120
+ end
121
+
122
+ # The caller's attributes with the field's own over them: token lists
123
+ # joined, data and aria merged (deeply), the field's values winning for
124
+ # its own keys.
125
+ def combine(theirs, ours)
126
+ theirs.merge(ours) do |key, given, own|
127
+ next own unless given.is_a?(Hash) && own.is_a?(Hash)
128
+
129
+ given.deep_merge(own) { |name, mine, field| token_list?("#{key}-#{name}") ? tokens(mine, field) : field }
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ # What the browser needs for its format hints: the rules (in the model's
136
+ # words), the case mode and the saved name. Every value a String, and
137
+ # the rules encoded by the json gem itself, so rendering a field never
138
+ # depends on Active Support's JSON encoder.
139
+ def hints(object, model, column)
140
+ {
141
+ "controller" => "username-check",
142
+ "action" => "input->username-check#check",
143
+ "username-check-rules-value" => JSON.generate(model.public_send(:"#{column}_rules_for_browser")),
144
+ "username-check-lowercase-value" => (model._usernames.fetch(column).mode != :preserve).to_s,
145
+ "username-check-persisted-value" => persisted(object, column)
146
+ }
147
+ end
148
+
149
+ # The endpoint's URL and this column's alias in config.availability.targets.
150
+ def endpoint(template, model, column)
151
+ url = url(template)
152
+ return {} if url.nil?
153
+
154
+ target = Targets.alias_for(model, column)
155
+ return missing_target(model, column) if target.nil?
156
+
157
+ { "username-check-url-value" => url, "username-check-target-value" => target,
158
+ "username-check-error-message-value" => error_message }
159
+ end
160
+
161
+ # In the page's language, else in English, never "Translation missing"
162
+ # (see Usernames::Locale).
163
+ def error_message
164
+ Locale.t(:usernames, :availability, :error)
165
+ end
166
+
167
+ # Through the app's route helper (`main_app` from inside an engine's
168
+ # views), so a sub-URI deployment's SCRIPT_NAME is in it. None when
169
+ # the app has no such route: the field then only hints.
170
+ def url(template)
171
+ routes = template.respond_to?(:main_app) ? template.main_app : template
172
+ routes.usernames_availability_path if routes.respond_to?(:usernames_availability_path)
173
+ end
174
+
175
+ # Publishing a column takes a line in config.availability.targets: in
176
+ # development and test, saying so loudly; in production, a plain
177
+ # field and one log line rather than a broken page.
178
+ def missing_target(model, column)
179
+ target = "#{model.name}##{column}"
180
+ why = "no entry in config.availability.targets names #{target}: add " \
181
+ "{ #{model.name.to_s.underscore.tr('/', '_').inspect} => #{target.inspect} } to it, or pass " \
182
+ "availability: false"
183
+ raise ConfigurationError, "username_field :#{column} can't check availability: #{why}" if Rails.env.local?
184
+
185
+ Rails.logger.error "[usernames] username_field :#{column} renders without its check: #{why}." if
186
+ MISSING_TARGET_LOGGED.make_true
187
+ :missing
188
+ end
189
+
190
+ def persisted(object, column)
191
+ object.attribute_in_database(column).presence if object.respond_to?(:persisted?) && object.persisted?
192
+ end
193
+
194
+ def token_list?(name)
195
+ TOKEN_LISTS.include?(name)
196
+ end
197
+
198
+ # Joined as Rails joins a token list (token_list, class_names): Arrays
199
+ # flattened, a Hash keeping the keys whose values are truthy, nil and
200
+ # false left out; then split on whitespace and deduplicated.
201
+ def tokens(*lists)
202
+ lists.flat_map { |list| token_values(list) }.flat_map { |value| value.to_s.split }.uniq.join(" ")
203
+ end
204
+
205
+ def token_values(value)
206
+ case value
207
+ when Hash then value.filter_map { |token, wanted| token if wanted }
208
+ when Array then value.flat_map { |each| token_values(each) }
209
+ when nil, false then []
210
+ else [value]
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Usernames
6
+ # The gem's own messages that are not a record's errors — what the
7
+ # availability check says — looked up so they never read
8
+ # "Translation missing":
9
+ #
10
+ # 1. in the current locale, so the app's own translation wins;
11
+ # 2. else in English as I18n has it (the app's English wording, if any);
12
+ # 3. else in the gem's English, read from config/locales/usernames.en.yml
13
+ # itself — for an app whose available locales leave English out, where
14
+ # I18n never loads it.
15
+ #
16
+ # The YAML file stays the one place the words are written. :nodoc:
17
+ module Locale
18
+ ENGLISH = File.expand_path("../../config/locales/usernames.en.yml", __dir__)
19
+
20
+ class << self
21
+ # Locale.t(:usernames, :availability, :error) # => "couldn't be checked just now"
22
+ def t(*keys, key)
23
+ I18n.t(key, scope: keys, raise: true)
24
+ rescue I18n::MissingTranslationData
25
+ if I18n.backend.exists?(:en, key, scope: keys)
26
+ I18n.backend.translate(:en, key, scope: keys)
27
+ else
28
+ english(*keys, key)
29
+ end
30
+ end
31
+
32
+ # The gem's own English for these keys, from the shipped file.
33
+ def english(*keys)
34
+ @english ||= YAML.safe_load(File.read(ENGLISH, encoding: "UTF-8")).fetch("en").freeze
35
+ @english.dig(*keys.map(&:to_s))
36
+ end
37
+ end
38
+ end
39
+ end