make_taggable 0.7.4 → 1.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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -0
  3. data/CONTRIBUTING.md +65 -22
  4. data/LICENSE.md +18 -17
  5. data/README.md +73 -444
  6. data/UPGRADING.md +28 -2
  7. data/docs/caching.md +93 -0
  8. data/docs/configuration.md +108 -0
  9. data/docs/contexts.md +147 -0
  10. data/docs/database.md +106 -0
  11. data/docs/getting-started.md +114 -0
  12. data/docs/migrating-from-aato.md +67 -0
  13. data/docs/ownership.md +111 -0
  14. data/docs/parsers.md +109 -0
  15. data/docs/querying.md +163 -0
  16. data/docs/tag-clouds.md +74 -0
  17. data/lib/make_taggable/default_parser.rb +45 -32
  18. data/lib/make_taggable/engine.rb +6 -0
  19. data/lib/make_taggable/generic_parser.rb +30 -4
  20. data/lib/make_taggable/tag.rb +121 -19
  21. data/lib/make_taggable/tag_list.rb +77 -24
  22. data/lib/make_taggable/taggable/cache.rb +61 -11
  23. data/lib/make_taggable/taggable/collection.rb +102 -30
  24. data/lib/make_taggable/taggable/core.rb +154 -34
  25. data/lib/make_taggable/taggable/ownership.rb +82 -4
  26. data/lib/make_taggable/taggable/related.rb +66 -3
  27. data/lib/make_taggable/taggable/tag_list_type.rb +8 -0
  28. data/lib/make_taggable/taggable/tagged_with_query/all_tags_query.rb +10 -0
  29. data/lib/make_taggable/taggable/tagged_with_query/any_tags_query.rb +10 -0
  30. data/lib/make_taggable/taggable/tagged_with_query/exclude_tags_query.rb +10 -0
  31. data/lib/make_taggable/taggable/tagged_with_query/query_base.rb +15 -0
  32. data/lib/make_taggable/taggable/tagged_with_query.rb +17 -0
  33. data/lib/make_taggable/taggable.rb +34 -33
  34. data/lib/make_taggable/tagger.rb +73 -12
  35. data/lib/make_taggable/tagging.rb +30 -3
  36. data/lib/make_taggable/tags_helper.rb +22 -1
  37. data/lib/make_taggable/utils.rb +40 -5
  38. data/lib/make_taggable/version.rb +8 -1
  39. data/lib/make_taggable.rb +153 -8
  40. data/make_taggable.gemspec +13 -20
  41. metadata +50 -165
  42. data/.dummyrc +0 -17
  43. data/.github/workflows/ci.yml +0 -140
  44. data/.github/workflows/standard-ci.yml +0 -27
  45. data/.gitignore +0 -17
  46. data/.rspec +0 -3
  47. data/Appraisals +0 -15
  48. data/Gemfile +0 -16
  49. data/Rakefile +0 -13
  50. data/gemfiles/rails_5.gemfile +0 -9
  51. data/gemfiles/rails_6.gemfile +0 -9
  52. data/gemfiles/rails_6_1.gemfile +0 -9
  53. data/gemfiles/rails_master.gemfile +0 -9
  54. data/lib/tasks/setup_test_db.rake +0 -8
data/docs/ownership.md ADDED
@@ -0,0 +1,111 @@
1
+ # Tag ownership
2
+
3
+ A tag can be applied *by* someone. That someone is a tagger, and the tags they applied are owned by
4
+ them. It is how you build "your tags" alongside everyone else's on the same record.
5
+
6
+ ## Declaring a tagger
7
+
8
+ ```ruby
9
+ class User < ApplicationRecord
10
+ make_tagger
11
+ end
12
+
13
+ class Photo < ApplicationRecord
14
+ make_taggable :locations
15
+ end
16
+ ```
17
+
18
+ `make_tagger` adds two associations to the model: `owned_taggings` and `owned_tags`.
19
+
20
+ ## Applying owned tags
21
+
22
+ ```ruby
23
+ user.tag(photo, with: "paris, normandy", on: :locations)
24
+ ```
25
+
26
+ `with` and `on` are both required — `tag` raises without them. The taggable is saved for you unless
27
+ you pass `skip_save: true`:
28
+
29
+ ```ruby
30
+ user.tag(photo, with: "paris", on: :locations, skip_save: true)
31
+ photo.save # when you're ready
32
+ ```
33
+
34
+ By default you may tag in a context the model does not declare. Pass `force: false` to require a
35
+ declared context:
36
+
37
+ ```ruby
38
+ user.tag(photo, with: "paris", on: :undeclared, force: false)
39
+ # => RuntimeError: No context :undeclared defined in Photo
40
+ ```
41
+
42
+ ## The one thing that surprises everyone
43
+
44
+ **`tag_list` never returns owned tags.** It returns only tags with no owner.
45
+
46
+ ```ruby
47
+ photo.tag_list # => [] <- not what most people expect
48
+ photo.all_tags_list # => ["paris", "normandy"]
49
+ ```
50
+
51
+ The same split applies per context: `location_list` excludes owned tags, `all_locations_list`
52
+ includes them. If every tag on a record is applied by a tagger, `tag_list` will always look empty.
53
+
54
+ So: use `all_*_list` to display what a record is tagged with, and `*_list` only when you
55
+ specifically mean the unowned tags.
56
+
57
+ ## Reading owned tags
58
+
59
+ ```ruby
60
+ photo.locations_from(user) # => ["paris", "normandy"]
61
+ photo.owner_tags_on(user, :locations) # => [#<MakeTaggable::Tag name: "paris">, ...]
62
+ photo.owner_tags_on(nil, :locations) # => every tag on the photo, owned or not
63
+
64
+ user.owned_taggings
65
+ user.owned_tags
66
+ ```
67
+
68
+ Find records by who tagged them:
69
+
70
+ ```ruby
71
+ Photo.tagged_with("paris", on: :locations, owned_by: user)
72
+ ```
73
+
74
+ ## Owned tags are replaced, not appended
75
+
76
+ Assigning an owner's tags overwrites everything that owner previously applied in that context:
77
+
78
+ ```ruby
79
+ user.tag(photo, with: "paris", on: :locations)
80
+ user.tag(photo, with: "normandy", on: :locations)
81
+
82
+ photo.locations_from(user) # => ["normandy"] <- "paris" is gone
83
+ ```
84
+
85
+ To add to what is already there, read the current list and write it back with the addition:
86
+
87
+ ```ruby
88
+ def add_owned_tag(photo, user, tag)
89
+ current = photo.locations_from(user)
90
+ user.tag(photo, with: (current + [tag]).join(", "), on: :locations)
91
+ photo.save
92
+ end
93
+ ```
94
+
95
+ And to remove one:
96
+
97
+ ```ruby
98
+ def remove_owned_tag(photo, user, tag)
99
+ current = photo.locations_from(user)
100
+ user.tag(photo, with: (current - [tag]).join(", "), on: :locations)
101
+ photo.save
102
+ end
103
+ ```
104
+
105
+ Joining with `", "` matches the default delimiter. If you have configured a different one, use
106
+ `MakeTaggable.glue` instead of a literal.
107
+
108
+ ## Ordering
109
+
110
+ On a model declared with `make_ordered_taggable`, owned tags keep the order they were applied in,
111
+ the same as unowned ones. See [contexts.md](contexts.md).
data/docs/parsers.md ADDED
@@ -0,0 +1,109 @@
1
+ # Parsers and delimiters
2
+
3
+ Every string that becomes a tag list goes through a parser. The default splits on commas and
4
+ understands quoting.
5
+
6
+ ## The default parser
7
+
8
+ ```ruby
9
+ MakeTaggable::DefaultParser.new("One , Two, Three").parse
10
+ # => ["One", "Two", "Three"]
11
+ ```
12
+
13
+ A tag containing the delimiter can be quoted, with single or double quotes:
14
+
15
+ ```ruby
16
+ MakeTaggable::DefaultParser.new('"Ruby, Rails", Hotwire').parse
17
+ # => ["Ruby, Rails", "Hotwire"]
18
+ ```
19
+
20
+ `TagList#to_s` quotes on the way back out, so a list survives a round trip through a text field:
21
+
22
+ ```ruby
23
+ MakeTaggable::TagList.new("Round", "Square,Cube").to_s
24
+ # => 'Round, "Square,Cube"'
25
+ ```
26
+
27
+ ## Delimiters
28
+
29
+ The delimiter defaults to `","`. Set one, or several:
30
+
31
+ ```ruby
32
+ MakeTaggable.delimiter = ";"
33
+ MakeTaggable.delimiter = [",", "|", ";"]
34
+ ```
35
+
36
+ **Delimiters are literal strings.** Regular expression metacharacters are escaped for you, so
37
+ `"|"`, `"."` and `"("` all mean themselves:
38
+
39
+ ```ruby
40
+ MakeTaggable.delimiter = [",", "|"]
41
+ MakeTaggable::DefaultParser.new("a|b,c").parse # => ["a", "b", "c"]
42
+ ```
43
+
44
+ > **Changed in 1.0.** Delimiters used to be interpolated into a pattern unescaped, which meant you
45
+ > had to pass `'\|'` to split on a pipe, and a `"."` delimiter silently matched every character and
46
+ > discarded the whole list. If you are passing pre-escaped delimiters, unescape them.
47
+
48
+ `MakeTaggable.delimiter=` is deprecated in favour of a parser, and warns when Active Record has a
49
+ logger. It still works, and reading `MakeTaggable.delimiter` is not deprecated.
50
+
51
+ Related: `MakeTaggable.glue` is what tags are joined with for display. It is the first configured
52
+ delimiter, with a trailing space added if it has none, which is why a comma delimiter renders as
53
+ `"one, two"`.
54
+
55
+ ## Writing a parser
56
+
57
+ Subclass `MakeTaggable::GenericParser` and implement `parse`, returning a `TagList`. The raw input
58
+ is in `@tag_list`.
59
+
60
+ ```ruby
61
+ class PipeParser < MakeTaggable::GenericParser
62
+ def parse
63
+ MakeTaggable::TagList.new.tap do |tag_list|
64
+ tag_list.add @tag_list.split("|")
65
+ end
66
+ end
67
+ end
68
+ ```
69
+
70
+ Use it for one call:
71
+
72
+ ```ruby
73
+ user.tag_list.add("north|west", parser: PipeParser)
74
+ ```
75
+
76
+ For one list:
77
+
78
+ ```ruby
79
+ user.tag_list.parser = PipeParser
80
+ user.tag_list.add("north|west")
81
+ ```
82
+
83
+ Or everywhere:
84
+
85
+ ```ruby
86
+ MakeTaggable.default_parser = PipeParser
87
+ ```
88
+
89
+ The global setting is what `tag_list=` uses, so it is the only one of the three that changes how
90
+ assignment behaves:
91
+
92
+ ```ruby
93
+ MakeTaggable.default_parser = PipeParser
94
+ user.tag_list = "east|south"
95
+ user.tag_list # => ["east", "south"]
96
+ ```
97
+
98
+ `GenericParser` is itself a working parser — it splits on commas, ignoring surrounding whitespace
99
+ and empty entries — so subclass it when you want a different format, and use it directly when you
100
+ want comma splitting without the quoting rules.
101
+
102
+ ## What happens after parsing
103
+
104
+ Whatever a parser returns is cleaned before it is stored: entries are converted to strings,
105
+ stripped of surrounding whitespace, and blanks and duplicates are dropped. Two settings also apply
106
+ here — `force_lowercase` and `force_parameterize` — see [configuration.md](configuration.md).
107
+
108
+ Duplicate detection follows `strict_case_match`: with it off, `"Ruby"` and `"ruby"` are the same
109
+ tag; with it on, they are two.
data/docs/querying.md ADDED
@@ -0,0 +1,163 @@
1
+ # Querying
2
+
3
+ ## `tagged_with`
4
+
5
+ `tagged_with` returns a relation, so it chains with your own scopes, with `order`, and with
6
+ pagination.
7
+
8
+ ```ruby
9
+ User.tagged_with("awesome").by_join_date.limit(20)
10
+ ```
11
+
12
+ By default every tag given must be present:
13
+
14
+ ```ruby
15
+ User.tagged_with(["awesome", "cool"]) # carries awesome AND cool
16
+ User.tagged_with(["awesome", "cool"], any: true) # carries awesome OR cool
17
+ User.tagged_with(["awesome", "cool"], exclude: true) # carries NEITHER
18
+ ```
19
+
20
+ Passing nothing matches nothing. `User.tagged_with([])` and `User.tagged_with("")` both return an
21
+ empty relation rather than every record — worth knowing when the tags come from user input.
22
+
23
+ ### Options
24
+
25
+ | Option | Effect |
26
+ |---|---|
27
+ | `:any` | Match records carrying at least one of the tags |
28
+ | `:exclude` | Match records carrying none of the tags |
29
+ | `:match_all` | Match records carrying only these tags and no others |
30
+ | `:wild` | Match tags *containing* the given text, i.e. `%awesome%` |
31
+ | `:on` | Restrict to one context |
32
+ | `:owned_by` | Restrict to tags applied by one tagger |
33
+ | `:order_by_matching_tag_count` | With `:any`, order by how many tags matched, most first |
34
+ | `:start_at` | Only tags applied after this time |
35
+ | `:end_at` | Only tags applied before this time |
36
+
37
+ `:wild` combines with `:any` or `:exclude`:
38
+
39
+ ```ruby
40
+ User.tagged_with(["awe", "co"], any: true, wild: true)
41
+ ```
42
+
43
+ Contexts are matched one call at a time, so chain to combine them:
44
+
45
+ ```ruby
46
+ User
47
+ .tagged_with(["awesome", "cool"], on: :tags, any: true)
48
+ .tagged_with(["smart", "shy"], on: :skills, any: true)
49
+ ```
50
+
51
+ ### Case sensitivity
52
+
53
+ Tag matching is case insensitive by default: `tagged_with("Ruby")` finds records tagged `"ruby"`.
54
+ Set `MakeTaggable.strict_case_match = true` to match exactly. See
55
+ [configuration.md](configuration.md), and the SQLite note in [database.md](database.md) — SQLite
56
+ cannot change the case of non-ASCII characters without an extension.
57
+
58
+ ## Counting tags
59
+
60
+ `tag_counts_on` returns tags with a `count` attribute, which is what tag clouds are built from.
61
+
62
+ ```ruby
63
+ Book.tag_counts_on(:genres)
64
+ book.tag_counts_on(:genres) # just this book's genres
65
+ Book.genre_counts # the same, generated per context
66
+ Book.top_genres(10) # the ten most used, most first
67
+ ```
68
+
69
+ `count` is an attribute on the returned tags, not a method call on the relation:
70
+
71
+ ```ruby
72
+ Book.tag_counts_on(:genres).each do |tag|
73
+ puts "#{tag.name}: #{tag.count}"
74
+ end
75
+ ```
76
+
77
+ ### `all_tags` and `all_tag_counts`
78
+
79
+ The generated methods above are wrappers around these two, which take the full option set:
80
+
81
+ ```ruby
82
+ Book.all_tag_counts(
83
+ on: :genres,
84
+ at_least: 5,
85
+ at_most: 100,
86
+ order: "count desc",
87
+ limit: 20,
88
+ start_at: 1.month.ago
89
+ )
90
+ ```
91
+
92
+ | Option | `all_tags` | `all_tag_counts` | Effect |
93
+ |---|:--:|:--:|---|
94
+ | `:on` | ✓ | ✓ | Restrict to one context |
95
+ | `:start_at` | ✓ | ✓ | Only tags applied after this time |
96
+ | `:end_at` | ✓ | ✓ | Only tags applied before this time |
97
+ | `:conditions` | ✓ | ✓ | SQL conditions added to the tag query |
98
+ | `:order` | ✓ | ✓ | SQL to order by |
99
+ | `:limit` | ✓ | ✓ | Most tags to return |
100
+ | `:at_least` | | ✓ | Skip tags used fewer times than this |
101
+ | `:at_most` | | ✓ | Skip tags used more times than this |
102
+
103
+ Use `all_tags` when you only need the names: it skips the join onto the taggable table, so it is
104
+ noticeably cheaper on a large table.
105
+
106
+ ## Related records
107
+
108
+ Records sharing tags with this one, ordered by how many tags matched:
109
+
110
+ ```ruby
111
+ tom.skill_list # => ["hacking", "jogging", "diving"]
112
+ bobby.skill_list # => ["jogging", "diving"]
113
+ frankie.skill_list # => ["hacking"]
114
+
115
+ tom.find_related_skills # => [bobby, frankie]
116
+ bobby.find_related_skills # => [tom]
117
+ ```
118
+
119
+ Ignore some tags, or search a different model:
120
+
121
+ ```ruby
122
+ tom.find_related_skills(ignore: ["jogging"])
123
+ tom.find_related_skills_for(Company)
124
+ ```
125
+
126
+ To match one context's tags against a different context's, use `find_matching_contexts`:
127
+
128
+ ```ruby
129
+ # users whose :interests match this user's :skills
130
+ user.find_matching_contexts(:skills, :interests)
131
+ user.find_matching_contexts_for(Company, :skills, :markets)
132
+ ```
133
+
134
+ Both return records carrying a `count` attribute holding the number of matching tags.
135
+
136
+ ## Tags directly
137
+
138
+ `MakeTaggable::Tag` is an ordinary model, so query it when you want the vocabulary rather than the
139
+ records:
140
+
141
+ ```ruby
142
+ MakeTaggable::Tag.most_used # default limit 20
143
+ MakeTaggable::Tag.least_used(10)
144
+ MakeTaggable::Tag.named("ruby") # exact, honouring case sensitivity
145
+ MakeTaggable::Tag.named_any(%w[ruby rails])
146
+ MakeTaggable::Tag.named_like("rub") # contains
147
+ MakeTaggable::Tag.for_context(:skills) # used in this context, on any model
148
+ ```
149
+
150
+ `most_used` and `least_used` read the `taggings_count` counter cache, so they do not aggregate at
151
+ query time. If you set `MakeTaggable.tags_counter = false` that counter is not maintained and both
152
+ scopes will be wrong.
153
+
154
+ ## Performance notes
155
+
156
+ - `tagged_with` with several tags and no `:any` adds one join per tag. Matching a dozen tags in a
157
+ single call generates a dozen joins; prefer `any: true` where the semantics allow it.
158
+ - `:order_by_matching_tag_count` adds a correlated subquery to the `ORDER BY`. It is fine for a
159
+ page of results and expensive across a whole table.
160
+ - `all_tag_counts` joins the taggables to count them. Reach for `all_tags` when the counts are not
161
+ needed.
162
+ - Caching a rendered list in a column avoids loading tags to display them. See
163
+ [caching.md](caching.md).
@@ -0,0 +1,74 @@
1
+ # Tag clouds
2
+
3
+ A tag cloud sizes each tag by how often it is used. MakeTaggable gives you the counts and a helper
4
+ that maps them onto CSS classes.
5
+
6
+ ## Getting the counts
7
+
8
+ ```ruby
9
+ class BooksController < ApplicationController
10
+ def tag_cloud
11
+ @tags = Book.tag_counts_on(:genres)
12
+ end
13
+ end
14
+ ```
15
+
16
+ `tag_counts_on` returns tags carrying a `count` attribute. Limit it to the tags worth showing:
17
+
18
+ ```ruby
19
+ @tags = Book.all_tag_counts(on: :genres, at_least: 3, limit: 50, order: "count desc")
20
+ ```
21
+
22
+ Counts also work per record and per association:
23
+
24
+ ```ruby
25
+ book.tag_counts_on(:genres)
26
+ user.books.tag_counts_on(:genres)
27
+ ```
28
+
29
+ ## The helper
30
+
31
+ `MakeTaggable::TagsHelper` is mixed into Action View automatically, so `tag_cloud` is available in
32
+ any view. It yields each tag with the CSS class matching its frequency, smallest class first:
33
+
34
+ ```erb
35
+ <% tag_cloud(@tags, %w[cloud1 cloud2 cloud3 cloud4]) do |tag, css_class| %>
36
+ <%= link_to tag.name, books_path(tag: tag.name), class: css_class %>
37
+ <% end %>
38
+ ```
39
+
40
+ ```css
41
+ .cloud1 { font-size: 1.0em; }
42
+ .cloud2 { font-size: 1.2em; }
43
+ .cloud3 { font-size: 1.4em; }
44
+ .cloud4 { font-size: 1.6em; }
45
+ ```
46
+
47
+ The classes are spread across the range between the least and most used tag in the set you pass, so
48
+ the same tag can land in a different band depending on what it is shown alongside.
49
+
50
+ Two things to know:
51
+
52
+ - `tag_cloud` requires a block, and returns an empty array without calling it when `@tags` is empty.
53
+ - It reads `taggings_count`, not the `count` attribute from the query. That means it reflects how
54
+ often a tag is used **overall**, not how often within the scope you counted. For a cloud of one
55
+ user's tags, sizing by global popularity is usually not what you want — build the markup yourself
56
+ from `tag.count` in that case:
57
+
58
+ ```erb
59
+ <% max = @tags.map(&:count).max.to_f %>
60
+ <% @tags.each do |tag| %>
61
+ <%= link_to tag.name, books_path(tag: tag.name),
62
+ style: "font-size: #{1.0 + (tag.count / max)}em" %>
63
+ <% end %>
64
+ ```
65
+
66
+ ## Using it outside Action View
67
+
68
+ Include the module wherever you need it:
69
+
70
+ ```ruby
71
+ module PostsHelper
72
+ include MakeTaggable::TagsHelper
73
+ end
74
+ ```
@@ -1,11 +1,26 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module MakeTaggable
2
4
  ##
3
- # Returns a new TagList using the given tag string.
5
+ # The parser the library uses unless told otherwise.
6
+ #
7
+ # It splits on the configured delimiter, or delimiters, and understands quoting: a tag wrapped in
8
+ # single or double quotes may contain the delimiter itself.
9
+ #
10
+ # @example
11
+ # MakeTaggable::DefaultParser.new("One , Two, Three").parse
12
+ # # => ["One", "Two", "Three"]
13
+ #
14
+ # @example A tag containing the delimiter
15
+ # MakeTaggable::DefaultParser.new('"Ruby, Rails", Hotwire').parse
16
+ # # => ["Ruby, Rails", "Hotwire"]
4
17
  #
5
- # Example:
6
- # tag_list = MakeTaggable::DefaultParser.parse("One , Two, Three")
7
- # tag_list # ["One", "Two", "Three"]
8
18
  class DefaultParser < GenericParser
19
+ ##
20
+ # Splits the input into tags, honouring quotes and every configured delimiter.
21
+ #
22
+ # @return [MakeTaggable::TagList] the parsed tags
23
+ #
9
24
  def parse
10
25
  string = @tag_list
11
26
 
@@ -33,41 +48,39 @@ module MakeTaggable
33
48
  end
34
49
  end
35
50
 
36
- # private
51
+ ##
52
+ # The configured delimiters as a regular expression source, ready to interpolate into a pattern.
53
+ #
54
+ # @return [String]
55
+ #
56
+ # @api private
57
+ #
37
58
  def delimiter
38
- # Parse the quoted tags
39
- d = MakeTaggable.delimiter
40
- # Separate multiple delimiters by bitwise operator
41
- d = d.join("|") if d.is_a?(Array)
42
- d
59
+ # Delimiters are literal strings, so any regular expression metacharacter
60
+ # they contain has to be escaped before it reaches a pattern. Regexp.union
61
+ # handles both the escaping and the alternation between multiple
62
+ # delimiters.
63
+ Regexp.union(Array(MakeTaggable.delimiter)).source
43
64
  end
44
65
 
45
- # ( # Tag start delimiter ($1)
46
- # \A | # Either string start or
47
- # #{delimiter} # a delimiter
48
- # )
49
- # \s*" # quote (") optionally preceded by whitespace
50
- # (.*?) # Tag ($2)
51
- # "\s* # quote (") optionally followed by whitespace
52
- # (?= # Tag end delimiter (not consumed; is zero-length lookahead)
53
- # #{delimiter}\s* | # Either a delimiter optionally followed by whitespace or
54
- # \z # string end
55
- # )
66
+ ##
67
+ # Matches a double-quoted tag, bounded by the start of the string or a delimiter.
68
+ #
69
+ # @return [Regexp]
70
+ #
71
+ # @api private
72
+ #
56
73
  def double_quote_pattern
57
74
  /(\A|#{delimiter})\s*"(.*?)"\s*(?=#{delimiter}\s*|\z)/
58
75
  end
59
76
 
60
- # ( # Tag start delimiter ($1)
61
- # \A | # Either string start or
62
- # #{delimiter} # a delimiter
63
- # )
64
- # \s*' # quote (') optionally preceded by whitespace
65
- # (.*?) # Tag ($2)
66
- # '\s* # quote (') optionally followed by whitespace
67
- # (?= # Tag end delimiter (not consumed; is zero-length lookahead)
68
- # #{delimiter}\s* | d # Either a delimiter optionally followed by whitespace or
69
- # \z # string end
70
- # )
77
+ ##
78
+ # Matches a single-quoted tag, bounded by the start of the string or a delimiter.
79
+ #
80
+ # @return [Regexp]
81
+ #
82
+ # @api private
83
+ #
71
84
  def single_quote_pattern
72
85
  /(\A|#{delimiter})\s*'(.*?)'\s*(?=#{delimiter}\s*|\z)/
73
86
  end
@@ -1,4 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module MakeTaggable
4
+ ##
5
+ # Mounts the gem's migrations into a host Rails application, so they can be installed with
6
+ # `rails make_taggable_engine:install:migrations`.
7
+ #
2
8
  class Engine < Rails::Engine
3
9
  end
4
10
  end
@@ -1,15 +1,41 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module MakeTaggable
2
4
  ##
3
- # Returns a new TagList using the given tag string.
5
+ # The base class for tag parsers, and a working parser in its own right: it splits on commas and
6
+ # ignores surrounding whitespace.
7
+ #
8
+ # Subclass it to accept a different format, and point {MakeTaggable::Configuration#default_parser}
9
+ # at the subclass, or pass it per call as the `:parser` option to {MakeTaggable::TagList#add}.
10
+ #
11
+ # @example A parser splitting on pipes
12
+ # class PipeParser < MakeTaggable::GenericParser
13
+ # def parse
14
+ # MakeTaggable::TagList.new.tap do |tag_list|
15
+ # tag_list.add @tag_list.split("|")
16
+ # end
17
+ # end
18
+ # end
4
19
  #
5
- # Example:
6
- # tag_list = MakeTaggable::GenericParser.new.parse("One , Two, Three")
7
- # tag_list # ["One", "Two", "Three"]
8
20
  class GenericParser
21
+ ##
22
+ # Holds the input until {#parse} is called.
23
+ #
24
+ # @param tag_list [String, Array<String>, MakeTaggable::TagList] the tag input to parse
25
+ # @return [MakeTaggable::GenericParser]
26
+ #
9
27
  def initialize(tag_list)
10
28
  @tag_list = tag_list
11
29
  end
12
30
 
31
+ ##
32
+ # Splits the input on commas.
33
+ #
34
+ # @return [MakeTaggable::TagList] the parsed tags
35
+ #
36
+ # @example
37
+ # MakeTaggable::GenericParser.new("One , Two, Three").parse # => ["One", "Two", "Three"]
38
+ #
13
39
  def parse
14
40
  TagList.new.tap do |tag_list|
15
41
  tag_list.add @tag_list.split(",").map(&:strip).reject(&:empty?)