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 +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +187 -12
- data/lib/cooklang/builders/recipe_builder.rb +0 -4
- data/lib/cooklang/builders/step_builder.rb +0 -2
- data/lib/cooklang/formatter.rb +6 -2
- data/lib/cooklang/formatters/hash.rb +257 -0
- data/lib/cooklang/formatters/json.rb +13 -0
- data/lib/cooklang/formatters/text.rb +1 -3
- data/lib/cooklang/parser.rb +0 -6
- data/lib/cooklang/parsers/cookware_parser.rb +0 -3
- data/lib/cooklang/parsers/ingredient_parser.rb +0 -3
- data/lib/cooklang/parsers/timer_parser.rb +0 -3
- data/lib/cooklang/processors/element_parser.rb +0 -5
- data/lib/cooklang/processors/metadata_processor.rb +0 -2
- data/lib/cooklang/processors/step_processor.rb +0 -4
- data/lib/cooklang/processors/token_processor.rb +0 -3
- data/lib/cooklang/recipe.rb +6 -9
- data/lib/cooklang/version.rb +1 -1
- data/lib/cooklang.rb +31 -3
- data/spec/models/recipe_spec.rb +209 -6
- metadata +5 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a13e5e798824a4c9fa2d7405a7466f03b59ef20a0a80ed16f1de789cf40df0db
|
4
|
+
data.tar.gz: ade82856860ca28430e38f325e64b15a8f2a0694ebaba9a56cfd058af1f658e5
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8ff63ccbc36d06328546b6508429a93d94d5d97a98fdec2d506854d39661d4015f18630269f2e61a838dcb9083b9850e70b0d2db1503f7a7631d6a246a2bd42f
|
7
|
+
data.tar.gz: 010e6b452e3d7102b7c347f6875d132f137b5b55cba594d0b29a3d28ad1cf19dfebbe6b700441f65b763db9c021675e8b7405f90615925fc1ca1baf9191f5e2e
|
data/Gemfile.lock
CHANGED
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
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
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
|
data/lib/cooklang/formatter.rb
CHANGED
@@ -8,8 +8,12 @@ module Cooklang
|
|
8
8
|
@recipe = recipe
|
9
9
|
end
|
10
10
|
|
11
|
-
def
|
12
|
-
raise NotImplementedError, "Subclasses must implement #
|
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
|
data/lib/cooklang/parser.rb
CHANGED
@@ -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,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
|
data/lib/cooklang/recipe.rb
CHANGED
@@ -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
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
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)
|
data/lib/cooklang/version.rb
CHANGED
data/lib/cooklang.rb
CHANGED
@@ -1,9 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative "cooklang/version"
|
4
|
-
|
5
|
-
|
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
|
data/spec/models/recipe_spec.rb
CHANGED
@@ -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
|
111
|
-
expect(hash
|
112
|
-
expect(hash
|
113
|
-
expect(hash
|
114
|
-
expect(hash
|
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.
|
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:
|
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.
|
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:
|