cooklang 1.0.0 → 1.0.2

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +35 -0
  3. data/.gitignore +12 -0
  4. data/.qlty/.gitignore +7 -0
  5. data/.qlty/configs/.yamllint.yaml +21 -0
  6. data/.qlty/qlty.toml +101 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +340 -0
  9. data/Gemfile +6 -0
  10. data/Gemfile.lock +84 -0
  11. data/README.md +197 -17
  12. data/Rakefile +12 -0
  13. data/cooklang.gemspec +35 -0
  14. data/lib/cooklang/builders/recipe_builder.rb +60 -0
  15. data/lib/cooklang/builders/step_builder.rb +74 -0
  16. data/lib/cooklang/formatter.rb +6 -2
  17. data/lib/cooklang/formatters/hash.rb +257 -0
  18. data/lib/cooklang/formatters/json.rb +13 -0
  19. data/lib/cooklang/formatters/text.rb +1 -3
  20. data/lib/cooklang/lexer.rb +5 -14
  21. data/lib/cooklang/parser.rb +18 -653
  22. data/lib/cooklang/parsers/cookware_parser.rb +130 -0
  23. data/lib/cooklang/parsers/ingredient_parser.rb +176 -0
  24. data/lib/cooklang/parsers/timer_parser.rb +132 -0
  25. data/lib/cooklang/processors/element_parser.rb +40 -0
  26. data/lib/cooklang/processors/metadata_processor.rb +127 -0
  27. data/lib/cooklang/processors/step_processor.rb +204 -0
  28. data/lib/cooklang/processors/token_processor.rb +101 -0
  29. data/lib/cooklang/recipe.rb +31 -24
  30. data/lib/cooklang/step.rb +12 -2
  31. data/lib/cooklang/timer.rb +3 -1
  32. data/lib/cooklang/token_stream.rb +130 -0
  33. data/lib/cooklang/version.rb +1 -1
  34. data/lib/cooklang.rb +31 -3
  35. data/spec/comprehensive_spec.rb +179 -0
  36. data/spec/cooklang_spec.rb +38 -0
  37. data/spec/fixtures/canonical.yaml +837 -0
  38. data/spec/formatters/text_spec.rb +189 -0
  39. data/spec/integration/canonical_spec.rb +211 -0
  40. data/spec/lexer_spec.rb +357 -0
  41. data/spec/models/cookware_spec.rb +116 -0
  42. data/spec/models/ingredient_spec.rb +192 -0
  43. data/spec/models/metadata_spec.rb +241 -0
  44. data/spec/models/note_spec.rb +65 -0
  45. data/spec/models/recipe_spec.rb +374 -0
  46. data/spec/models/section_spec.rb +65 -0
  47. data/spec/models/step_spec.rb +236 -0
  48. data/spec/models/timer_spec.rb +173 -0
  49. data/spec/parser_spec.rb +398 -0
  50. data/spec/spec_helper.rb +23 -0
  51. data/spec/token_stream_spec.rb +278 -0
  52. metadata +159 -4
data/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Cooklang
2
2
 
3
+ ![Gem Version](https://img.shields.io/gem/v/cooklang)
4
+ [![Test](https://github.com/jamesbrooks/cooklang/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/jamesbrooks/cooklang/actions/workflows/test.yml)
5
+ [![Maintainability](https://qlty.sh/gh/jamesbrooks/projects/cooklang/maintainability.svg)](https://qlty.sh/gh/jamesbrooks/projects/cooklang)
6
+ [![Code Coverage](https://qlty.sh/gh/jamesbrooks/projects/cooklang/coverage.svg)](https://qlty.sh/gh/jamesbrooks/projects/cooklang)
7
+
3
8
  A Ruby parser for the [Cooklang](https://cooklang.org) recipe markup language.
4
9
 
5
10
  ## Installation
@@ -26,7 +31,7 @@ recipe_text = <<~RECIPE
26
31
  >> servings: 4
27
32
 
28
33
  Crack @eggs{3} into a bowl, add @flour{125%g} and @milk{250%ml}.
29
-
34
+
30
35
  Heat #frying pan over medium heat for ~{5%minutes}.
31
36
  Pour batter and cook until golden.
32
37
  RECIPE
@@ -41,25 +46,200 @@ recipe.metadata['servings'] # => 4
41
46
  recipe.ingredients.each do |ingredient|
42
47
  puts "#{ingredient.name}: #{ingredient.quantity} #{ingredient.unit}"
43
48
  end
44
- # => eggs: 3
49
+ # => eggs: 3
45
50
  # => flour: 125 g
46
51
  # => milk: 250 ml
47
52
 
48
53
  # Parse from file
49
54
  recipe = Cooklang.parse_file('pancakes.cook')
55
+ ```
56
+
57
+ ## Formatters
58
+
59
+ The gem provides three built-in formatters for different output needs. Here's an example using Uncle Roger's Fried Rice recipe:
60
+
61
+ ```ruby
62
+ >> title: Uncle Roger's Fried Rice
63
+ >> servings: 2
64
+ >> prep_time: 10 minutes
65
+ >> cook_time: 8 minutes
66
+
67
+ Heat @peanut oil{1%tbsp} in #wok over high heat until smoking.
68
+
69
+ Add @garlic{3%cloves}(minced) and @shallots{2}(diced), stir-fry for ~{30%seconds}.
70
+
71
+ Add @eggs{3}(beaten) and scramble until almost set.
72
+
73
+ Add @day-old rice{3%cups} and stir-fry, breaking up clumps for ~{2%minutes}.
74
+
75
+ Season with @soy sauce{2%tbsp}, @sesame oil{1%tbsp}, @msg{1%tsp}, and @white pepper{0.5%tsp}.
76
+
77
+ Add @spring onions{3%stalks}(chopped) and stir for ~{30%seconds} more.
78
+
79
+ Serve immediately while hot!
80
+ ```
81
+
82
+ ### Text Formatter
83
+ Human-readable format with ingredients list and numbered steps:
84
+
85
+ Usage: `Cooklang::Formatters::Text.new(recipe).generate`
86
+
87
+ ```
88
+ Ingredients:
89
+ peanut oil 1 tbsp
90
+ garlic 3 cloves
91
+ shallots 2
92
+ eggs 3
93
+ day-old rice 3 cups
94
+ soy sauce 2 tbsp
95
+ sesame oil 1 tbsp
96
+ msg 1 tsp
97
+ white pepper 0.5 tsp
98
+ spring onions 3 stalks
99
+
100
+ Steps:
101
+ 1. Heat peanut oil in wok over high heat until smoking.
102
+ 2. Add garlic and shallots, stirfry for for 30 seconds.
103
+ 3. Add eggs and scramble until almost set.
104
+ 4. Add day-old rice and stirfry, breaking up clumps for for 2 minutes.
105
+ 5. Season with soy sauce, sesame oil, msg, and white pepper.
106
+ 6. Add spring onions and stir for for 30 seconds more.
107
+ 7. Serve immediately while hot!
108
+ ```
109
+
110
+ ### Hash Formatter
111
+ Structured Ruby hash format compatible with Cook CLI:
112
+
113
+ Usage: `Cooklang::Formatters::Hash.new(recipe).generate`
114
+
115
+ ```ruby
116
+ {:metadata=>
117
+ {:map=>
118
+ {"title"=>"Uncle Roger's Fried Rice",
119
+ "servings"=>"2",
120
+ "prep_time"=>"10 minutes",
121
+ "cook_time"=>"8 minutes"}},
122
+ :sections=>
123
+ [{:name=>nil,
124
+ :content=>
125
+ [{:type=>"step",
126
+ :value=>
127
+ {:items=>
128
+ [{:type=>"text", :value=>"Heat "},
129
+ {:type=>"ingredient", :index=>0},
130
+ {:type=>"text", :value=>" in "},
131
+ {:type=>"cookware", :index=>0},
132
+ {:type=>"text", :value=>" over high heat until smoking."}],
133
+ :number=>1}},
134
+ {:type=>"step",
135
+ :value=>
136
+ {:items=>
137
+ [{:type=>"text", :value=>"Add "},
138
+ {:type=>"ingredient", :index=>1},
139
+ {:type=>"text", :value=>" and "},
140
+ {:type=>"ingredient", :index=>2},
141
+ {:type=>"text", :value=>" and stir for "},
142
+ {:type=>"timer", :index=>0}],
143
+ :number=>2}}]}],
144
+ :ingredients=>
145
+ [{:name=>"peanut oil",
146
+ :quantity=>
147
+ {:value=>{:type=>"number", :value=>{:type=>"regular", :value=>1.0}},
148
+ :unit=>"tbsp"}},
149
+ {:name=>"garlic",
150
+ :quantity=>
151
+ {:value=>{:type=>"number", :value=>{:type=>"regular", :value=>3.0}},
152
+ :unit=>"cloves"},
153
+ :note=>"minced"},
154
+ {:name=>"spring onions",
155
+ :quantity=>
156
+ {:value=>{:type=>"number", :value=>{:type=>"regular", :value=>3.0}},
157
+ :unit=>"stalks"},
158
+ :note=>"chopped"}],
159
+ :cookware=>[{:name=>"wok"}],
160
+ :timers=>
161
+ [{:quantity=>
162
+ {:value=>{:type=>"number", :value=>{:type=>"regular", :value=>30.0}},
163
+ :unit=>"seconds"}},
164
+ {:quantity=>
165
+ {:value=>{:type=>"number", :value=>{:type=>"regular", :value=>2.0}},
166
+ :unit=>"minutes"}}]}
167
+ ```
50
168
 
51
- # Format as text
52
- formatter = Cooklang::Formatters::Text.new(recipe)
53
- puts formatter.to_s
54
- # Ingredients:
55
- # eggs 3
56
- # flour 125 g
57
- # milk 250 ml
58
- #
59
- # Steps:
60
- # 1. Crack eggs into a bowl, add flour and milk.
61
- # 2. Heat frying pan over medium heat for 5 minutes.
62
- # 3. Pour batter and cook until golden.
169
+ ### JSON Formatter
170
+ JSON string format for APIs and data interchange:
171
+
172
+ Usage: `Cooklang::Formatters::Json.new(recipe).generate`
173
+
174
+ ```json
175
+ {
176
+ "metadata": {
177
+ "map": {
178
+ "title": "Uncle Roger's Fried Rice",
179
+ "servings": "2",
180
+ "prep_time": "10 minutes",
181
+ "cook_time": "8 minutes"
182
+ }
183
+ },
184
+ "sections": [
185
+ {
186
+ "name": null,
187
+ "content": [
188
+ {
189
+ "type": "step",
190
+ "value": {
191
+ "items": [
192
+ {"type": "text", "value": "Heat "},
193
+ {"type": "ingredient", "index": 0},
194
+ {"type": "text", "value": " in "},
195
+ {"type": "cookware", "index": 0},
196
+ {"type": "text", "value": " over high heat until smoking."}
197
+ ],
198
+ "number": 1
199
+ }
200
+ },
201
+ {
202
+ "type": "step",
203
+ "value": {
204
+ "items": [
205
+ {"type": "text", "value": "Add "},
206
+ {"type": "ingredient", "index": 1},
207
+ {"type": "text", "value": " and stir for "},
208
+ {"type": "timer", "index": 0}
209
+ ],
210
+ "number": 2
211
+ }
212
+ }
213
+ ]
214
+ }
215
+ ],
216
+ "ingredients": [
217
+ {
218
+ "name": "peanut oil",
219
+ "quantity": {
220
+ "value": {"type": "number", "value": {"type": "regular", "value": 1.0}},
221
+ "unit": "tbsp"
222
+ }
223
+ },
224
+ {
225
+ "name": "garlic",
226
+ "quantity": {
227
+ "value": {"type": "number", "value": {"type": "regular", "value": 3.0}},
228
+ "unit": "cloves"
229
+ },
230
+ "note": "minced"
231
+ }
232
+ ],
233
+ "cookware": [{"name": "wok"}],
234
+ "timers": [
235
+ {
236
+ "quantity": {
237
+ "value": {"type": "number", "value": {"type": "regular", "value": 30.0}},
238
+ "unit": "seconds"
239
+ }
240
+ }
241
+ ]
242
+ }
63
243
  ```
64
244
 
65
245
  ## API
@@ -68,7 +248,7 @@ puts formatter.to_s
68
248
  # Recipe object
69
249
  recipe.metadata # Hash of metadata
70
250
  recipe.ingredients # Array of Ingredient objects
71
- recipe.cookware # Array of Cookware objects
251
+ recipe.cookware # Array of Cookware objects
72
252
  recipe.timers # Array of Timer objects
73
253
  recipe.steps # Array of Step objects
74
254
  recipe.steps_text # Array of plain text steps
@@ -103,7 +283,7 @@ timer.unit # "minutes"
103
283
  # Install dependencies
104
284
  bundle install
105
285
 
106
- # Run tests
286
+ # Run tests
107
287
  bundle exec rspec
108
288
 
109
289
  # Run linter
@@ -121,4 +301,4 @@ Bug reports and pull requests welcome on GitHub.
121
301
 
122
302
  ## License
123
303
 
124
- MIT License
304
+ MIT License
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/cooklang.gemspec ADDED
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path("../lib", __FILE__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require "cooklang/version"
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = "cooklang"
9
+ spec.version = Cooklang::VERSION
10
+ spec.authors = ["James Brooks"]
11
+ spec.email = ["james@jamesbrooks.net"]
12
+
13
+ spec.summary = "A Ruby parser for the Cooklang recipe markup language."
14
+ spec.description = "Cooklang is a markup language for recipes that allows you to define ingredients, cookware, timers, and metadata in a structured way. This gem provides a Ruby parser for Cooklang files."
15
+ spec.homepage = "https://github.com/jamesbrooks/cooklang"
16
+ spec.license = "MIT"
17
+ spec.required_ruby_version = ">= 3.2.0"
18
+
19
+ spec.metadata["homepage_uri"] = spec.homepage
20
+ spec.metadata["source_code_uri"] = "https://github.com/jamesbrooks/cooklang"
21
+ spec.metadata["rubygems_mfa_required"] = "true"
22
+
23
+ spec.files = `git ls-files`.split($/)
24
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
25
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
26
+ spec.require_paths = ["lib"]
27
+
28
+ spec.add_development_dependency "bundler", "~> 2.7"
29
+ spec.add_development_dependency "rake", "~> 13.0"
30
+ spec.add_development_dependency "rspec", "~> 3.13"
31
+ spec.add_development_dependency "rubocop", "~> 1.80"
32
+ spec.add_development_dependency "rubocop-performance", "~> 1.25"
33
+ spec.add_development_dependency "rubocop-rspec", "~> 3.6"
34
+ spec.add_development_dependency "simplecov", "~> 0.22.0"
35
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cooklang
4
+ module Builders
5
+ class RecipeBuilder
6
+ class << self
7
+ def build_recipe(parsed_steps, metadata)
8
+ # Aggregate all elements from steps
9
+ all_ingredients = []
10
+ all_cookware = []
11
+ all_timers = []
12
+ steps = []
13
+ sections = []
14
+
15
+ parsed_steps.each do |step_data|
16
+ all_ingredients.concat(step_data[:ingredients])
17
+ all_cookware.concat(step_data[:cookware])
18
+ all_timers.concat(step_data[:timers])
19
+
20
+ step = Step.new(segments: step_data[:segments])
21
+ steps << step
22
+
23
+ # Create section if this step has a section name
24
+ if step_data[:section_name]
25
+ sections << Section.new(name: step_data[:section_name], steps: [step])
26
+ end
27
+ end
28
+
29
+ # Deduplicate ingredients and cookware
30
+ unique_ingredients = deduplicate_ingredients(all_ingredients)
31
+ unique_cookware = deduplicate_cookware(all_cookware)
32
+
33
+ Recipe.new(
34
+ ingredients: unique_ingredients,
35
+ cookware: unique_cookware,
36
+ timers: all_timers,
37
+ steps: steps,
38
+ metadata: metadata,
39
+ sections: sections
40
+ )
41
+ end
42
+
43
+ private
44
+ def deduplicate_ingredients(ingredients)
45
+ # Group by name AND quantity to preserve different quantities of same ingredient
46
+ grouped = ingredients.group_by { |i| [i.name, i.quantity, i.unit] }
47
+ grouped.values.map(&:first)
48
+ end
49
+
50
+ def deduplicate_cookware(cookware_items)
51
+ # Group by name and prefer items with quantity over those without
52
+ cookware_items.group_by(&:name).map do |_name, items|
53
+ # Prefer items with quantity, then take the first one
54
+ items.find(&:quantity) || items.first
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cooklang
4
+ module Builders
5
+ class StepBuilder
6
+ def initialize
7
+ @segments = []
8
+ @ingredients = []
9
+ @cookware = []
10
+ @timers = []
11
+ @section_name = nil
12
+ end
13
+
14
+ def add_text(text)
15
+ @segments << text
16
+ self
17
+ end
18
+
19
+ def add_ingredient(ingredient)
20
+ @ingredients << ingredient
21
+ @segments << ingredient
22
+ self
23
+ end
24
+
25
+ def add_cookware(cookware)
26
+ @cookware << cookware
27
+ @segments << cookware
28
+ self
29
+ end
30
+
31
+ def add_timer(timer)
32
+ @timers << timer
33
+ @segments << timer
34
+ self
35
+ end
36
+
37
+ def add_remaining_text(text)
38
+ if text && !text.empty?
39
+ @segments << text
40
+ end
41
+ self
42
+ end
43
+
44
+ def set_section_name(name)
45
+ @section_name = name unless name&.empty?
46
+ self
47
+ end
48
+
49
+ def build
50
+ # Clean up segments - remove trailing newlines
51
+ cleaned_segments = remove_trailing_newlines(@segments.dup)
52
+
53
+ Step.new(segments: cleaned_segments)
54
+ end
55
+
56
+ # Check if the step has meaningful content
57
+ def has_content?
58
+ @segments.any? { |segment| segment != "\n" && !(segment.is_a?(String) && segment.strip.empty?) }
59
+ end
60
+
61
+ # Access internal collections for compatibility
62
+ attr_reader :segments, :ingredients, :cookware, :timers, :section_name
63
+
64
+ private
65
+ def remove_trailing_newlines(segments)
66
+ # Remove trailing newlines and whitespace-only text segments
67
+ segments.pop while !segments.empty? &&
68
+ (segments.last == "\n" ||
69
+ (segments.last.is_a?(String) && segments.last.strip.empty?))
70
+ segments
71
+ end
72
+ end
73
+ end
74
+ end
@@ -8,8 +8,12 @@ module Cooklang
8
8
  @recipe = recipe
9
9
  end
10
10
 
11
- def to_s
12
- raise NotImplementedError, "Subclasses must implement #to_s"
11
+ def generate(*args)
12
+ raise NotImplementedError, "Subclasses must implement #generate"
13
+ end
14
+
15
+ def to_s(*args)
16
+ generate(*args).to_s
13
17
  end
14
18
 
15
19
  private