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
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usernames
4
+ # GET /usernames/availability?username=javi&target=user
5
+ # => { "state": "free" | "taken" | "invalid", "message": "…" }
6
+ #
7
+ # The endpoint behind `f.username_field`. The engine draws its route
8
+ # (config.availability.path); `target` is an alias from
9
+ # config.availability.targets, and any other value is a 404.
10
+ #
11
+ # An availability endpoint tells anyone which names exist — that is its
12
+ # job — so it comes with guard rails:
13
+ #
14
+ # * Rails' own rate_limit (config.availability.rate_limit), counted in
15
+ # config.availability.rate_limit_store (Rails.cache by default), read
16
+ # once when this class loads. A store that cannot count — the NullStore,
17
+ # Rails' default in development and test, or a backend that is down —
18
+ # makes the limiter count nothing, so outside development and test the
19
+ # endpoint answers 503, with a log line, until it can. A MemoryStore
20
+ # works, but per process: each worker counts on its own, so it logs a
21
+ # warning once.
22
+ # * Taken, reserved and not-allowed names all answer "taken", with one
23
+ # message: no list, matched word or reason is ever sent to the browser.
24
+ # * Never cached (no-store), whatever the answer, and advisory only: the
25
+ # model's validations and the unique index decide at save time.
26
+ #
27
+ # It inherits config.availability.parent_controller (your
28
+ # ApplicationController by default), so that controller's locale and
29
+ # authentication callbacks apply. Copy it into your app to change it:
30
+ # `rails generate usernames:availability --copy`.
31
+ class AvailabilityController < Usernames.configuration.availability.parent_class
32
+ # Raised by the counter when the rate limit store cannot count.
33
+ class CounterUnavailable < StandardError; end
34
+
35
+ # The store Rails' rate_limit writes to, wrapped so that a count that
36
+ # did not happen is noticed: Rails' limiter lets a request through when
37
+ # increment returns nil, which is exactly what a NullStore, or a cache
38
+ # whose backend is down, returns.
39
+ class Counter
40
+ attr_reader :store
41
+
42
+ def initialize(store)
43
+ @store = store
44
+ @warned = Concurrent::AtomicBoolean.new(false)
45
+ end
46
+
47
+ def increment(name, amount = 1, **)
48
+ count = begin
49
+ store.increment(name, amount, **)
50
+ rescue StandardError => e
51
+ failure = e
52
+ nil
53
+ end
54
+ return count if count.is_a?(Integer)
55
+
56
+ unavailable!(failure)
57
+ end
58
+
59
+ def null?
60
+ store.is_a?(ActiveSupport::Cache::NullStore)
61
+ end
62
+
63
+ def memory?
64
+ store.is_a?(ActiveSupport::Cache::MemoryStore)
65
+ end
66
+
67
+ # Why nothing was counted, for the log.
68
+ def problem(failure = nil)
69
+ if null? then "the rate limit store is the NullStore, where Rails' rate_limit counts nothing"
70
+ elsif failure then "the rate limit store failed to count (#{failure.class}: #{failure.message})"
71
+ else "the rate limit store's increment returned nothing, so Rails' rate_limit counts nothing"
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ # In development and test an unlimited check is the useful one: log
78
+ # once and let it through. Anywhere else, refuse.
79
+ def unavailable!(failure)
80
+ raise CounterUnavailable, problem(failure) unless Rails.env.local?
81
+
82
+ if @warned.make_true
83
+ Rails.logger.warn "[usernames] The availability check isn't rate limited here: #{problem(failure)}. " \
84
+ "Outside development and test it answers 503 until the store can count."
85
+ end
86
+ 0
87
+ end
88
+ end
89
+
90
+ # Captured once: the very object the limiter writes to.
91
+ RATE_LIMIT = Counter.new(Usernames.configuration.availability.rate_limit_store || Rails.cache)
92
+
93
+ MEMORY_STORE_WARNED = Concurrent::AtomicBoolean.new(false)
94
+
95
+ # Format failures are shown as they are (the browser hints them anyway);
96
+ # reserved and not-allowed names are reported as taken (see above).
97
+ FORMAT_FAILURES = Rules.format_reasons
98
+
99
+ # Far beyond any username: what is longer is cut here, before the
100
+ # normaliser sees it, and still breaks the length rule.
101
+ LONGEST = 1024
102
+
103
+ # Before everything else, the limiter included: an answer about who
104
+ # exists is never cached, and neither is a 404, a 429 or a 503.
105
+ prepend_before_action :no_store
106
+
107
+ # A counter of its own: an unnamed limiter the parent declares would key
108
+ # on this controller's path too, and the two would share one count and
109
+ # one expiry — a parent's shorter window resetting this one, a longer
110
+ # one spending it twice. Rails 8 takes a name for that; Rails 7.2 keys
111
+ # only on `by`, so there the name goes into it.
112
+ OWN_COUNTER =
113
+ if instance_method(:rate_limiting).parameters.any? { |_kind, name| name == :name }
114
+ { name: "usernames/availability" }
115
+ else
116
+ { by: -> { "usernames/availability:#{request.remote_ip}" } }
117
+ end
118
+
119
+ rate_limit(**Usernames.configuration.availability.rate_limit, **OWN_COUNTER, store: RATE_LIMIT,
120
+ with: -> { head :too_many_requests })
121
+
122
+ before_action :warn_about_a_memory_store
123
+ # After the limiter, so guessing aliases is rate limited too.
124
+ before_action :find_the_target
125
+
126
+ rescue_from CounterUnavailable do |error|
127
+ Rails.logger.error "[usernames] The availability check is off (503): #{error.message}. " \
128
+ "Use a shared store that counts: Solid Cache, Redis or Memcached."
129
+ head :service_unavailable
130
+ end
131
+
132
+ def show
133
+ render json: answer_for(typed)
134
+ end
135
+
136
+ private
137
+
138
+ def find_the_target
139
+ @model, @column = Targets.find(params[:target])
140
+ head :not_found unless @model
141
+ end
142
+
143
+ def typed
144
+ value = params[:username]
145
+ value.is_a?(String) ? value[0, LONGEST] : ""
146
+ end
147
+
148
+ # Every message in the model's own words: a translation under
149
+ # activerecord.errors.models.<model>.attributes.<column> wins here as on
150
+ # the form.
151
+ def answer_for(value)
152
+ result = @model.public_send(:"check_#{@column}", value)
153
+
154
+ if result.canonical.empty?
155
+ { state: "invalid", message: message(:blank) }
156
+ elsif FORMAT_FAILURES.include?(result.reason)
157
+ { state: "invalid", message: result.message }
158
+ elsif result.ok? && @model.public_send(:"#{@column}_available?", result.canonical)
159
+ { state: "free", message: Locale.t(:usernames, :available) }
160
+ else
161
+ { state: "taken", message: message(:taken) }
162
+ end
163
+ end
164
+
165
+ def message(type)
166
+ @model.new.errors.generate_message(@column, type)
167
+ end
168
+
169
+ def warn_about_a_memory_store
170
+ return unless RATE_LIMIT.memory? && MEMORY_STORE_WARNED.make_true
171
+
172
+ Rails.logger.warn "[usernames] The availability check's rate limit uses a MemoryStore, which each " \
173
+ "process counts on its own. Use a shared store (Solid Cache, Redis, Memcached) for one " \
174
+ "limit per client."
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drawn into the app's importmap by the engine (importmap-rails only).
4
+ # `import "usernames"` registers the username-check Stimulus controller on the
5
+ # app's own Stimulus application; "usernames/username-check" is the controller
6
+ # alone, for an app that registers it itself.
7
+ pin "usernames", to: "usernames/index.js"
8
+ pin "usernames/username-check", to: "usernames/username_check_controller.js"
@@ -48,9 +48,13 @@ en:
48
48
  one: "is too long (maximum is 1 character)"
49
49
  other: "is too long (maximum is %{count} characters)"
50
50
  usernames:
51
- # What the generated availability check says, in its live region, when
52
- # a name is free to take.
51
+ # What the availability check says, in its live region, when a name is
52
+ # free to take…
53
53
  available: "is available"
54
+ availability:
55
+ # …and when it could not ask (offline, rate limited, switched off).
56
+ # The form still submits; the model decides.
57
+ error: "couldn't be checked just now"
54
58
  # The pieces the phrases above are built from.
55
59
  letters:
56
60
  lowercase: "lowercase letters"
@@ -32,6 +32,8 @@ es:
32
32
  other: "es demasiado largo (%{count} caracteres máximo)"
33
33
  usernames:
34
34
  available: "está disponible"
35
+ availability:
36
+ error: "no se pudo comprobar ahora"
35
37
  letters:
36
38
  lowercase: "letras minúsculas"
37
39
  any_case: "letras"
@@ -32,6 +32,8 @@ pt:
32
32
  other: "é muito longo (máximo: %{count} caracteres)"
33
33
  usernames:
34
34
  available: "está disponível"
35
+ availability:
36
+ error: "não foi possível verificar agora"
35
37
  letters:
36
38
  lowercase: "letras minúsculas"
37
39
  any_case: "letras"
@@ -4,58 +4,119 @@ require "rails/generators/base"
4
4
 
5
5
  module Usernames
6
6
  module Generators
7
- # rails generate usernames:availability [--model=User] [--column=username]
7
+ # rails generate usernames:availability [--copy] [--model=User] [--column=username]
8
8
  #
9
- # Copies a live "is this name free?" check into the app:
10
- # nothing is mounted behind its back, and every file is the app's to edit.
9
+ # The live "is this name free?" check is built in: the gem's endpoint,
10
+ # its route and its Stimulus controller, used by `f.username_field`. On
11
+ # its own this generator says so, and — when it finds the files a 0.1
12
+ # install copied into the app — what to do with them.
11
13
  #
12
- # app/controllers/usernames/availability_controller.rb GET /usernames/availability?<column>=…
14
+ # --copy writes a set of your own to change, which replaces the gem's:
15
+ #
16
+ # app/controllers/usernames/availability_controller.rb the endpoint (the gem's class, overridden)
13
17
  # app/javascript/controllers/username_check_controller.js
14
18
  # app/views/usernames/_field.html.erb <%= render "usernames/field", form: form %>
19
+ # config/routes.rb usernames_availability, at the top
15
20
  class AvailabilityGenerator < Rails::Generators::Base
16
21
  source_root File.expand_path("templates/availability", __dir__)
17
22
 
18
- desc "Copy a rate-limited availability endpoint, a Stimulus controller and a form partial into your app."
23
+ desc "Explain the built-in availability check, or copy it into your app (--copy) to customise it."
19
24
 
25
+ class_option :copy, type: :boolean, default: false, desc: "Copy the endpoint, the JavaScript and a partial"
20
26
  class_option :model, type: :string, default: "User", desc: "The model with has_username"
21
27
  class_option :column, type: :string, default: "username", desc: "Its username column"
22
28
 
23
- def create_controller
24
- template "availability_controller.rb.tt", "app/controllers/usernames/availability_controller.rb"
29
+ GEM = File.expand_path("../../..", __dir__)
30
+ CONTROLLER = "app/controllers/usernames/availability_controller.rb"
31
+ JAVASCRIPT = "app/javascript/controllers/username_check_controller.js"
32
+ PARTIAL = "app/views/usernames/_field.html.erb"
33
+ ROUTES = "config/routes.rb"
34
+
35
+ # Read before anything is written, so --copy's own files are not mistaken for 0.1's.
36
+ def look_for_copied_files
37
+ @found = [CONTROLLER, JAVASCRIPT, PARTIAL].select { |path| File.exist?(File.join(destination_root, path)) }
38
+ @found << ROUTES if routes.include?("usernames/availability#show")
39
+ end
40
+
41
+ def copy_the_endpoint
42
+ return unless options[:copy]
43
+
44
+ create_file CONTROLLER, copied("#", File.read(File.join(GEM, CONTROLLER), encoding: "UTF-8"))
25
45
  end
26
46
 
27
- # Thor injects a line only once, so a re-run leaves routes.rb alone.
28
- def add_route
29
- route %(get "usernames/availability", to: "usernames/availability#show", as: :usernames_availability)
47
+ def copy_the_javascript
48
+ return unless options[:copy]
49
+
50
+ source = File.join(GEM, "app/assets/javascripts/usernames/username_check_controller.js")
51
+ create_file JAVASCRIPT, copied("//", File.read(source, encoding: "UTF-8"))
30
52
  end
31
53
 
32
- def create_stimulus_controller
33
- template "username_check_controller.js.tt", "app/javascript/controllers/username_check_controller.js"
54
+ def copy_the_partial
55
+ template "_field.html.erb.tt", PARTIAL if options[:copy]
34
56
  end
35
57
 
36
- def create_partial
37
- template "_field.html.erb.tt", "app/views/usernames/_field.html.erb"
58
+ # Above every other route, so a catch-all can't shadow it; the engine
59
+ # then draws none. Thor injects a line only once.
60
+ def draw_the_route
61
+ route "usernames_availability" if options[:copy]
38
62
  end
39
63
 
40
64
  def display_post_install_message
41
- say "\n\tThe `usernames` availability check has been successfully installed!", :green
42
- say "\nTo complete the setup:"
43
- say " 1. Render the field in your sign-up form: <%= render \"usernames/field\", form: form %>"
44
- say " 2. If you don't use importmap's eagerLoadControllersFrom, register the Stimulus controller " \
45
- "(rails stimulus:manifest:update)."
46
- say " 3. Give production a shared cache store (Solid Cache, Redis or Memcached). With the NullStore, " \
47
- "Rails' rate_limit counts nothing, so the endpoint answers 503 there until you do."
48
- say "\nThe route is declared at the top of config/routes.rb, so a catch-all /:#{column} below it can't shadow it."
49
- say "Hints in the browser never block submission: the model and its unique index decide.", :green
65
+ options[:copy] ? copied_message : built_in_message
66
+ upgrade_message unless @found.empty? || options[:copy]
50
67
  end
51
68
 
52
69
  private
53
70
 
71
+ def built_in_message
72
+ say "\n\tThe availability check is built into the usernames gem: nothing to generate.", :green
73
+ say "\n <%= f.username_field :#{column} %> <%= f.username_status :#{column} %> in the form"
74
+ say %( import "usernames" in app/javascript/application.js)
75
+ say %( config.availability.targets = { #{target_alias.inspect} => "#{model_name}##{column}" } in the initializer)
76
+ say "\nTo change how it works, copy it into your app: rails generate usernames:availability --copy"
77
+ end
78
+
79
+ # A 0.1 install copied a controller, a route, a Stimulus controller and
80
+ # a partial that speak an older protocol. Either mode keeps the pair
81
+ # consistent; mixing them does not.
82
+ def upgrade_message
83
+ say "\nFound files a 0.1 install copied into this app: #{@found.join(', ')}.", :yellow
84
+ say "They speak the 0.1 protocol, and the app's copies win over the gem's. Pick one:"
85
+ say " * Managed (recommended): delete them all — the controller, the usernames/availability route, " \
86
+ "the Stimulus controller and the partial — and use f.username_field / f.username_status with " \
87
+ "`import \"usernames\"`. Upgrades then come with the gem."
88
+ say " * Customized: keep your pair, and set config.availability.path = nil so the gem draws no route. " \
89
+ "Don't `import \"usernames\"`: your Stimulus controller is the one registered as username-check."
90
+ end
91
+
92
+ def copied_message
93
+ say "\n\tThe availability check is now yours to change.", :green
94
+ say "\n #{CONTROLLER} replaces the gem's endpoint (same class); `usernames_availability` in config/routes.rb " \
95
+ "draws its route, above any catch-all, so the gem draws none."
96
+ say " #{JAVASCRIPT} is registered by your Stimulus setup as username-check: remove " \
97
+ "`import \"usernames\"` from app/javascript/application.js, or the gem's would register over it."
98
+ say %( Render the field with <%= render "usernames/field", form: form %>.)
99
+ say " The endpoint answers for config.availability.targets, as the gem's did: keep " \
100
+ "{ #{target_alias.inspect} => \"#{model_name}##{column}\" } there."
101
+ say "Gem upgrades no longer change these files: compare them with the gem's when you upgrade.", :yellow
102
+ end
103
+
104
+ def copied(comment, source)
105
+ note = "#{comment} Copied from the usernames gem by `rails generate usernames:availability --copy`. " \
106
+ "It's yours: edit it.\n"
107
+ source.start_with?("# frozen_string_literal") ? source.sub("\n", "\n\n#{note}") : note + source
108
+ end
109
+
110
+ def routes
111
+ path = File.join(destination_root, ROUTES)
112
+ File.exist?(path) ? File.read(path) : ""
113
+ end
114
+
54
115
  def model_name
55
116
  options[:model].camelize
56
117
  end
57
118
 
58
- def singular
119
+ def target_alias
59
120
  model_name.underscore.tr("/", "_")
60
121
  end
61
122
 
@@ -4,6 +4,7 @@ require "rails/generators/base"
4
4
  require "rails/generators/active_record"
5
5
  require_relative "existing_schema"
6
6
  require_relative "install_messages"
7
+ require_relative "javascript_setup"
7
8
 
8
9
  module Usernames
9
10
  module Generators
@@ -20,9 +21,14 @@ module Usernames
20
21
  # added. A column that already holds usernames gets the adoption workflow
21
22
  # instead of a promise: the index cannot be trusted — or even added —
22
23
  # until `rails usernames:audit` comes back clean.
24
+ #
25
+ # The initializer publishes that column to the live availability check
26
+ # (config.availability.targets), and in an importmap + Stimulus app the
27
+ # generator adds `import "usernames"` to app/javascript/application.js, once.
23
28
  class InstallGenerator < Rails::Generators::Base
24
29
  include ActiveRecord::Generators::Migration
25
30
  include InstallMessages
31
+ include JavascriptSetup
26
32
 
27
33
  source_root File.expand_path("templates", __dir__)
28
34
 
@@ -65,6 +71,19 @@ module Usernames
65
71
  migration_template "add_username.rb.erb", File.join(db_migrate_path, "#{migration_name}.rb")
66
72
  end
67
73
 
74
+ # Only where the line can work: importmap (nothing else resolves it),
75
+ # the entrypoint, and stimulus-rails' controllers/application.js
76
+ # exporting the `application` it registers on. Never twice, and never
77
+ # removing anything.
78
+ def add_the_javascript_import
79
+ return unless javascript_setup == :ready
80
+
81
+ separator = File.read(destination(APPLICATION_JS)).end_with?("\n") ? "" : "\n"
82
+ # force: Thor would skip text it finds anywhere in the file, even in a
83
+ # comment; javascript_setup has already said the import is missing.
84
+ append_to_file APPLICATION_JS, %(#{separator}import "usernames"\n), force: true
85
+ end
86
+
68
87
  def display_post_install_message
69
88
  say "\n\tThe `usernames` gem has been successfully installed!", :green
70
89
  warn_about_the_database unless database.reachable?
@@ -74,6 +93,11 @@ module Usernames
74
93
 
75
94
  private
76
95
 
96
+ # The initializer's line: config.availability.targets = { "user" => "User#handle" }.
97
+ def target_alias = model_name.underscore.tr("/", "_")
98
+
99
+ def target = "#{model_name}##{column}"
100
+
77
101
  # --- what the migration does -----------------------------------------------------------------
78
102
 
79
103
  # A file name and a constant, whatever the table is called:
@@ -12,10 +12,9 @@ module Usernames
12
12
  say "\nTo complete the setup:"
13
13
  say " 1. Run 'rails db:migrate'#{migration_summary}."
14
14
  say " 2. Add `#{macro_line}` to your #{model_name} model."
15
- say " 3. Optionally, run 'rails generate usernames:availability' for a live \"is this name free?\" " \
16
- "check on your sign-up form."
17
15
  say "\nUsernames are unique from the moment the index exists: the database decides, " \
18
16
  "with the same comparison the gem uses everywhere."
17
+ integration_message
19
18
  end
20
19
 
21
20
  # On a populated column no index is a promise — not even one already in
@@ -35,8 +34,48 @@ module Usernames
35
34
  say " 4. Add `#{macro_line}` to your #{model_name} model. Existing names that break today's rules " \
36
35
  "keep working; only a change is validated."
37
36
  say " See \"Adopting an existing column\" in the README."
37
+ integration_message
38
38
  end
39
39
 
40
+ # Then, in the forms: the field and its status, and the one line of
41
+ # JavaScript behind them.
42
+ def integration_message
43
+ say "\nThen, in any form for a #{model_name}, a username field that checks names as people type:"
44
+ say " <%= f.label :#{column} %> <%= f.username_field :#{column} %> <%= f.username_status :#{column} %>"
45
+ say " #{JAVASCRIPT_STEPS.fetch(javascript_setup)}"
46
+ devise_message if defined?(::Devise)
47
+ say " In production, the check counts requests in Rails.cache: give it a shared store (Solid Cache, " \
48
+ "Redis, Memcached). It answers 503 until one counts."
49
+ end
50
+
51
+ # Devise permits only its own attributes: without these lines a sign-up
52
+ # form says the name is free, and the save drops it.
53
+ def devise_message
54
+ say " Devise drops attributes it doesn't know, so permit :#{column} where people choose or change " \
55
+ "it (in ApplicationController, before_action ... if: :devise_controller?):"
56
+ say " devise_parameter_sanitizer.permit(:sign_up, keys: [:#{column}])"
57
+ say " devise_parameter_sanitizer.permit(:account_update, keys: [:#{column}])"
58
+ end
59
+
60
+ JAVASCRIPT_STEPS = {
61
+ ready: %(Added `import "usernames"` to app/javascript/application.js.),
62
+ imported: %(app/javascript/application.js already has `import "usernames"`.),
63
+ no_importmap: "No importmap here: run 'rails generate usernames:javascript' to copy the Stimulus controller " \
64
+ "into app/javascript/controllers (then it is yours to keep up to date).",
65
+ no_entrypoint: %(No app/javascript/application.js: add `import "usernames"` to your importmap entrypoint.),
66
+ no_stimulus: "No app/javascript/controllers/application.js (stimulus-rails' Stimulus setup): the field " \
67
+ "works as a plain input; with stimulus-rails, add `import \"usernames\"` for the live check.",
68
+ uncertain_source: "Didn't add `import \"usernames\"`: app/javascript/controllers/application.js has " \
69
+ "constructs the installer won't guess at (template interpolation or a regular " \
70
+ "expression). Add the line yourself if that file exports `application`.",
71
+ no_application_export: "Didn't add `import \"usernames\"`: app/javascript/controllers/application.js " \
72
+ "doesn't export the Stimulus application by the name `application` " \
73
+ "(`export { application }`), which it imports, and a missing export would stop " \
74
+ "app/javascript/application.js loading at all. Export it under that name and add the " \
75
+ "line, or register \"usernames/username-check\" on your application yourself."
76
+ }.freeze
77
+ private_constant :JAVASCRIPT_STEPS
78
+
40
79
  def index_doubt
41
80
  return "the unique index isn't trustworthy yet (it may not even be addable)" if add_index?
42
81
 
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Usernames
6
+ module Generators
7
+ # rails generate usernames:javascript
8
+ #
9
+ # For an app that bundles its JavaScript (jsbundling, esbuild, Vite…)
10
+ # rather than using importmap, where `import "usernames"` has nothing to
11
+ # resolve it: copies the gem's Stimulus controller to
12
+ # app/javascript/controllers/username_check_controller.js, where the
13
+ # bundler and Stimulus' manifest pick it up. From then on the file is
14
+ # the app's: run this again after a gem upgrade to refresh it.
15
+ class JavascriptGenerator < Rails::Generators::Base
16
+ desc "Copy the username-check Stimulus controller into app/javascript/controllers (for jsbundling apps)."
17
+
18
+ SOURCE = File.expand_path("../../../app/assets/javascripts/usernames/username_check_controller.js", __dir__)
19
+ DESTINATION = "app/javascript/controllers/username_check_controller.js"
20
+
21
+ def copy_the_controller
22
+ note = "// Copied from the usernames gem #{Usernames::VERSION} by `rails generate usernames:javascript`. " \
23
+ "Run it again after upgrading the gem.\n"
24
+ create_file DESTINATION, note + File.read(SOURCE, encoding: "UTF-8")
25
+ end
26
+
27
+ def display_post_install_message
28
+ say "\n\t#{DESTINATION} is in place.", :green
29
+ say "\nRegister it as username-check: `rails stimulus:manifest:update`, or in " \
30
+ "app/javascript/controllers/index.js:"
31
+ say %( import UsernameCheckController from "./username_check_controller"\n) +
32
+ %( application.register("username-check", UsernameCheckController))
33
+ say "Then f.username_field / f.username_status work as with importmap. The file is the app's now: " \
34
+ "the gem's fields and endpoint change with the gem, so refresh it when you upgrade.", :yellow
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usernames
4
+ module Generators
5
+ # Whether `import "usernames"` can work in the app being installed into,
6
+ # for `rails generate usernames:install`: importmap (nothing else
7
+ # resolves the specifier), an app/javascript/application.js entrypoint,
8
+ # and stimulus-rails' controllers/application.js exporting the
9
+ # `application` the gem's index.js imports by name. A static import of a
10
+ # name a module doesn't export fails the whole entrypoint before any of
11
+ # it runs, so anything short of a plain named export is a no. :nodoc:
12
+ module JavascriptSetup
13
+ APPLICATION_JS = "app/javascript/application.js"
14
+ STIMULUS_APPLICATION = "app/javascript/controllers/application.js"
15
+ IMPORTED = /^\s*import\s+["']usernames["']/
16
+
17
+ # `export const application = …` (or let, var).
18
+ EXPORTS_A_BINDING = /\bexport\s+(?:const|let|var)\s+application\b/
19
+
20
+ # `export { … }`, with an optional `from "…"`: the list, to read.
21
+ EXPORT_LISTS = /\bexport\s*\{([^}]*)\}/
22
+
23
+ # One entry of such a list: `application`, `application as application`,
24
+ # or `other as application` — whatever is exported under that name.
25
+ EXPORTED_AS_APPLICATION = /\A(?:application|[\w$]+\s+as\s+application)\z/
26
+
27
+ QUOTES = %w[' " `].freeze
28
+
29
+ private
30
+
31
+ # :ready, :imported, or what is missing for the line to work. Read
32
+ # once, before the line is added, so the message can say it was.
33
+ def javascript_setup
34
+ @javascript_setup ||=
35
+ if !File.exist?(destination("config/importmap.rb")) then :no_importmap
36
+ elsif !File.exist?(destination(APPLICATION_JS)) then :no_entrypoint
37
+ elsif code(APPLICATION_JS, strings: true).match?(IMPORTED) then :imported
38
+ elsif !File.exist?(destination(STIMULUS_APPLICATION)) then :no_stimulus
39
+ elsif uncertain?(STIMULUS_APPLICATION) then :uncertain_source
40
+ elsif !exports_application?(code(STIMULUS_APPLICATION)) then :no_application_export
41
+ else :ready
42
+ end
43
+ end
44
+
45
+ def exports_application?(source)
46
+ source.match?(EXPORTS_A_BINDING) || source.scan(EXPORT_LISTS).flatten.any? do |list|
47
+ list.split(",").any? { |entry| entry.strip.match?(EXPORTED_AS_APPLICATION) }
48
+ end
49
+ end
50
+
51
+ # A file read as code, in one pass: each comment becomes one space
52
+ # (so `export/* c */const` still reads as two words), each string or
53
+ # template literal `""` (so `"export { application }"` exports
54
+ # nothing, and the `//` in `"https://…"` starts no comment).
55
+ # `strings: true` keeps '…' and "…" strings as written, to read an
56
+ # import's specifier.
57
+ def code(path, strings: false)
58
+ scan(path, strings).first
59
+ end
60
+
61
+ # Whether the file has what this reading doesn't follow, and so won't
62
+ # guess about: a template literal interpolating `${…}` (which may nest
63
+ # code, comments and more templates) or a `/` that starts no comment —
64
+ # division, or a regular-expression literal that may hold anything.
65
+ # stimulus-rails' generated controllers/application.js has neither.
66
+ def uncertain?(path)
67
+ scan(path, false).last
68
+ end
69
+
70
+ # [code, uncertain]
71
+ def scan(path, strings)
72
+ source = File.read(destination(path), encoding: "UTF-8")
73
+ code = +""
74
+ uncertain = false
75
+ index = 0
76
+ while index < source.length
77
+ finish = token_end(source, index)
78
+ uncertain ||= finish ? interpolates?(source, index, finish) : source[index] == "/"
79
+ code << (finish ? token(source, index, finish, strings) : source[index])
80
+ index = finish || (index + 1)
81
+ end
82
+ [code, uncertain]
83
+ end
84
+
85
+ def interpolates?(source, start, finish)
86
+ source[start] == "`" && source[start...finish].include?("${")
87
+ end
88
+
89
+ # Where the comment or literal starting at `index` ends, or nil for
90
+ # any other character.
91
+ def token_end(source, index)
92
+ case source[index, 2]
93
+ when "//" then source.index("\n", index) || source.length
94
+ when "/*" then (finish = source.index("*/", index + 2)) ? finish + 2 : source.length
95
+ else literal_end(source, index) if QUOTES.include?(source[index])
96
+ end
97
+ end
98
+
99
+ def token(source, start, finish, strings)
100
+ return " " if source[start] == "/"
101
+ return source[start...finish] if strings && source[start] != "`"
102
+
103
+ '""'
104
+ end
105
+
106
+ # Just past the literal opening at `start`: its closing quote, with
107
+ # backslash escapes skipped. A '…' or "…" string ends at a line break
108
+ # at the latest; a template's `${…}` is read as part of it.
109
+ def literal_end(source, start)
110
+ quote = source[start]
111
+ index = start + 1
112
+ while index < source.length
113
+ return index + 1 if source[index] == quote
114
+ return index if source[index] == "\n" && quote != "`"
115
+
116
+ index += source[index] == "\\" ? 2 : 1
117
+ end
118
+ source.length
119
+ end
120
+
121
+ def destination(path) = File.join(destination_root, path)
122
+ end
123
+ end
124
+ end