cooklang 1.0.1 → 1.0.3

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: c61d7de5e0471ab52eb21411d501fb1b909a5e1c356b2eeb105a37c971847321
4
- data.tar.gz: 24fb626c2590541600acf57b317255adbb6fa75abc34ace698c70c8fcf2a5c90
3
+ metadata.gz: f4a3402d1f6f0e5864c0e59c8f237f0d06ca9a469ef3783f51b49e48d2ab1adb
4
+ data.tar.gz: bba07497106184771d712868f8abaaca2538bff3b85278357ba8d62438e0d452
5
5
  SHA512:
6
- metadata.gz: 723008d0a4ea83eaec6c376f715f47a74edf29230a2fb8fcc2ec13d47250b65adc6027511343de7a6bd3e4d2b833e592add88373e298bf21e98a1fa217358166
7
- data.tar.gz: ff3a2b9eb011b525309a7ac19bda34c51eb9dd68f154958af8b164511b1955a61e786dfd3cb45dd51391682700b9b962e2b3a8f54bade6fc1a5d56ea4053b2b3
6
+ metadata.gz: 9d89c0f9d7828567958e37b3b98f2318d5d69e6bb64b31ef2a1536a14e142c031143c798236e36da450dd5f1ac15370306fa2694ef8358cbe790358c621e642d
7
+ data.tar.gz: f1d1881eccc351da77bb473447e4b1eb8508d951ed71ad044e082bc8ed7ab92cbbbd8cb9904a996595db74b04ee91ef56896a9b3d75b1c12e51a39503d3615fc
@@ -11,7 +11,7 @@ jobs:
11
11
  runs-on: ubuntu-latest
12
12
  strategy:
13
13
  matrix:
14
- ruby-version: ['3.2', '3.3', '3.4', '3.5']
14
+ ruby-version: ['2.7', '3.0', '3.1', '3.2', '3.3', '3.4', '3.5']
15
15
 
16
16
  steps:
17
17
  - uses: actions/checkout@v4
@@ -29,7 +29,7 @@ jobs:
29
29
  run: bundle exec rubocop
30
30
 
31
31
  - uses: qltysh/qlty-action/coverage@v2
32
- if: matrix.ruby-version == '3.5'
32
+ if: matrix.ruby-version == '3.4'
33
33
  with:
34
34
  token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
35
35
  files: coverage/.resultset.json
data/.gitignore CHANGED
@@ -10,3 +10,4 @@
10
10
 
11
11
  # rspec failure tracking
12
12
  .rspec_status
13
+ Gemfile.lock
data/README.md CHANGED
@@ -7,6 +7,10 @@
7
7
 
8
8
  A Ruby parser for the [Cooklang](https://cooklang.org) recipe markup language.
9
9
 
10
+ ## Requirements
11
+
12
+ - Ruby 2.7 or higher
13
+
10
14
  ## Installation
11
15
 
12
16
  Add to your Gemfile:
@@ -52,19 +56,194 @@ end
52
56
 
53
57
  # Parse from file
54
58
  recipe = Cooklang.parse_file('pancakes.cook')
59
+ ```
60
+
61
+ ## Formatters
62
+
63
+ The gem provides three built-in formatters for different output needs. Here's an example using Uncle Roger's Fried Rice recipe:
64
+
65
+ ```ruby
66
+ >> title: Uncle Roger's Fried Rice
67
+ >> servings: 2
68
+ >> prep_time: 10 minutes
69
+ >> cook_time: 8 minutes
70
+
71
+ Heat @peanut oil{1%tbsp} in #wok over high heat until smoking.
72
+
73
+ Add @garlic{3%cloves}(minced) and @shallots{2}(diced), stir-fry for ~{30%seconds}.
74
+
75
+ Add @eggs{3}(beaten) and scramble until almost set.
76
+
77
+ Add @day-old rice{3%cups} and stir-fry, breaking up clumps for ~{2%minutes}.
78
+
79
+ Season with @soy sauce{2%tbsp}, @sesame oil{1%tbsp}, @msg{1%tsp}, and @white pepper{0.5%tsp}.
80
+
81
+ Add @spring onions{3%stalks}(chopped) and stir for ~{30%seconds} more.
82
+
83
+ Serve immediately while hot!
84
+ ```
85
+
86
+ ### Text Formatter
87
+ Human-readable format with ingredients list and numbered steps:
88
+
89
+ Usage: `Cooklang::Formatters::Text.new(recipe).generate`
90
+
91
+ ```
92
+ Ingredients:
93
+ peanut oil 1 tbsp
94
+ garlic 3 cloves
95
+ shallots 2
96
+ eggs 3
97
+ day-old rice 3 cups
98
+ soy sauce 2 tbsp
99
+ sesame oil 1 tbsp
100
+ msg 1 tsp
101
+ white pepper 0.5 tsp
102
+ spring onions 3 stalks
103
+
104
+ Steps:
105
+ 1. Heat peanut oil in wok over high heat until smoking.
106
+ 2. Add garlic and shallots, stirfry for for 30 seconds.
107
+ 3. Add eggs and scramble until almost set.
108
+ 4. Add day-old rice and stirfry, breaking up clumps for for 2 minutes.
109
+ 5. Season with soy sauce, sesame oil, msg, and white pepper.
110
+ 6. Add spring onions and stir for for 30 seconds more.
111
+ 7. Serve immediately while hot!
112
+ ```
113
+
114
+ ### Hash Formatter
115
+ Structured Ruby hash format compatible with Cook CLI:
116
+
117
+ Usage: `Cooklang::Formatters::Hash.new(recipe).generate`
118
+
119
+ ```ruby
120
+ {:metadata=>
121
+ {:map=>
122
+ {"title"=>"Uncle Roger's Fried Rice",
123
+ "servings"=>"2",
124
+ "prep_time"=>"10 minutes",
125
+ "cook_time"=>"8 minutes"}},
126
+ :sections=>
127
+ [{:name=>nil,
128
+ :content=>
129
+ [{:type=>"step",
130
+ :value=>
131
+ {:items=>
132
+ [{:type=>"text", :value=>"Heat "},
133
+ {:type=>"ingredient", :index=>0},
134
+ {:type=>"text", :value=>" in "},
135
+ {:type=>"cookware", :index=>0},
136
+ {:type=>"text", :value=>" over high heat until smoking."}],
137
+ :number=>1}},
138
+ {:type=>"step",
139
+ :value=>
140
+ {:items=>
141
+ [{:type=>"text", :value=>"Add "},
142
+ {:type=>"ingredient", :index=>1},
143
+ {:type=>"text", :value=>" and "},
144
+ {:type=>"ingredient", :index=>2},
145
+ {:type=>"text", :value=>" and stir for "},
146
+ {:type=>"timer", :index=>0}],
147
+ :number=>2}}]}],
148
+ :ingredients=>
149
+ [{:name=>"peanut oil",
150
+ :quantity=>
151
+ {:value=>{:type=>"number", :value=>{:type=>"regular", :value=>1.0}},
152
+ :unit=>"tbsp"}},
153
+ {:name=>"garlic",
154
+ :quantity=>
155
+ {:value=>{:type=>"number", :value=>{:type=>"regular", :value=>3.0}},
156
+ :unit=>"cloves"},
157
+ :note=>"minced"},
158
+ {:name=>"spring onions",
159
+ :quantity=>
160
+ {:value=>{:type=>"number", :value=>{:type=>"regular", :value=>3.0}},
161
+ :unit=>"stalks"},
162
+ :note=>"chopped"}],
163
+ :cookware=>[{:name=>"wok"}],
164
+ :timers=>
165
+ [{:quantity=>
166
+ {:value=>{:type=>"number", :value=>{:type=>"regular", :value=>30.0}},
167
+ :unit=>"seconds"}},
168
+ {:quantity=>
169
+ {:value=>{:type=>"number", :value=>{:type=>"regular", :value=>2.0}},
170
+ :unit=>"minutes"}}]}
171
+ ```
55
172
 
56
- # Format as text
57
- formatter = Cooklang::Formatters::Text.new(recipe)
58
- puts formatter.to_s
59
- # Ingredients:
60
- # eggs 3
61
- # flour 125 g
62
- # milk 250 ml
63
- #
64
- # Steps:
65
- # 1. Crack eggs into a bowl, add flour and milk.
66
- # 2. Heat frying pan over medium heat for 5 minutes.
67
- # 3. Pour batter and cook until golden.
173
+ ### JSON Formatter
174
+ JSON string format for APIs and data interchange:
175
+
176
+ Usage: `Cooklang::Formatters::Json.new(recipe).generate`
177
+
178
+ ```json
179
+ {
180
+ "metadata": {
181
+ "map": {
182
+ "title": "Uncle Roger's Fried Rice",
183
+ "servings": "2",
184
+ "prep_time": "10 minutes",
185
+ "cook_time": "8 minutes"
186
+ }
187
+ },
188
+ "sections": [
189
+ {
190
+ "name": null,
191
+ "content": [
192
+ {
193
+ "type": "step",
194
+ "value": {
195
+ "items": [
196
+ {"type": "text", "value": "Heat "},
197
+ {"type": "ingredient", "index": 0},
198
+ {"type": "text", "value": " in "},
199
+ {"type": "cookware", "index": 0},
200
+ {"type": "text", "value": " over high heat until smoking."}
201
+ ],
202
+ "number": 1
203
+ }
204
+ },
205
+ {
206
+ "type": "step",
207
+ "value": {
208
+ "items": [
209
+ {"type": "text", "value": "Add "},
210
+ {"type": "ingredient", "index": 1},
211
+ {"type": "text", "value": " and stir for "},
212
+ {"type": "timer", "index": 0}
213
+ ],
214
+ "number": 2
215
+ }
216
+ }
217
+ ]
218
+ }
219
+ ],
220
+ "ingredients": [
221
+ {
222
+ "name": "peanut oil",
223
+ "quantity": {
224
+ "value": {"type": "number", "value": {"type": "regular", "value": 1.0}},
225
+ "unit": "tbsp"
226
+ }
227
+ },
228
+ {
229
+ "name": "garlic",
230
+ "quantity": {
231
+ "value": {"type": "number", "value": {"type": "regular", "value": 3.0}},
232
+ "unit": "cloves"
233
+ },
234
+ "note": "minced"
235
+ }
236
+ ],
237
+ "cookware": [{"name": "wok"}],
238
+ "timers": [
239
+ {
240
+ "quantity": {
241
+ "value": {"type": "number", "value": {"type": "regular", "value": 30.0}},
242
+ "unit": "seconds"
243
+ }
244
+ }
245
+ ]
246
+ }
68
247
  ```
69
248
 
70
249
  ## API
data/cooklang.gemspec CHANGED
@@ -14,7 +14,7 @@ Gem::Specification.new do |spec|
14
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
15
  spec.homepage = "https://github.com/jamesbrooks/cooklang"
16
16
  spec.license = "MIT"
17
- spec.required_ruby_version = ">= 3.2.0"
17
+ spec.required_ruby_version = ">= 2.7.0"
18
18
 
19
19
  spec.metadata["homepage_uri"] = spec.homepage
20
20
  spec.metadata["source_code_uri"] = "https://github.com/jamesbrooks/cooklang"
@@ -25,7 +25,7 @@ Gem::Specification.new do |spec|
25
25
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
26
26
  spec.require_paths = ["lib"]
27
27
 
28
- spec.add_development_dependency "bundler", "~> 2.7"
28
+ spec.add_development_dependency "bundler", ">= 2.1"
29
29
  spec.add_development_dependency "rake", "~> 13.0"
30
30
  spec.add_development_dependency "rspec", "~> 3.13"
31
31
  spec.add_development_dependency "rubocop", "~> 1.80"
@@ -1,9 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../recipe"
4
- require_relative "../step"
5
- require_relative "../section"
6
-
7
3
  module Cooklang
8
4
  module Builders
9
5
  class RecipeBuilder
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../step"
4
-
5
3
  module Cooklang
6
4
  module Builders
7
5
  class StepBuilder
@@ -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
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cooklang
4
+ module Formatters
5
+ class Hash < Formatter
6
+ def generate(*args)
7
+ {
8
+ metadata: format_metadata,
9
+ sections: format_sections,
10
+ ingredients: format_ingredients,
11
+ cookware: format_cookware,
12
+ timers: format_timers,
13
+ inline_quantities: [],
14
+ data: format_data
15
+ }
16
+ end
17
+
18
+ private
19
+ def format_metadata
20
+ {
21
+ map: recipe.metadata.to_h.transform_values(&:to_s)
22
+ }
23
+ end
24
+
25
+ def format_sections
26
+ [
27
+ {
28
+ name: nil,
29
+ content: format_steps_as_content
30
+ }
31
+ ]
32
+ end
33
+
34
+ def format_steps_as_content
35
+ recipe.steps.each_with_index.map do |step, index|
36
+ {
37
+ type: "step",
38
+ value: {
39
+ items: format_step_items(step),
40
+ number: index + 1
41
+ }
42
+ }
43
+ end
44
+ end
45
+
46
+ def format_step_items(step)
47
+ items = []
48
+
49
+ step.segments.each do |segment|
50
+ items << format_segment(segment)
51
+ end
52
+
53
+ items
54
+ end
55
+
56
+ def format_segment(segment)
57
+ case segment
58
+ when Hash
59
+ case segment[:type]
60
+ when :ingredient
61
+ {
62
+ type: "ingredient",
63
+ index: find_ingredient_index(segment[:name])
64
+ }
65
+ when :cookware
66
+ {
67
+ type: "cookware",
68
+ index: find_cookware_index(segment[:name])
69
+ }
70
+ when :timer
71
+ {
72
+ type: "timer",
73
+ index: find_timer_index(segment)
74
+ }
75
+ else
76
+ {
77
+ type: "text",
78
+ value: segment[:value] || ""
79
+ }
80
+ end
81
+ when String
82
+ {
83
+ type: "text",
84
+ value: segment
85
+ }
86
+ when Cooklang::Ingredient
87
+ {
88
+ type: "ingredient",
89
+ index: find_ingredient_index(segment.name)
90
+ }
91
+ when Cooklang::Cookware
92
+ {
93
+ type: "cookware",
94
+ index: find_cookware_index(segment.name)
95
+ }
96
+ when Cooklang::Timer
97
+ {
98
+ type: "timer",
99
+ index: find_timer_index(segment)
100
+ }
101
+ else
102
+ {
103
+ type: "text",
104
+ value: segment.to_s
105
+ }
106
+ end
107
+ end
108
+
109
+ def format_ingredients
110
+ recipe.ingredients.map do |ingredient|
111
+ {
112
+ name: ingredient.name,
113
+ alias: nil,
114
+ quantity: format_ingredient_quantity(ingredient),
115
+ note: ingredient.notes,
116
+ reference: nil,
117
+ relation: {
118
+ type: "definition",
119
+ referenced_from: [],
120
+ defined_in_step: true,
121
+ reference_target: nil
122
+ },
123
+ modifiers: ""
124
+ }
125
+ end
126
+ end
127
+
128
+ def format_ingredient_quantity(ingredient)
129
+ return nil unless ingredient.quantity && ingredient.quantity != "some"
130
+
131
+ {
132
+ value: {
133
+ type: "number",
134
+ value: {
135
+ type: "regular",
136
+ value: ingredient.quantity.to_f
137
+ }
138
+ },
139
+ unit: ingredient.unit
140
+ }
141
+ end
142
+
143
+ def format_cookware
144
+ recipe.cookware.map do |cookware_item|
145
+ {
146
+ name: cookware_item.name,
147
+ alias: nil,
148
+ quantity: format_cookware_quantity(cookware_item),
149
+ note: nil,
150
+ relation: {
151
+ type: "definition",
152
+ referenced_from: [],
153
+ defined_in_step: true
154
+ },
155
+ modifiers: ""
156
+ }
157
+ end
158
+ end
159
+
160
+ def format_cookware_quantity(cookware_item)
161
+ return nil unless cookware_item.quantity && cookware_item.quantity != 1
162
+
163
+ {
164
+ value: {
165
+ type: "number",
166
+ value: {
167
+ type: "regular",
168
+ value: cookware_item.quantity.to_f
169
+ }
170
+ },
171
+ unit: nil
172
+ }
173
+ end
174
+
175
+ def format_timers
176
+ recipe.timers.map do |timer|
177
+ {
178
+ name: (timer.name.nil? || timer.name.empty?) ? nil : timer.name,
179
+ quantity: format_timer_quantity(timer)
180
+ }
181
+ end
182
+ end
183
+
184
+ def format_timer_quantity(timer)
185
+ return nil unless timer.duration
186
+
187
+ {
188
+ value: {
189
+ type: "number",
190
+ value: {
191
+ type: "regular",
192
+ value: timer.duration.to_f
193
+ }
194
+ },
195
+ unit: timer.unit
196
+ }
197
+ end
198
+
199
+ def find_ingredient_index(name)
200
+ recipe.ingredients.find_index { |ingredient| ingredient.name == name } || 0
201
+ end
202
+
203
+ def find_cookware_index(name)
204
+ recipe.cookware.find_index { |cookware_item| cookware_item.name == name } || 0
205
+ end
206
+
207
+ def find_timer_index(timer_data)
208
+ # For hash segments, we need to match by duration and unit
209
+ if timer_data.is_a?(Hash)
210
+ recipe.timers.find_index do |timer|
211
+ timer.duration == timer_data[:duration] && timer.unit == timer_data[:unit]
212
+ end || 0
213
+ elsif timer_data.is_a?(Cooklang::Timer)
214
+ recipe.timers.find_index { |timer| timer == timer_data } || 0
215
+ else
216
+ 0
217
+ end
218
+ end
219
+
220
+ def format_data
221
+ {
222
+ type: "Scaled",
223
+ target: {
224
+ factor: 1.0
225
+ },
226
+ ingredients: format_ingredient_data,
227
+ cookware: format_cookware_data,
228
+ timers: format_timer_data
229
+ }
230
+ end
231
+
232
+ def format_ingredient_data
233
+ recipe.ingredients.map do |ingredient|
234
+ if ingredient.quantity && ingredient.quantity != "some"
235
+ "scaled"
236
+ else
237
+ "noQuantity"
238
+ end
239
+ end
240
+ end
241
+
242
+ def format_cookware_data
243
+ recipe.cookware.map do |cookware_item|
244
+ if cookware_item.quantity && cookware_item.quantity != 1
245
+ "scaled"
246
+ else
247
+ "noQuantity"
248
+ end
249
+ end
250
+ end
251
+
252
+ def format_timer_data
253
+ recipe.timers.map { "fixed" }
254
+ end
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Cooklang
6
+ module Formatters
7
+ class Json < Hash
8
+ def generate(*args)
9
+ super().to_json(*args)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -1,11 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../formatter"
4
-
5
3
  module Cooklang
6
4
  module Formatters
7
5
  class Text < Formatter
8
- def to_s
6
+ def generate(*args)
9
7
  sections = []
10
8
 
11
9
  sections << ingredients_section unless recipe.ingredients.empty?
@@ -23,8 +23,8 @@ module Cooklang
23
23
  super(key.to_s)
24
24
  end
25
25
 
26
- def fetch(key, *)
27
- super(key.to_s, *)
26
+ def fetch(key, *args)
27
+ super(key.to_s, *args)
28
28
  end
29
29
 
30
30
  def to_h
@@ -1,11 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "lexer"
4
- require_relative "processors/metadata_processor"
5
- require_relative "processors/token_processor"
6
- require_relative "processors/step_processor"
7
- require_relative "builders/recipe_builder"
8
-
9
3
  module Cooklang
10
4
  class Parser
11
5
  def parse(input)
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../cookware"
4
- require_relative "../token_stream"
5
-
6
3
  module Cooklang
7
4
  module Parsers
8
5
  class CookwareParser
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../ingredient"
4
- require_relative "../token_stream"
5
-
6
3
  module Cooklang
7
4
  module Parsers
8
5
  class IngredientParser
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../timer"
4
- require_relative "../token_stream"
5
-
6
3
  module Cooklang
7
4
  module Parsers
8
5
  class TimerParser
@@ -1,10 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../token_stream"
4
- require_relative "../parsers/ingredient_parser"
5
- require_relative "../parsers/cookware_parser"
6
- require_relative "../parsers/timer_parser"
7
-
8
3
  module Cooklang
9
4
  module Processors
10
5
  class ElementParser
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../metadata"
4
-
5
3
  module Cooklang
6
4
  module Processors
7
5
  class MetadataProcessor
@@ -1,9 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "element_parser"
4
- require_relative "../token_stream"
5
- require_relative "../builders/step_builder"
6
-
7
3
  module Cooklang
8
4
  module Processors
9
5
  class StepProcessor
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../lexer"
4
- require_relative "../note"
5
-
6
3
  module Cooklang
7
4
  module Processors
8
5
  class TokenProcessor
@@ -19,6 +19,7 @@ module Cooklang
19
19
  (value || []).freeze
20
20
  end
21
21
 
22
+
22
23
  public
23
24
 
24
25
  def ingredients_hash
@@ -35,15 +36,11 @@ module Cooklang
35
36
  end
36
37
 
37
38
  def to_h
38
- {
39
- ingredients: @ingredients.map(&:to_h),
40
- cookware: @cookware.map(&:to_h),
41
- timers: @timers.map(&:to_h),
42
- steps: @steps.map(&:to_h),
43
- metadata: @metadata.to_h,
44
- sections: @sections.map(&:to_h),
45
- notes: @notes.map(&:to_h)
46
- }
39
+ Formatters::Hash.new(self).generate
40
+ end
41
+
42
+ def to_json(*args)
43
+ Formatters::Json.new(self).generate(*args)
47
44
  end
48
45
 
49
46
  def ==(other)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cooklang
4
- VERSION = "1.0.1"
4
+ VERSION = "1.0.3"
5
5
  end
data/lib/cooklang.rb CHANGED
@@ -1,9 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "cooklang/version"
4
- require_relative "cooklang/lexer"
5
- require_relative "cooklang/parser"
6
- require_relative "cooklang/recipe"
4
+
5
+ # Core classes
7
6
  require_relative "cooklang/ingredient"
8
7
  require_relative "cooklang/cookware"
9
8
  require_relative "cooklang/timer"
@@ -11,8 +10,37 @@ require_relative "cooklang/step"
11
10
  require_relative "cooklang/metadata"
12
11
  require_relative "cooklang/section"
13
12
  require_relative "cooklang/note"
13
+
14
+ # Parsing infrastructure
15
+ require_relative "cooklang/token_stream"
16
+ require_relative "cooklang/lexer"
17
+
18
+ # Parsers (depend on token_stream and core classes)
19
+ require_relative "cooklang/parsers/ingredient_parser"
20
+ require_relative "cooklang/parsers/cookware_parser"
21
+ require_relative "cooklang/parsers/timer_parser"
22
+
23
+ # Processors (depend on parsers and token_stream)
24
+ require_relative "cooklang/processors/element_parser"
25
+ require_relative "cooklang/processors/metadata_processor"
26
+ require_relative "cooklang/processors/token_processor"
27
+
28
+ # Builders (depend on core classes)
29
+ require_relative "cooklang/builders/step_builder"
30
+ require_relative "cooklang/builders/recipe_builder"
31
+
32
+ # Processors that depend on builders
33
+ require_relative "cooklang/processors/step_processor"
34
+
35
+ # Main parser (depends on processors and builders)
36
+ require_relative "cooklang/parser"
37
+
38
+ # Recipe (depends on formatters)
14
39
  require_relative "cooklang/formatter"
40
+ require_relative "cooklang/formatters/hash"
41
+ require_relative "cooklang/formatters/json"
15
42
  require_relative "cooklang/formatters/text"
43
+ require_relative "cooklang/recipe"
16
44
 
17
45
  module Cooklang
18
46
  class Error < StandardError; end
@@ -96,7 +96,7 @@ RSpec.describe Cooklang::Recipe do
96
96
  end
97
97
 
98
98
  describe "#to_h" do
99
- it "returns complete hash representation" do
99
+ it "returns complete hash representation in Cook CLI format" do
100
100
  recipe = described_class.new(
101
101
  ingredients: [ingredient],
102
102
  cookware: [cookware],
@@ -107,11 +107,214 @@ RSpec.describe Cooklang::Recipe do
107
107
 
108
108
  hash = recipe.to_h
109
109
 
110
- expect(hash[:ingredients]).to eq([ingredient.to_h])
111
- expect(hash[:cookware]).to eq([cookware.to_h])
112
- expect(hash[:timers]).to eq([timer.to_h])
113
- expect(hash[:steps]).to eq([step.to_h])
114
- expect(hash[:metadata]).to eq(metadata.to_h)
110
+ expect(hash).to have_key(:metadata)
111
+ expect(hash).to have_key(:sections)
112
+ expect(hash).to have_key(:ingredients)
113
+ expect(hash).to have_key(:cookware)
114
+ expect(hash).to have_key(:timers)
115
+ expect(hash).to have_key(:inline_quantities)
116
+
117
+ expect(hash[:metadata]).to have_key(:map)
118
+ expect(hash[:ingredients]).to be_an(Array)
119
+ expect(hash[:cookware]).to be_an(Array)
120
+ expect(hash[:timers]).to be_an(Array)
121
+ expect(hash[:sections]).to be_an(Array)
122
+ end
123
+ end
124
+
125
+ describe "comprehensive output tests" do
126
+ let(:recipe_text) do
127
+ <<~RECIPE
128
+ >> title: Test Recipe
129
+ >> servings: 4
130
+ >> prep_time: 15 minutes
131
+
132
+ Heat #large pot{} over medium heat.
133
+
134
+ Add @olive oil{2%tbsp} and @onion{1%medium}(diced).
135
+ Cook for ~{5%minutes} until soft.
136
+
137
+ Add @salt{} and @pepper{} to taste.
138
+ Season with @garlic{2%cloves}(minced).
139
+ RECIPE
140
+ end
141
+
142
+ let(:parsed_recipe) { Cooklang.parse(recipe_text) }
143
+
144
+ let(:expected_hash) do
145
+ {
146
+ metadata: { map: { "title" => "Test Recipe", "servings" => "4", "prep_time" => "15 minutes" } },
147
+ sections: [
148
+ {
149
+ name: nil,
150
+ content: [
151
+ { type: "step", value: { items: [{ type: "text", value: "Heat " }, { type: "cookware", index: 0 }, { type: "text", value: " over medium heat." }], number: 1 } },
152
+ { type: "step", value: { items: [{ type: "text", value: "Add " }, { type: "ingredient", index: 0 }, { type: "text", value: " and " }, { type: "ingredient", index: 1 }, { type: "text", value: "." }, { type: "text", value: "\n" }, { type: "text", value: "Cook for " }, { type: "timer", index: 0 }, { type: "text", value: " until soft." }], number: 2 } },
153
+ { type: "step", value: { items: [{ type: "text", value: "Add " }, { type: "ingredient", index: 2 }, { type: "text", value: " and " }, { type: "ingredient", index: 3 }, { type: "text", value: " to taste." }, { type: "text", value: "\n" }, { type: "text", value: "Season with " }, { type: "ingredient", index: 4 }, { type: "text", value: "." }], number: 3 } }
154
+ ]
155
+ }
156
+ ],
157
+ ingredients: [
158
+ { name: "olive oil", alias: nil, quantity: { value: { type: "number", value: { type: "regular", value: 2.0 } }, unit: "tbsp" }, note: nil, reference: nil, relation: { type: "definition", referenced_from: [], defined_in_step: true, reference_target: nil }, modifiers: "" },
159
+ { name: "onion", alias: nil, quantity: { value: { type: "number", value: { type: "regular", value: 1.0 } }, unit: "medium" }, note: "diced", reference: nil, relation: { type: "definition", referenced_from: [], defined_in_step: true, reference_target: nil }, modifiers: "" },
160
+ { name: "salt", alias: nil, quantity: nil, note: nil, reference: nil, relation: { type: "definition", referenced_from: [], defined_in_step: true, reference_target: nil }, modifiers: "" },
161
+ { name: "pepper", alias: nil, quantity: nil, note: nil, reference: nil, relation: { type: "definition", referenced_from: [], defined_in_step: true, reference_target: nil }, modifiers: "" },
162
+ { name: "garlic", alias: nil, quantity: { value: { type: "number", value: { type: "regular", value: 2.0 } }, unit: "cloves" }, note: "minced", reference: nil, relation: { type: "definition", referenced_from: [], defined_in_step: true, reference_target: nil }, modifiers: "" }
163
+ ],
164
+ cookware: [
165
+ { name: "large pot", alias: nil, quantity: nil, note: nil, relation: { type: "definition", referenced_from: [], defined_in_step: true }, modifiers: "" }
166
+ ],
167
+ timers: [
168
+ { name: nil, quantity: { value: { type: "number", value: { type: "regular", value: 5.0 } }, unit: "minutes" } }
169
+ ],
170
+ inline_quantities: [],
171
+ data: { type: "Scaled", target: { factor: 1.0 }, ingredients: ["scaled", "scaled", "noQuantity", "noQuantity", "scaled"], cookware: ["noQuantity"], timers: ["fixed"] }
172
+ }
173
+ end
174
+
175
+ let(:expected_json) do
176
+ {
177
+ "metadata" => { "map" => { "title" => "Test Recipe", "servings" => "4", "prep_time" => "15 minutes" } },
178
+ "sections" => [
179
+ {
180
+ "name" => nil,
181
+ "content" => [
182
+ { "type" => "step", "value" => { "items" => [{ "type" => "text", "value" => "Heat " }, { "type" => "cookware", "index" => 0 }, { "type" => "text", "value" => " over medium heat." }], "number" => 1 } },
183
+ { "type" => "step", "value" => { "items" => [{ "type" => "text", "value" => "Add " }, { "type" => "ingredient", "index" => 0 }, { "type" => "text", "value" => " and " }, { "type" => "ingredient", "index" => 1 }, { "type" => "text", "value" => "." }, { "type" => "text", "value" => "\n" }, { "type" => "text", "value" => "Cook for " }, { "type" => "timer", "index" => 0 }, { "type" => "text", "value" => " until soft." }], "number" => 2 } },
184
+ { "type" => "step", "value" => { "items" => [{ "type" => "text", "value" => "Add " }, { "type" => "ingredient", "index" => 2 }, { "type" => "text", "value" => " and " }, { "type" => "ingredient", "index" => 3 }, { "type" => "text", "value" => " to taste." }, { "type" => "text", "value" => "\n" }, { "type" => "text", "value" => "Season with " }, { "type" => "ingredient", "index" => 4 }, { "type" => "text", "value" => "." }], "number" => 3 } }
185
+ ]
186
+ }
187
+ ],
188
+ "ingredients" => [
189
+ { "name" => "olive oil", "alias" => nil, "quantity" => { "value" => { "type" => "number", "value" => { "type" => "regular", "value" => 2.0 } }, "unit" => "tbsp" }, "note" => nil, "reference" => nil, "relation" => { "type" => "definition", "referenced_from" => [], "defined_in_step" => true, "reference_target" => nil }, "modifiers" => "" },
190
+ { "name" => "onion", "alias" => nil, "quantity" => { "value" => { "type" => "number", "value" => { "type" => "regular", "value" => 1.0 } }, "unit" => "medium" }, "note" => "diced", "reference" => nil, "relation" => { "type" => "definition", "referenced_from" => [], "defined_in_step" => true, "reference_target" => nil }, "modifiers" => "" },
191
+ { "name" => "salt", "alias" => nil, "quantity" => nil, "note" => nil, "reference" => nil, "relation" => { "type" => "definition", "referenced_from" => [], "defined_in_step" => true, "reference_target" => nil }, "modifiers" => "" },
192
+ { "name" => "pepper", "alias" => nil, "quantity" => nil, "note" => nil, "reference" => nil, "relation" => { "type" => "definition", "referenced_from" => [], "defined_in_step" => true, "reference_target" => nil }, "modifiers" => "" },
193
+ { "name" => "garlic", "alias" => nil, "quantity" => { "value" => { "type" => "number", "value" => { "type" => "regular", "value" => 2.0 } }, "unit" => "cloves" }, "note" => "minced", "reference" => nil, "relation" => { "type" => "definition", "referenced_from" => [], "defined_in_step" => true, "reference_target" => nil }, "modifiers" => "" }
194
+ ],
195
+ "cookware" => [
196
+ { "name" => "large pot", "alias" => nil, "quantity" => nil, "note" => nil, "relation" => { "type" => "definition", "referenced_from" => [], "defined_in_step" => true }, "modifiers" => "" }
197
+ ],
198
+ "timers" => [
199
+ { "name" => nil, "quantity" => { "value" => { "type" => "number", "value" => { "type" => "regular", "value" => 5.0 } }, "unit" => "minutes" } }
200
+ ],
201
+ "inline_quantities" => [],
202
+ "data" => { "type" => "Scaled", "target" => { "factor" => 1.0 }, "ingredients" => ["scaled", "scaled", "noQuantity", "noQuantity", "scaled"], "cookware" => ["noQuantity"], "timers" => ["fixed"] }
203
+ }
204
+ end
205
+
206
+ describe "#to_h" do
207
+ it "returns complete canonical structure" do
208
+ expect(parsed_recipe.to_h).to eq(expected_hash)
209
+ end
210
+ end
211
+
212
+ describe "#to_json" do
213
+ it "returns JSON with null for canonical defaults" do
214
+ expect(JSON.parse(parsed_recipe.to_json)).to eq(expected_json)
215
+ end
216
+ end
217
+
218
+ describe "canonical vs JSON consistency" do
219
+ it "maintains different representations for same recipe" do
220
+ # Internal canonical representation keeps defaults
221
+ expect(parsed_recipe.ingredients.find { |i| i.name == "salt" }.quantity).to eq("some")
222
+ expect(parsed_recipe.cookware.first.quantity).to eq(1)
223
+
224
+ # Both hash and JSON output convert defaults to nil for external use
225
+ hash = parsed_recipe.to_h
226
+ json_parsed = JSON.parse(parsed_recipe.to_json)
227
+
228
+ salt_hash = hash[:ingredients].find { |i| i[:name] == "salt" }
229
+ salt_json = json_parsed["ingredients"].find { |i| i["name"] == "salt" }
230
+ expect(salt_hash[:quantity]).to be_nil
231
+ expect(salt_json["quantity"]).to be_nil
232
+
233
+ expect(hash[:cookware].first[:quantity]).to be_nil
234
+ expect(json_parsed["cookware"].first["quantity"]).to be_nil
235
+ end
236
+ end
237
+ end
238
+
239
+ describe "edge cases" do
240
+ it "handles empty recipe" do
241
+ recipe = described_class.new(
242
+ ingredients: [],
243
+ cookware: [],
244
+ timers: [],
245
+ steps: [],
246
+ metadata: Cooklang::Metadata.new
247
+ )
248
+
249
+ hash = recipe.to_h
250
+ expect(hash[:ingredients]).to eq([])
251
+ expect(hash[:cookware]).to eq([])
252
+ expect(hash[:timers]).to eq([])
253
+ expect(hash[:sections].first[:content]).to eq([])
254
+
255
+ json = JSON.parse(recipe.to_json)
256
+ expect(json["ingredients"]).to eq([])
257
+ expect(json["cookware"]).to eq([])
258
+ expect(json["timers"]).to eq([])
259
+ end
260
+
261
+ it "handles ingredients with no quantity specified" do
262
+ ingredient = Cooklang::Ingredient.new(name: "salt")
263
+ recipe = described_class.new(
264
+ ingredients: [ingredient],
265
+ cookware: [],
266
+ timers: [],
267
+ steps: [],
268
+ metadata: Cooklang::Metadata.new
269
+ )
270
+
271
+ hash = recipe.to_h
272
+ salt = hash[:ingredients].first
273
+ expect(salt[:quantity]).to be_nil
274
+
275
+ json = JSON.parse(recipe.to_json)
276
+ salt_json = json["ingredients"].first
277
+ expect(salt_json["quantity"]).to be_nil
278
+ end
279
+
280
+ it "handles cookware with no quantity specified" do
281
+ cookware = Cooklang::Cookware.new(name: "pan")
282
+ recipe = described_class.new(
283
+ ingredients: [],
284
+ cookware: [cookware],
285
+ timers: [],
286
+ steps: [],
287
+ metadata: Cooklang::Metadata.new
288
+ )
289
+
290
+ hash = recipe.to_h
291
+ pan = hash[:cookware].first
292
+ expect(pan[:quantity]).to be_nil
293
+
294
+ json = JSON.parse(recipe.to_json)
295
+ pan_json = json["cookware"].first
296
+ expect(pan_json["quantity"]).to be_nil
297
+ end
298
+
299
+ it "handles fractional quantities" do
300
+ ingredient = Cooklang::Ingredient.new(name: "flour", quantity: 1.5, unit: "cups")
301
+ recipe = described_class.new(
302
+ ingredients: [ingredient],
303
+ cookware: [],
304
+ timers: [],
305
+ steps: [],
306
+ metadata: Cooklang::Metadata.new
307
+ )
308
+
309
+ hash = recipe.to_h
310
+ flour = hash[:ingredients].first
311
+ expect(flour[:quantity][:value][:value][:value]).to eq(1.5)
312
+ expect(flour[:quantity][:unit]).to eq("cups")
313
+
314
+ json = JSON.parse(recipe.to_json)
315
+ flour_json = json["ingredients"].first
316
+ expect(flour_json["quantity"]["value"]["value"]["value"]).to eq(1.5)
317
+ expect(flour_json["quantity"]["unit"]).to eq("cups")
115
318
  end
116
319
  end
117
320
 
metadata CHANGED
@@ -1,29 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cooklang
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - James Brooks
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2025-08-25 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: bundler
15
14
  requirement: !ruby/object:Gem::Requirement
16
15
  requirements:
17
- - - "~>"
16
+ - - ">="
18
17
  - !ruby/object:Gem::Version
19
- version: '2.7'
18
+ version: '2.1'
20
19
  type: :development
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
- - - "~>"
23
+ - - ">="
25
24
  - !ruby/object:Gem::Version
26
- version: '2.7'
25
+ version: '2.1'
27
26
  - !ruby/object:Gem::Dependency
28
27
  name: rake
29
28
  requirement: !ruby/object:Gem::Requirement
@@ -125,7 +124,6 @@ files:
125
124
  - ".rspec"
126
125
  - ".rubocop.yml"
127
126
  - Gemfile
128
- - Gemfile.lock
129
127
  - LICENSE.txt
130
128
  - README.md
131
129
  - Rakefile
@@ -135,6 +133,8 @@ files:
135
133
  - lib/cooklang/builders/step_builder.rb
136
134
  - lib/cooklang/cookware.rb
137
135
  - lib/cooklang/formatter.rb
136
+ - lib/cooklang/formatters/hash.rb
137
+ - lib/cooklang/formatters/json.rb
138
138
  - lib/cooklang/formatters/text.rb
139
139
  - lib/cooklang/ingredient.rb
140
140
  - lib/cooklang/lexer.rb
@@ -178,7 +178,6 @@ metadata:
178
178
  homepage_uri: https://github.com/jamesbrooks/cooklang
179
179
  source_code_uri: https://github.com/jamesbrooks/cooklang
180
180
  rubygems_mfa_required: 'true'
181
- post_install_message:
182
181
  rdoc_options: []
183
182
  require_paths:
184
183
  - lib
@@ -186,15 +185,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
186
185
  requirements:
187
186
  - - ">="
188
187
  - !ruby/object:Gem::Version
189
- version: 3.2.0
188
+ version: 2.7.0
190
189
  required_rubygems_version: !ruby/object:Gem::Requirement
191
190
  requirements:
192
191
  - - ">="
193
192
  - !ruby/object:Gem::Version
194
193
  version: '0'
195
194
  requirements: []
196
- rubygems_version: 3.5.22
197
- signing_key:
195
+ rubygems_version: 3.7.1
198
196
  specification_version: 4
199
197
  summary: A Ruby parser for the Cooklang recipe markup language.
200
198
  test_files:
data/Gemfile.lock DELETED
@@ -1,84 +0,0 @@
1
- PATH
2
- remote: .
3
- specs:
4
- cooklang (1.0.0)
5
-
6
- GEM
7
- remote: https://rubygems.org/
8
- specs:
9
- ast (2.4.3)
10
- diff-lcs (1.6.2)
11
- docile (1.4.1)
12
- json (2.13.2)
13
- language_server-protocol (3.17.0.5)
14
- lint_roller (1.1.0)
15
- parallel (1.27.0)
16
- parser (3.3.9.0)
17
- ast (~> 2.4.1)
18
- racc
19
- prism (1.4.0)
20
- racc (1.8.1)
21
- rainbow (3.1.1)
22
- rake (13.3.0)
23
- regexp_parser (2.11.2)
24
- rspec (3.13.1)
25
- rspec-core (~> 3.13.0)
26
- rspec-expectations (~> 3.13.0)
27
- rspec-mocks (~> 3.13.0)
28
- rspec-core (3.13.5)
29
- rspec-support (~> 3.13.0)
30
- rspec-expectations (3.13.5)
31
- diff-lcs (>= 1.2.0, < 2.0)
32
- rspec-support (~> 3.13.0)
33
- rspec-mocks (3.13.5)
34
- diff-lcs (>= 1.2.0, < 2.0)
35
- rspec-support (~> 3.13.0)
36
- rspec-support (3.13.5)
37
- rubocop (1.80.0)
38
- json (~> 2.3)
39
- language_server-protocol (~> 3.17.0.2)
40
- lint_roller (~> 1.1.0)
41
- parallel (~> 1.10)
42
- parser (>= 3.3.0.2)
43
- rainbow (>= 2.2.2, < 4.0)
44
- regexp_parser (>= 2.9.3, < 3.0)
45
- rubocop-ast (>= 1.46.0, < 2.0)
46
- ruby-progressbar (~> 1.7)
47
- unicode-display_width (>= 2.4.0, < 4.0)
48
- rubocop-ast (1.46.0)
49
- parser (>= 3.3.7.2)
50
- prism (~> 1.4)
51
- rubocop-performance (1.25.0)
52
- lint_roller (~> 1.1)
53
- rubocop (>= 1.75.0, < 2.0)
54
- rubocop-ast (>= 1.38.0, < 2.0)
55
- rubocop-rspec (3.6.0)
56
- lint_roller (~> 1.1)
57
- rubocop (~> 1.72, >= 1.72.1)
58
- ruby-progressbar (1.13.0)
59
- simplecov (0.22.0)
60
- docile (~> 1.1)
61
- simplecov-html (~> 0.11)
62
- simplecov_json_formatter (~> 0.1)
63
- simplecov-html (0.13.2)
64
- simplecov_json_formatter (0.1.4)
65
- unicode-display_width (3.1.5)
66
- unicode-emoji (~> 4.0, >= 4.0.4)
67
- unicode-emoji (4.0.4)
68
-
69
- PLATFORMS
70
- arm64-darwin-24
71
- ruby
72
-
73
- DEPENDENCIES
74
- bundler (~> 2.7)
75
- cooklang!
76
- rake (~> 13.0)
77
- rspec (~> 3.13)
78
- rubocop (~> 1.80)
79
- rubocop-performance (~> 1.25)
80
- rubocop-rspec (~> 3.6)
81
- simplecov (~> 0.22.0)
82
-
83
- BUNDLED WITH
84
- 2.7.1