cooklang 1.0.1 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c61d7de5e0471ab52eb21411d501fb1b909a5e1c356b2eeb105a37c971847321
4
- data.tar.gz: 24fb626c2590541600acf57b317255adbb6fa75abc34ace698c70c8fcf2a5c90
3
+ metadata.gz: a13e5e798824a4c9fa2d7405a7466f03b59ef20a0a80ed16f1de789cf40df0db
4
+ data.tar.gz: ade82856860ca28430e38f325e64b15a8f2a0694ebaba9a56cfd058af1f658e5
5
5
  SHA512:
6
- metadata.gz: 723008d0a4ea83eaec6c376f715f47a74edf29230a2fb8fcc2ec13d47250b65adc6027511343de7a6bd3e4d2b833e592add88373e298bf21e98a1fa217358166
7
- data.tar.gz: ff3a2b9eb011b525309a7ac19bda34c51eb9dd68f154958af8b164511b1955a61e786dfd3cb45dd51391682700b9b962e2b3a8f54bade6fc1a5d56ea4053b2b3
6
+ metadata.gz: 8ff63ccbc36d06328546b6508429a93d94d5d97a98fdec2d506854d39661d4015f18630269f2e61a838dcb9083b9850e70b0d2db1503f7a7631d6a246a2bd42f
7
+ data.tar.gz: 010e6b452e3d7102b7c347f6875d132f137b5b55cba594d0b29a3d28ad1cf19dfebbe6b700441f65b763db9c021675e8b7405f90615925fc1ca1baf9191f5e2e
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- cooklang (1.0.0)
4
+ cooklang (1.0.1)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
data/README.md CHANGED
@@ -52,19 +52,194 @@ end
52
52
 
53
53
  # Parse from file
54
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
+ ```
55
168
 
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.
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
+ }
68
243
  ```
69
244
 
70
245
  ## API
@@ -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?
@@ -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.2"
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,14 +1,13 @@
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.2
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
@@ -135,6 +134,8 @@ files:
135
134
  - lib/cooklang/builders/step_builder.rb
136
135
  - lib/cooklang/cookware.rb
137
136
  - lib/cooklang/formatter.rb
137
+ - lib/cooklang/formatters/hash.rb
138
+ - lib/cooklang/formatters/json.rb
138
139
  - lib/cooklang/formatters/text.rb
139
140
  - lib/cooklang/ingredient.rb
140
141
  - lib/cooklang/lexer.rb
@@ -178,7 +179,6 @@ metadata:
178
179
  homepage_uri: https://github.com/jamesbrooks/cooklang
179
180
  source_code_uri: https://github.com/jamesbrooks/cooklang
180
181
  rubygems_mfa_required: 'true'
181
- post_install_message:
182
182
  rdoc_options: []
183
183
  require_paths:
184
184
  - lib
@@ -193,8 +193,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
193
193
  - !ruby/object:Gem::Version
194
194
  version: '0'
195
195
  requirements: []
196
- rubygems_version: 3.5.22
197
- signing_key:
196
+ rubygems_version: 3.7.1
198
197
  specification_version: 4
199
198
  summary: A Ruby parser for the Cooklang recipe markup language.
200
199
  test_files: