spoonerize 0.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 932ce6e7e6441c787ded513aebed0b3e0b83fa6afa83790abe8da66c9314e96b
4
- data.tar.gz: 6a5dedc04324853f1bfbc63e6317b17737eab6fb10b89ae11136b598438a4f08
3
+ metadata.gz: 9ad82a22fd996a0e5af1653a6ed66f27863da252331b4fa58865980d6a27b063
4
+ data.tar.gz: d206494ce8cc6e9dabbda3ab86389d080e57d1baf0a003800ba6edd3fe3c3b13
5
5
  SHA512:
6
- metadata.gz: b579d2925471dca724865574a82a827a5f0d31229991d1746db4890a0d4c250141daa536bbc932c829fd9d7e8f5af63e89b85846a788d23eca766219c2a60681
7
- data.tar.gz: 1531172c418c2cf00e155ca5e56c888218888af9c8b6694ff7a9b3195dbda173c122058e7ac46c4705c311d7951a8c6f773aa09d4571a447da7f4781ca425168
6
+ metadata.gz: f8b89f4ea9ca38fb0ee9773ca55e6fb654e94d4135f4b4748abc91c90fe014e3c1bd90e6f0c9c4f9d74f2efcf64b7bfd8b488ebcf3cf92dc40d9c2616089d30e
7
+ data.tar.gz: 1c81200ffd8922cc1384c11b9a8a8766eae3d59f3301c544dbf9a6928617c85d231f931cec768d4ad4c7c8fcbc697ca94d666c182340974c7d406411c11dab34
data/Gemfile.lock CHANGED
@@ -1,28 +1,101 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- spoonerize (0.2.0)
4
+ spoonerize (2.0.0)
5
5
  csv
6
+ puma (~> 8.0)
7
+ rackup (~> 2.3)
8
+ sinatra (~> 4.2)
6
9
 
7
10
  GEM
8
11
  remote: https://rubygems.org/
9
12
  specs:
13
+ ast (2.4.3)
14
+ base64 (0.3.0)
10
15
  csv (3.3.5)
11
16
  date (3.5.1)
12
17
  erb (6.0.4)
18
+ json (2.20.0)
19
+ language_server-protocol (3.17.0.5)
20
+ lint_roller (1.1.0)
21
+ logger (1.7.0)
22
+ mustermann (3.1.1)
23
+ nio4r (2.7.5)
24
+ parallel (1.28.0)
25
+ parser (3.3.11.1)
26
+ ast (~> 2.4.1)
27
+ racc
13
28
  power_assert (3.0.1)
29
+ prism (1.9.0)
14
30
  psych (5.4.0)
15
31
  date
16
32
  stringio
33
+ puma (8.0.2)
34
+ nio4r (~> 2.0)
35
+ racc (1.8.1)
36
+ rack (3.2.6)
37
+ rack-protection (4.2.1)
38
+ base64 (>= 0.1.0)
39
+ logger (>= 1.6.0)
40
+ rack (>= 3.0.0, < 4)
41
+ rack-session (2.1.2)
42
+ base64 (>= 0.1.0)
43
+ rack (>= 3.0.0)
44
+ rackup (2.3.1)
45
+ rack (>= 3)
46
+ rainbow (3.1.1)
17
47
  rake (13.4.2)
18
48
  rdoc (7.2.0)
19
49
  erb
20
50
  psych (>= 4.0.0)
21
51
  tsort
52
+ regexp_parser (2.12.0)
53
+ rubocop (1.84.2)
54
+ json (~> 2.3)
55
+ language_server-protocol (~> 3.17.0.2)
56
+ lint_roller (~> 1.1.0)
57
+ parallel (~> 1.10)
58
+ parser (>= 3.3.0.2)
59
+ rainbow (>= 2.2.2, < 4.0)
60
+ regexp_parser (>= 2.9.3, < 3.0)
61
+ rubocop-ast (>= 1.49.0, < 2.0)
62
+ ruby-progressbar (~> 1.7)
63
+ unicode-display_width (>= 2.4.0, < 4.0)
64
+ rubocop-ast (1.49.1)
65
+ parser (>= 3.3.7.2)
66
+ prism (~> 1.7)
67
+ rubocop-performance (1.26.1)
68
+ lint_roller (~> 1.1)
69
+ rubocop (>= 1.75.0, < 2.0)
70
+ rubocop-ast (>= 1.47.1, < 2.0)
71
+ ruby-progressbar (1.13.0)
72
+ sinatra (4.2.1)
73
+ logger (>= 1.6.0)
74
+ mustermann (~> 3.0)
75
+ rack (>= 3.0.0, < 4)
76
+ rack-protection (= 4.2.1)
77
+ rack-session (>= 2.0.0, < 3)
78
+ tilt (~> 2.0)
79
+ standard (1.54.0)
80
+ language_server-protocol (~> 3.17.0.2)
81
+ lint_roller (~> 1.0)
82
+ rubocop (~> 1.84.0)
83
+ standard-custom (~> 1.0.0)
84
+ standard-performance (~> 1.8)
85
+ standard-custom (1.0.2)
86
+ lint_roller (~> 1.0)
87
+ rubocop (~> 1.50)
88
+ standard-performance (1.9.0)
89
+ lint_roller (~> 1.1)
90
+ rubocop-performance (~> 1.26.0)
22
91
  stringio (3.2.0)
23
92
  test-unit (3.7.8)
24
93
  power_assert
94
+ tilt (2.7.0)
25
95
  tsort (0.2.0)
96
+ unicode-display_width (3.2.0)
97
+ unicode-emoji (~> 4.1)
98
+ unicode-emoji (4.2.0)
26
99
 
27
100
  PLATFORMS
28
101
  arm64-darwin-25
@@ -33,6 +106,7 @@ DEPENDENCIES
33
106
  rake (~> 13.0, >= 13.0.1)
34
107
  rdoc
35
108
  spoonerize!
109
+ standard (= 1.54.0)
36
110
  test-unit (~> 3.3, >= 3.3.5)
37
111
 
38
112
  BUNDLED WITH
data/README.md CHANGED
@@ -26,6 +26,8 @@ consonants, but will still lose its own if it has any.
26
26
  - If the word to pull from is excluded, that word is skipped, and you pull the
27
27
  leading consonants from the next non-excluded word.
28
28
  - "Q" and "U" should stay together (like "queen").
29
+ - "Y" is treated like a leading consonant by itself or before a vowel sound
30
+ (like "yellow"), but like a leading vowel before a consonant (like "yttrium").
29
31
  - A lot of the time, the words won't look how they're supposed to sound, as you
30
32
  go by how the word *used* to sound, not how it's spelled. For instance,
31
33
  `$ spoonerize two new cuties` becomes "no cew twuties", but it would be
@@ -40,7 +42,7 @@ Just install the gem!
40
42
  gem install spoonerize
41
43
  ```
42
44
 
43
- If you don't have permission on your system to install ruby or gems, I recommend
45
+ If you don't have permission on your system to install Ruby or gems, I recommend
44
46
  using
45
47
  [rbenv](http://www.rubyinside.com/rbenv-a-simple-new-ruby-version-management-tool-5302.html),
46
48
  or you can try the manual methods below.
@@ -82,10 +84,12 @@ get the results. For example:
82
84
 
83
85
  ```
84
86
  $ spoonerize -s not too shabby
85
- Saving [tot shoo nabby] to ~/.cache/spoonerize/spoonerize.csv
87
+ tot shoo nabby
88
+ Saving...
86
89
 
87
90
  $ spoonerize -rs not too shabby
88
- Saving [shot noo tabby] to ~/.cache/spoonerize/spoonerize.csv
91
+ shot noo tabby
92
+ Saving...
89
93
 
90
94
  $ spoonerize -p
91
95
  not too shabby | tot shoo nabby | No Options
@@ -96,22 +100,48 @@ Here is a list of all available options:
96
100
 
97
101
  ```
98
102
  -r, --[no-]reverse Reverse flipping
99
- -l, --[no-]lazy Skip small words
103
+ -l, --[no-]lazy Skip common words
104
+ -c, --[no-]consonants-only Only flip consonant-starting words
100
105
  -m, --[no-]map Print words mapping
101
- -p, --[no-]print Print all entries in the log
106
+ -p, --[no-]print-log Print all entries in the log
102
107
  -s, --[no-]save Save results in log
103
- --exclude=WORDS Words to skip
108
+ --exclude=WORD Words to skip
104
109
  ```
105
110
 
111
+ ## Web Usage
112
+ The gem also installs a small Sinatra app:
113
+
114
+ ```sh
115
+ spoonerize-web
116
+ ```
117
+
118
+ By default, Sinatra starts on its normal local development address. You can
119
+ choose a host or port when you need to:
120
+
121
+ ```sh
122
+ spoonerize-web --host 127.0.0.1 --port 9292
123
+ ```
124
+
125
+ Open the printed local URL in your browser, enter a phrase, choose any options,
126
+ and submit the form. The page reloads with the spoonerized result and keeps
127
+ your phrase and options in the form. Check "Save result" to write a successful
128
+ result to the configured log file.
129
+
130
+ The web app ships with the main gem for now, so `gem install spoonerize`
131
+ installs both `spoonerize` and `spoonerize-web`.
132
+
106
133
  ### Config File
107
134
  You can create a Ruby config file called `~/.spoonerizerc`. The CLI loads this
108
135
  file automatically before it parses command-line options, so options set in the
109
- file can still be overridden at runtime by executable flags.
136
+ file can still be overridden at runtime by executable flags. The web app loads
137
+ the same file when it starts, and uses those values for the initial form
138
+ defaults.
110
139
 
111
140
  ```ruby
112
141
  Spoonerize.configure do |config|
113
142
  config.excluded_words = []
114
143
  config.lazy = false
144
+ config.consonants_only = false
115
145
  config.reverse = false
116
146
  config.logfile_name = File.expand_path("~/.cache/spoonerize/spoonerize.csv")
117
147
  end
@@ -122,50 +152,63 @@ Because the file is Ruby, you can set only the values you want to change.
122
152
  ## API
123
153
  The API is [fully
124
154
  documented](https://evanthegrayt.github.io/spoonerize/), but below
125
- are some quick examples of how you could use this in your ruby code.
155
+ are some quick examples of how you could use this in your Ruby code.
126
156
 
127
157
  ```ruby
128
158
  require 'spoonerize'
129
159
 
130
- spoonerism = Spoonerize::Spoonerism.new(%w[not too shabby])
160
+ spoonerism = Spoonerize::Spoonerism.new("not", "too", "shabby")
131
161
 
132
162
  spoonerism.to_s
133
163
  # => tot shoo nabby
134
164
 
135
- Spoonerize.configure do |config|
136
- config.reverse = true
137
- end
138
-
139
- spoonerism.to_s
165
+ reversed = Spoonerize::Spoonerism.new("not", "too", "shabby", reverse: true)
166
+ reversed.to_s
140
167
  # => shot noo tabby
141
168
 
142
169
  Spoonerize.configure do |config|
143
170
  config.logfile_name = File.expand_path("~/.cache/spoonerize/spoonerize.csv")
144
171
  end
145
- spoonerism.save
172
+ Spoonerize::Spoonerism.new("not", "too", "shabby").save
173
+ ```
174
+
175
+ To leave vowel-starting words alone, enable consonants-only mode:
176
+
177
+ ```ruby
178
+ Spoonerize::Spoonerism.new("turn", "up", "son", consonants_only: true).to_s
179
+ # => surn up ton
146
180
  ```
147
181
 
148
- You can also configure Spoonerize in Ruby before creating a spoonerism:
182
+ You can also configure global defaults before creating a spoonerism:
149
183
 
150
184
  ```ruby
151
185
  Spoonerize.configure do |config|
152
186
  config.reverse = true
153
187
  end
154
188
 
155
- s = Spoonerize::Spoonerism.new(%w[not too shabby])
189
+ s = Spoonerize::Spoonerism.new("not", "too", "shabby")
156
190
  s.spoonerize
157
191
  # => shot noo tabby
158
192
  ```
159
193
 
194
+ Options passed directly to `Spoonerize::Spoonerism.new` only apply to that
195
+ instance.
196
+
197
+ Passing words as an array is deprecated and will be removed in Spoonerize 1.0:
198
+
199
+ ```ruby
200
+ Spoonerize::Spoonerism.new(%w[not too shabby])
201
+ ```
202
+
160
203
  Or load a config file manually:
161
204
 
162
205
  ```ruby
163
206
  Spoonerize.load_config_file("~/.spoonerizerc")
164
- s = Spoonerize::Spoonerism.new(%w[not too shabby])
207
+ s = Spoonerize::Spoonerism.new("not", "too", "shabby")
165
208
  ```
166
209
 
167
210
  ## Self Promotion
168
211
  I do these projects for fun, and I enjoy knowing that they're helpful to people.
169
212
  Consider starring [the repository](https://github.com/evanthegrayt/spoonerize)
170
213
  if you like it! If you love it, follow me [on
171
- github](https://github.com/evanthegrayt)!
214
+ GitHub](https://github.com/evanthegrayt)!
data/Rakefile CHANGED
@@ -4,6 +4,7 @@ require_relative "lib/spoonerize"
4
4
  require "bundler/gem_tasks"
5
5
  require "rdoc/task"
6
6
  require "rake/testtask"
7
+ require "standard/rake"
7
8
 
8
9
  Rake::TestTask.new do |t|
9
10
  t.libs = ["lib"]
@@ -64,4 +65,3 @@ namespace :version do
64
65
  end
65
66
  end
66
67
  end
67
-
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative "../lib/spoonerize/web/cli"
4
+
5
+ Spoonerize::Web::Cli.execute(ARGV)
@@ -7,10 +7,10 @@ module Spoonerize
7
7
  # The class for handling the command-line interface.
8
8
  class Cli
9
9
  ##
10
- # The config file the user can create to change default runtime options.
10
+ # The config file the CLI loads before parsing runtime options.
11
11
  #
12
12
  # @return [String]
13
- CONFIG_FILE = File.expand_path(File.join(ENV["HOME"], ".spoonerizerc"))
13
+ CONFIG_FILE = Spoonerize::CONFIG_FILE
14
14
 
15
15
  ##
16
16
  # Creates an instance of +Spoonerism+ and runs what the user requested.
@@ -40,10 +40,10 @@ module Spoonerize
40
40
  attr_reader :options
41
41
 
42
42
  ##
43
- # Preferences after reading config file and parsing ARGV.
43
+ # Overrides after reading config file and parsing ARGV.
44
44
  #
45
- # @return [Array]
46
- attr_reader :preferences
45
+ # @return [Hash]
46
+ attr_reader :overrides
47
47
 
48
48
  ##
49
49
  # Create instance of +Cli+
@@ -57,7 +57,7 @@ module Spoonerize
57
57
  @save = false
58
58
  @print_log = false
59
59
  @options = options
60
- @preferences = get_preferences
60
+ @overrides = get_overrides
61
61
  end
62
62
 
63
63
  ##
@@ -65,7 +65,7 @@ module Spoonerize
65
65
  #
66
66
  # @return [Spoonerize::Spoonerism]
67
67
  def spoonerism
68
- @spoonerism ||= Spoonerism.new(options)
68
+ @spoonerism ||= Spoonerism.new(*options, **overrides)
69
69
  end
70
70
 
71
71
  ##
@@ -124,15 +124,18 @@ module Spoonerize
124
124
 
125
125
  ##
126
126
  # Read in args and set options
127
- def get_preferences # :nodoc:
127
+ def get_overrides # :nodoc:
128
128
  {}.tap do |prefs|
129
129
  OptionParser.new do |o|
130
130
  o.version = ::Spoonerize::Version.to_s
131
131
  o.on("-r", "--[no-]reverse", "Reverse flipping") do |v|
132
- Spoonerize.config.reverse = v
132
+ prefs[:reverse] = v
133
133
  end
134
- o.on("-l", "--[no-]lazy", "Skip small words") do |v|
135
- Spoonerize.config.lazy = v
134
+ o.on("-l", "--[no-]lazy", "Skip common words") do |v|
135
+ prefs[:lazy] = v
136
+ end
137
+ o.on("-c", "--[no-]consonants-only", "Only flip consonant-starting words") do |v|
138
+ prefs[:consonants_only] = v
136
139
  end
137
140
  o.on("-m", "--[no-]map", "Print words mapping") do |v|
138
141
  @map = v
@@ -144,7 +147,7 @@ module Spoonerize
144
147
  @save = v
145
148
  end
146
149
  o.on("--exclude=WORD", Array, "Words to skip") do |v|
147
- Spoonerize.config.excluded_words = v
150
+ prefs[:excluded_words] = v
148
151
  end
149
152
  end.parse!(options)
150
153
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Spoonerize
4
+ ##
5
+ # Runtime options used by the CLI and Spoonerism instances.
4
6
  class Config
5
7
  ##
6
8
  # Lazy mode. If true, words in +lazy_words+ will not be altered.
@@ -20,6 +22,12 @@ module Spoonerize
20
22
  # @return [Array]
21
23
  attr_accessor :excluded_words
22
24
 
25
+ ##
26
+ # When true, only consonant-starting words are eligible to be flipped.
27
+ #
28
+ # @return [Boolean]
29
+ attr_accessor :consonants_only
30
+
23
31
  ##
24
32
  # When true, reverse the order of the flipping. Only makes a difference
25
33
  # when there are more than two flip-able words.
@@ -41,10 +49,36 @@ module Spoonerize
41
49
  @lazy = false
42
50
  @lazy_words = %w[i a an and in of the my your his her him hers to is]
43
51
  @excluded_words = []
52
+ @consonants_only = false
44
53
  @reverse = false
45
54
  @logfile_name = File.expand_path(
46
55
  File.join(ENV["HOME"], ".cache", "spoonerize", "spoonerize.csv")
47
56
  )
48
57
  end
58
+
59
+ ##
60
+ # Create a copy of the current config with optional overrides.
61
+ #
62
+ # @return [Spoonerize::Config]
63
+ def with(**overrides)
64
+ self.class.new.tap do |config|
65
+ config.lazy = lazy
66
+ config.lazy_words = lazy_words.dup
67
+ config.excluded_words = excluded_words.dup
68
+ config.consonants_only = consonants_only
69
+ config.reverse = reverse
70
+ config.logfile_name = logfile_name
71
+
72
+ overrides.each do |key, value|
73
+ config.public_send(:"#{key}=", copy_value(value))
74
+ end
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def copy_value(value)
81
+ value.is_a?(Array) ? value.dup : value
82
+ end
49
83
  end
50
84
  end
@@ -4,20 +4,69 @@ module Spoonerize
4
4
  ##
5
5
  # The main word-flipper.
6
6
  class Spoonerism
7
+ ##
8
+ # Letters that always represent vowel sounds.
9
+ #
10
+ # @return [String]
11
+ VOWEL_LETTERS = "aeio"
12
+
13
+ ##
14
+ # Letters that always represent consonant sounds.
15
+ #
16
+ # @return [String]
17
+ CONSONANT_LETTERS = "bcdfghjklmnprstvwxz"
18
+
19
+ ##
20
+ # Letters that make an initial "y" act like a vowel sound.
21
+ #
22
+ # @return [String]
23
+ Y_FOLLOWING_CONSONANTS = "bcdfghjklmnpqrstvwxz"
24
+
7
25
  ##
8
26
  # The words originally passed at initialization.
9
27
  #
10
28
  # @return [Array]
11
29
  attr_reader :words
12
30
 
31
+ ##
32
+ # Configuration values for this spoonerism.
33
+ #
34
+ # @return [Spoonerize::Config]
35
+ attr_reader :config
36
+
13
37
  ##
14
38
  # Initialize instance.
15
39
  #
16
- # @param [Array] words
40
+ # @param [Array<String>] words Words to spoonerize. Passing a single array
41
+ # is deprecated and will be removed in Spoonerize 1.0.
42
+ # @param [Spoonerize::Config] config Base config to copy.
43
+ # @param [Boolean, nil] lazy Override lazy mode for this instance.
44
+ # @param [Array<String>, nil] lazy_words Override lazy words for this instance.
45
+ # @param [Array<String>, nil] excluded_words Override excluded words for this instance.
46
+ # @param [Boolean, nil] consonants_only Override consonants-only mode for this instance.
47
+ # @param [Boolean, nil] reverse Override reverse mode for this instance.
48
+ # @param [String, nil] logfile_name Override the log file path for this instance.
17
49
  #
18
50
  # @return [Spoonerize::Spoonerism]
19
- def initialize(words)
20
- @words = words.map(&:downcase)
51
+ def initialize(
52
+ *words,
53
+ config: Spoonerize.config,
54
+ lazy: nil,
55
+ lazy_words: nil,
56
+ excluded_words: nil,
57
+ consonants_only: nil,
58
+ reverse: nil,
59
+ logfile_name: nil
60
+ )
61
+ @words = normalize_words(words).map(&:downcase)
62
+ @config = config.with(**{
63
+ lazy: lazy,
64
+ lazy_words: lazy_words,
65
+ excluded_words: excluded_words,
66
+ consonants_only: consonants_only,
67
+ reverse: reverse,
68
+ logfile_name: logfile_name
69
+ }.reject { |_, value| value.nil? })
21
70
  end
22
71
 
23
72
  ##
@@ -60,7 +109,7 @@ module Spoonerize
60
109
  #
61
110
  # @return [Boolean]
62
111
  def enough_flippable_words?
63
- (words - all_excluded_words).size > 1
112
+ words.each_index.count { |index| flippable?(index) } > 1
64
113
  end
65
114
 
66
115
  ##
@@ -73,27 +122,44 @@ module Spoonerize
73
122
 
74
123
  ##
75
124
  # Array of words to exclude by combining two arrays:
76
- # * Any user-passed words, stored in +Spoonerize.config.excluded_words+
125
+ # * Any user-passed words, stored in +config.excluded_words+
77
126
  # * Any lazy words, if lazy mode is true
78
127
  #
79
128
  # @return [Array]
80
129
  def all_excluded_words
81
- (Spoonerize.config.excluded_words + (
82
- Spoonerize.config.lazy ? Spoonerize.config.lazy_words : []
130
+ (config.excluded_words + (
131
+ config.lazy ? config.lazy_words : []
83
132
  )).map(&:downcase)
84
133
  end
85
134
 
86
135
  private
87
136
 
137
+ def normalize_words(words)
138
+ if words.size == 1 && words.first.is_a?(Array)
139
+ warn(
140
+ "Passing words as an array is deprecated and will be removed in Spoonerize 1.0. " \
141
+ "Pass words as positional arguments instead."
142
+ )
143
+ words = words.first
144
+ end
145
+
146
+ unless words.all? { |word| word.is_a?(String) }
147
+ raise ArgumentError, "Words must be strings"
148
+ end
149
+
150
+ words
151
+ end
152
+
88
153
  ##
89
154
  # Main flipping method. Creates the replacement word from the next
90
- # non-excluded word's leading syllables, and the current word's first vowels
91
- # through the end of the word.
155
+ # non-excluded word's leading consonants, and the current word's first
156
+ # vowel sound through the end of the word.
92
157
  def flip_words(word, idx) # :nodoc:
93
- return word if excluded?(idx)
94
- bumper = Bumper.new(idx, words.size, Spoonerize.config.reverse)
95
- bumper.bump while excluded?(bumper.value)
96
- words[bumper.value].match(consonants).to_s + word.match(vowels).to_s
158
+ return word unless flippable?(idx)
159
+
160
+ bumper = Bumper.new(idx, words.size, config.reverse)
161
+ bumper.bump until flippable?(bumper.value)
162
+ leading_consonants(words[bumper.value]) + retained_suffix(word)
97
163
  end
98
164
 
99
165
  ##
@@ -103,31 +169,94 @@ module Spoonerize
103
169
  end
104
170
 
105
171
  ##
106
- # Returns regex to match first vowels through the rest of the word
107
- def vowels # :nodoc:
108
- /((?<!q)u|[aeio]|(?<=[bcdfghjklmnprstvwxz])y).*$/
172
+ # Returns true if word[index] can participate in a spoonerism.
173
+ def flippable?(index) # :nodoc:
174
+ return false if excluded?(index)
175
+
176
+ !config.consonants_only || consonant_sound_start?(words[index])
177
+ end
178
+
179
+ ##
180
+ # Returns true when a word starts with a consonant sound.
181
+ def consonant_sound_start?(word) # :nodoc:
182
+ return false if word.empty?
183
+
184
+ !vowel_sound_at?(word, 0)
185
+ end
186
+
187
+ ##
188
+ # Returns the consonant group a word contributes to another word.
189
+ def leading_consonants(word) # :nodoc:
190
+ return "qu" if word.start_with?("qu")
191
+ return "y" if initial_y_consonant?(word)
192
+
193
+ index = word.length.times.find do |letter_index|
194
+ !CONSONANT_LETTERS.include?(word[letter_index])
195
+ end
196
+
197
+ index ? word[0...index] : word
198
+ end
199
+
200
+ ##
201
+ # Returns the part of a word kept after dropping its leading consonants.
202
+ def retained_suffix(word) # :nodoc:
203
+ index = first_vowel_sound_index(word)
204
+
205
+ index ? word[index..] : ""
206
+ end
207
+
208
+ ##
209
+ # Returns the first index where a word starts sounding vowel-like.
210
+ def first_vowel_sound_index(word) # :nodoc:
211
+ word.length.times.find { |index| vowel_sound_at?(word, index) }
212
+ end
213
+
214
+ ##
215
+ # Returns true when the letter at index starts a vowel sound.
216
+ def vowel_sound_at?(word, index) # :nodoc:
217
+ letter = word[index]
218
+
219
+ VOWEL_LETTERS.include?(letter) ||
220
+ (letter == "u" && word[index - 1] != "q") ||
221
+ y_vowel_sound_at?(word, index)
222
+ end
223
+
224
+ ##
225
+ # Initial y is vowel-like before a consonant; later y is vowel-like after a
226
+ # consonant.
227
+ def y_vowel_sound_at?(word, index) # :nodoc:
228
+ return false unless word[index] == "y"
229
+
230
+ if index.zero?
231
+ next_letter = word[index + 1]
232
+
233
+ next_letter && Y_FOLLOWING_CONSONANTS.include?(next_letter)
234
+ else
235
+ CONSONANT_LETTERS.include?(word[index - 1])
236
+ end
109
237
  end
110
238
 
111
239
  ##
112
- # Returns regex to match leading consonants
113
- def consonants # :nodoc:
114
- /^(y|[bcdfghjklmnprstvwxz]+|qu)/
240
+ # Initial y is consonant-like by itself or before a vowel sound.
241
+ def initial_y_consonant?(word) # :nodoc:
242
+ word == "y" || (word.start_with?("y") && vowel_sound_at?(word, 1))
115
243
  end
116
244
 
117
245
  ##
118
246
  # Creates and memoizes instance of the log file.
119
247
  def log # :nodoc:
120
- @log ||= Spoonerize::Log.new(Spoonerize.config.logfile_name)
248
+ @log ||= Spoonerize::Log.new(config.logfile_name)
121
249
  end
122
250
 
123
251
  ##
124
252
  # The options that were passed at runtime as a string
125
253
  def options # :nodoc:
126
254
  [].tap do |o|
127
- o << "Lazy" if Spoonerize.config.lazy
128
- o << "Reverse" if Spoonerize.config.reverse
129
- if Spoonerize.config.excluded_words.any?
130
- o << "Exclude [#{Spoonerize.config.excluded_words.join(", ")}]"
255
+ o << "Lazy" if config.lazy
256
+ o << "Consonants Only" if config.consonants_only
257
+ o << "Reverse" if config.reverse
258
+ if config.excluded_words.any?
259
+ o << "Exclude [#{config.excluded_words.join(", ")}]"
131
260
  end
132
261
  o << "No Options" if o.empty?
133
262
  end
@@ -9,13 +9,13 @@ module Spoonerize
9
9
  # Major version.
10
10
  #
11
11
  # @return [Integer]
12
- MAJOR = 0
12
+ MAJOR = 2
13
13
 
14
14
  ##
15
15
  # Minor version.
16
16
  #
17
17
  # @return [Integer]
18
- MINOR = 2
18
+ MINOR = 0
19
19
 
20
20
  ##
21
21
  # Patch version.
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ require_relative "../web"
6
+
7
+ module Spoonerize
8
+ class Web
9
+ ##
10
+ # Command-line launcher for the web app.
11
+ class Cli
12
+ ##
13
+ # Server options used when no command-line overrides are passed.
14
+ #
15
+ # @return [Hash]
16
+ DEFAULT_OPTIONS = {
17
+ host: Web.bind,
18
+ port: Web.port
19
+ }.freeze
20
+
21
+ ##
22
+ # Starts the web app from command-line arguments.
23
+ #
24
+ # @param [Array] options
25
+ #
26
+ # @return [nil]
27
+ def self.execute(options = [])
28
+ new(options).execute
29
+ end
30
+
31
+ ##
32
+ # Parsed server options.
33
+ #
34
+ # @return [Hash]
35
+ attr_reader :options
36
+
37
+ ##
38
+ # Create a web CLI launcher.
39
+ #
40
+ # @param [Array] options Command-line arguments.
41
+ #
42
+ # @return [self]
43
+ def initialize(options)
44
+ @options = DEFAULT_OPTIONS.merge(parse(options))
45
+ end
46
+
47
+ ##
48
+ # Starts the Sinatra web app.
49
+ #
50
+ # @return [nil]
51
+ def execute
52
+ Web.run!(bind: options[:host], port: options[:port])
53
+ end
54
+
55
+ private
56
+
57
+ def parse(options)
58
+ {}.tap do |prefs|
59
+ OptionParser.new do |o|
60
+ o.version = ::Spoonerize::Version.to_s
61
+ o.on("--host=HOST", "Host to bind") do |value|
62
+ prefs[:host] = value
63
+ end
64
+ o.on("--port=PORT", Integer, "Port to bind") do |value|
65
+ prefs[:port] = value
66
+ end
67
+ end.parse!(options)
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,178 @@
1
+ :root {
2
+ color-scheme: light;
3
+ --bg: #f7f7f4;
4
+ --ink: #24211f;
5
+ --muted: #6c655f;
6
+ --line: #d7d2c9;
7
+ --panel: #fffdfa;
8
+ --accent: #1d6f78;
9
+ --accent-dark: #145059;
10
+ --notice-bg: #fff0dc;
11
+ --notice-line: #e1a853;
12
+ }
13
+
14
+ * {
15
+ box-sizing: border-box;
16
+ }
17
+
18
+ body {
19
+ margin: 0;
20
+ min-height: 100vh;
21
+ background: var(--bg);
22
+ color: var(--ink);
23
+ font-family: ui-serif, Georgia, "Times New Roman", serif;
24
+ line-height: 1.5;
25
+ }
26
+
27
+ .page {
28
+ min-height: 100vh;
29
+ display: grid;
30
+ place-items: start center;
31
+ padding: 4rem 1rem;
32
+ }
33
+
34
+ .shell {
35
+ width: min(100%, 46rem);
36
+ }
37
+
38
+ .masthead {
39
+ margin-bottom: 2rem;
40
+ border-bottom: 1px solid var(--line);
41
+ padding-bottom: 1.25rem;
42
+ }
43
+
44
+ h1 {
45
+ margin: 0;
46
+ font-size: clamp(2.2rem, 7vw, 4rem);
47
+ line-height: 1;
48
+ }
49
+
50
+ .result {
51
+ margin: 0 0 1.75rem;
52
+ font-size: clamp(2rem, 6vw, 3.6rem);
53
+ font-weight: 800;
54
+ line-height: 1.05;
55
+ }
56
+
57
+ .notice {
58
+ margin: 0 0 1.5rem;
59
+ border: 1px solid var(--notice-line);
60
+ background: var(--notice-bg);
61
+ padding: 0.8rem 1rem;
62
+ font-family: ui-sans-serif, system-ui, sans-serif;
63
+ font-weight: 650;
64
+ }
65
+
66
+ .spoonerize-form {
67
+ display: grid;
68
+ gap: 1rem;
69
+ }
70
+
71
+ .field,
72
+ fieldset {
73
+ display: grid;
74
+ gap: 0.45rem;
75
+ }
76
+
77
+ .field span,
78
+ legend {
79
+ color: var(--muted);
80
+ font-family: ui-sans-serif, system-ui, sans-serif;
81
+ font-size: 0.85rem;
82
+ font-weight: 700;
83
+ }
84
+
85
+ input[type="text"] {
86
+ width: 100%;
87
+ border: 1px solid var(--line);
88
+ border-radius: 4px;
89
+ background: var(--panel);
90
+ color: var(--ink);
91
+ font: inherit;
92
+ font-size: 1.1rem;
93
+ padding: 0.75rem 0.85rem;
94
+ }
95
+
96
+ input[type="text"]:focus {
97
+ border-color: var(--accent);
98
+ outline: 3px solid color-mix(in srgb, var(--accent) 22%, transparent);
99
+ }
100
+
101
+ fieldset {
102
+ margin: 0;
103
+ border: 1px solid var(--line);
104
+ border-radius: 4px;
105
+ background: var(--panel);
106
+ padding: 0.9rem 1rem 1rem;
107
+ }
108
+
109
+ legend {
110
+ padding: 0 0.35rem;
111
+ }
112
+
113
+ .check {
114
+ display: flex;
115
+ align-items: center;
116
+ gap: 0.55rem;
117
+ font-family: ui-sans-serif, system-ui, sans-serif;
118
+ }
119
+
120
+ .check input {
121
+ width: 1rem;
122
+ height: 1rem;
123
+ accent-color: var(--accent);
124
+ }
125
+
126
+ button {
127
+ width: fit-content;
128
+ border: 0;
129
+ border-radius: 4px;
130
+ background: var(--accent);
131
+ color: #fff;
132
+ cursor: pointer;
133
+ font-family: ui-sans-serif, system-ui, sans-serif;
134
+ font-size: 1rem;
135
+ font-weight: 750;
136
+ padding: 0.72rem 1rem;
137
+ }
138
+
139
+ button:hover,
140
+ button:focus {
141
+ background: var(--accent-dark);
142
+ }
143
+
144
+ .footer {
145
+ width: min(100%, 46rem);
146
+ margin-top: 2.5rem;
147
+ border-top: 1px solid var(--line);
148
+ padding-top: 1rem;
149
+ color: var(--muted);
150
+ font-family: ui-sans-serif, system-ui, sans-serif;
151
+ font-size: 0.82rem;
152
+ }
153
+
154
+ .footer a {
155
+ color: inherit;
156
+ text-decoration-color: color-mix(in srgb, var(--muted) 45%, transparent);
157
+ text-underline-offset: 0.16em;
158
+ }
159
+
160
+ .footer a:hover,
161
+ .footer a:focus {
162
+ color: var(--accent-dark);
163
+ text-decoration-color: currentcolor;
164
+ }
165
+
166
+ .footer span {
167
+ margin: 0 0.35rem;
168
+ }
169
+
170
+ @media (max-width: 40rem) {
171
+ .page {
172
+ padding-top: 2rem;
173
+ }
174
+
175
+ button {
176
+ width: 100%;
177
+ }
178
+ }
@@ -0,0 +1,61 @@
1
+ <section class="shell">
2
+ <header class="masthead">
3
+ <h1>Flip a phrase</h1>
4
+ </header>
5
+
6
+ <% if @result %>
7
+ <p class="result"><%= h @result %></p>
8
+ <% if @saved %>
9
+ <p class="notice">Saved.</p>
10
+ <% end %>
11
+ <% elsif @error %>
12
+ <p class="notice"><%= h @error %></p>
13
+ <% end %>
14
+
15
+ <form action="/" method="post" class="spoonerize-form">
16
+ <label class="field">
17
+ <span>Phrase</span>
18
+ <input
19
+ type="text"
20
+ name="phrase"
21
+ value="<%= h @phrase %>"
22
+ placeholder="Enter phrase to spoonerize..."
23
+ autofocus>
24
+ </label>
25
+
26
+ <fieldset>
27
+ <legend>Options</legend>
28
+
29
+ <label class="check">
30
+ <input type="checkbox" name="reverse" value="1" <%= checked?("reverse") %>>
31
+ <span>Reverse flipping</span>
32
+ </label>
33
+
34
+ <label class="check">
35
+ <input type="checkbox" name="lazy" value="1" <%= checked?("lazy") %>>
36
+ <span>Skip common words</span>
37
+ </label>
38
+
39
+ <label class="check">
40
+ <input type="checkbox" name="consonants_only" value="1" <%= checked?("consonants_only") %>>
41
+ <span>Only flip consonant-starting words</span>
42
+ </label>
43
+
44
+ <label class="check">
45
+ <input type="checkbox" name="save" value="1" <%= @save ? "checked" : nil %>>
46
+ <span>Save result</span>
47
+ </label>
48
+ </fieldset>
49
+
50
+ <label class="field">
51
+ <span>Excluded words</span>
52
+ <input
53
+ type="text"
54
+ name="excluded_words"
55
+ value="<%= h @excluded_words_value %>"
56
+ placeholder="word another">
57
+ </label>
58
+
59
+ <button type="submit">Spoonerize</button>
60
+ </form>
61
+ </section>
@@ -0,0 +1,20 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Spoonerize</title>
7
+ <link rel="stylesheet" href="/styles.css">
8
+ </head>
9
+ <body>
10
+ <main class="page">
11
+ <%= yield %>
12
+
13
+ <footer class="footer">
14
+ <a href="https://github.com/evanthegrayt">evanthegrayt</a>
15
+ <span>/</span>
16
+ <a href="https://github.com/evanthegrayt/spoonerize">spoonerize</a>
17
+ </footer>
18
+ </main>
19
+ </body>
20
+ </html>
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sinatra/base"
4
+
5
+ require_relative "../spoonerize"
6
+
7
+ module Spoonerize
8
+ ##
9
+ # Sinatra app for spoonerizing phrases in the browser.
10
+ class Web < Sinatra::Base
11
+ ##
12
+ # Boolean Spoonerism options exposed by the web form.
13
+ #
14
+ # @return [Array<String>]
15
+ OPTION_NAMES = %w[reverse lazy consonants_only].freeze
16
+
17
+ set :root, File.expand_path(File.join(__dir__, "..", ".."))
18
+ set :public_folder, File.expand_path(File.join(__dir__, "web", "public"))
19
+ set :views, File.expand_path(File.join(__dir__, "web", "views"))
20
+ set :static, true
21
+ set :show_exceptions, false
22
+
23
+ configure do
24
+ Spoonerize.load_config_file(Spoonerize::CONFIG_FILE) if File.file?(Spoonerize::CONFIG_FILE)
25
+ end
26
+
27
+ helpers do
28
+ ##
29
+ # HTML checkbox attribute for a truthy option value.
30
+ #
31
+ # @param [String] name The option name.
32
+ #
33
+ # @return [String, nil]
34
+ def checked?(name)
35
+ option_value(name) ? "checked" : nil
36
+ end
37
+
38
+ ##
39
+ # Current boolean value for a web form option.
40
+ #
41
+ # @param [String] name The option name.
42
+ #
43
+ # @return [Boolean]
44
+ def option_value(name)
45
+ @options.fetch(name.to_sym)
46
+ end
47
+
48
+ ##
49
+ # Escapes a value for safe HTML output.
50
+ #
51
+ # @param [Object] value The value to escape.
52
+ #
53
+ # @return [String]
54
+ def h(value)
55
+ Rack::Utils.escape_html(value)
56
+ end
57
+ end
58
+
59
+ get "/" do
60
+ prepare_request(false)
61
+ erb :index
62
+ end
63
+
64
+ post "/" do
65
+ prepare_request(true)
66
+ @result = spoonerize_phrase if @submitted && !@phrase.strip.empty?
67
+
68
+ erb :index
69
+ end
70
+
71
+ private
72
+
73
+ def options_from_params
74
+ OPTION_NAMES.to_h do |name|
75
+ value = @submitted ? params.key?(name) : Spoonerize.config.public_send(name)
76
+ [name.to_sym, value]
77
+ end
78
+ end
79
+
80
+ def excluded_words_from(value)
81
+ value.split(/[,\s]+/).reject(&:empty?)
82
+ end
83
+
84
+ def excluded_words_from_params
85
+ return excluded_words_from(params["excluded_words"].to_s) if @submitted
86
+
87
+ Spoonerize.config.excluded_words
88
+ end
89
+
90
+ def prepare_request(submitted)
91
+ @submitted = submitted
92
+ @phrase = params["phrase"].to_s
93
+ @options = options_from_params
94
+ @excluded_words = excluded_words_from_params
95
+ @excluded_words_value = @excluded_words.join(" ")
96
+ @save = @submitted && params.key?("save")
97
+ end
98
+
99
+ def spoonerize_phrase
100
+ spoonerism = Spoonerism.new(
101
+ *@phrase.split,
102
+ **@options,
103
+ excluded_words: @excluded_words
104
+ )
105
+ result = spoonerism.to_s
106
+ spoonerism.save if @save
107
+ @saved = @save
108
+
109
+ result
110
+ rescue => error
111
+ @error = friendly_error(error)
112
+ nil
113
+ end
114
+
115
+ def friendly_error(error)
116
+ return "Not enough words to flip." if error.message == "Not enough words to flip"
117
+
118
+ error.message
119
+ end
120
+ end
121
+ end
data/lib/spoonerize.rb CHANGED
@@ -1,5 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ ##
4
+ # The main namespace for the gem.
5
+ module Spoonerize
6
+ ##
7
+ # The config file the user can create to change default runtime options.
8
+ #
9
+ # @return [String]
10
+ CONFIG_FILE = File.expand_path(File.join(ENV["HOME"], ".spoonerizerc"))
11
+ end
12
+
3
13
  require_relative "spoonerize/config"
4
14
  require_relative "spoonerize/spoonerism"
5
15
  require_relative "spoonerize/bumper"
@@ -53,7 +63,7 @@ module Spoonerize
53
63
  ##
54
64
  # Loads a config file.
55
65
  #
56
- # @param [String] file
66
+ # @param [String] config_file
57
67
  #
58
68
  # @return [String] file
59
69
  def load_config_file(config_file)
data/spoonerize.gemspec CHANGED
@@ -10,6 +10,7 @@ Gem::Specification.new do |spec|
10
10
  spec.summary = %(Spoonerize phrases from the command line.)
11
11
  spec.description = %(Spoonerize phrases from the command line. Comes with an API)
12
12
  spec.homepage = "https://evanthegrayt.github.io/spoonerize/"
13
+ spec.required_ruby_version = ">= 3.2"
13
14
 
14
15
  # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
15
16
  # to allow pushing to a single host or delete this section to allow pushing to any host.
@@ -29,7 +30,11 @@ Gem::Specification.new do |spec|
29
30
  spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
30
31
  spec.require_paths = ["lib"]
31
32
  spec.add_dependency "csv"
33
+ spec.add_dependency "puma", "~> 8.0"
34
+ spec.add_dependency "rackup", "~> 2.3"
35
+ spec.add_dependency "sinatra", "~> 4.2"
32
36
  spec.add_development_dependency "rdoc"
33
37
  spec.add_development_dependency "rake", "~> 13.0", ">= 13.0.1"
38
+ spec.add_development_dependency "standard", "= 1.54.0"
34
39
  spec.add_development_dependency "test-unit", "~> 3.3", ">= 3.3.5"
35
40
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spoonerize
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Evan Gray
@@ -23,6 +23,48 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: puma
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '8.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '8.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rackup
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '2.3'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '2.3'
54
+ - !ruby/object:Gem::Dependency
55
+ name: sinatra
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '4.2'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '4.2'
26
68
  - !ruby/object:Gem::Dependency
27
69
  name: rdoc
28
70
  requirement: !ruby/object:Gem::Requirement
@@ -57,6 +99,20 @@ dependencies:
57
99
  - - ">="
58
100
  - !ruby/object:Gem::Version
59
101
  version: 13.0.1
102
+ - !ruby/object:Gem::Dependency
103
+ name: standard
104
+ requirement: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - '='
107
+ - !ruby/object:Gem::Version
108
+ version: 1.54.0
109
+ type: :development
110
+ prerelease: false
111
+ version_requirements: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - '='
114
+ - !ruby/object:Gem::Version
115
+ version: 1.54.0
60
116
  - !ruby/object:Gem::Dependency
61
117
  name: test-unit
62
118
  requirement: !ruby/object:Gem::Requirement
@@ -81,6 +137,7 @@ description: Spoonerize phrases from the command line. Comes with an API
81
137
  email: evanthegrayt@vivaldi.net
82
138
  executables:
83
139
  - spoonerize
140
+ - spoonerize-web
84
141
  extensions: []
85
142
  extra_rdoc_files: []
86
143
  files:
@@ -93,6 +150,7 @@ files:
93
150
  - Rakefile
94
151
  - _config.yml
95
152
  - bin/spoonerize
153
+ - bin/spoonerize-web
96
154
  - lib/spoonerize.rb
97
155
  - lib/spoonerize/bumper.rb
98
156
  - lib/spoonerize/cli.rb
@@ -100,6 +158,11 @@ files:
100
158
  - lib/spoonerize/log.rb
101
159
  - lib/spoonerize/spoonerism.rb
102
160
  - lib/spoonerize/version.rb
161
+ - lib/spoonerize/web.rb
162
+ - lib/spoonerize/web/cli.rb
163
+ - lib/spoonerize/web/public/styles.css
164
+ - lib/spoonerize/web/views/index.erb
165
+ - lib/spoonerize/web/views/layout.erb
103
166
  - spoonerize.gemspec
104
167
  homepage: https://evanthegrayt.github.io/spoonerize/
105
168
  licenses:
@@ -116,7 +179,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
116
179
  requirements:
117
180
  - - ">="
118
181
  - !ruby/object:Gem::Version
119
- version: '0'
182
+ version: '3.2'
120
183
  required_rubygems_version: !ruby/object:Gem::Requirement
121
184
  requirements:
122
185
  - - ">="