cooklang 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/test.yml +35 -0
- data/.gitignore +12 -0
- data/.qlty/.gitignore +7 -0
- data/.qlty/configs/.yamllint.yaml +21 -0
- data/.qlty/qlty.toml +101 -0
- data/.rspec +3 -0
- data/.rubocop.yml +340 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +84 -0
- data/README.md +197 -17
- data/Rakefile +12 -0
- data/cooklang.gemspec +35 -0
- data/lib/cooklang/builders/recipe_builder.rb +60 -0
- data/lib/cooklang/builders/step_builder.rb +74 -0
- 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/lexer.rb +5 -14
- data/lib/cooklang/parser.rb +18 -653
- data/lib/cooklang/parsers/cookware_parser.rb +130 -0
- data/lib/cooklang/parsers/ingredient_parser.rb +176 -0
- data/lib/cooklang/parsers/timer_parser.rb +132 -0
- data/lib/cooklang/processors/element_parser.rb +40 -0
- data/lib/cooklang/processors/metadata_processor.rb +127 -0
- data/lib/cooklang/processors/step_processor.rb +204 -0
- data/lib/cooklang/processors/token_processor.rb +101 -0
- data/lib/cooklang/recipe.rb +31 -24
- data/lib/cooklang/step.rb +12 -2
- data/lib/cooklang/timer.rb +3 -1
- data/lib/cooklang/token_stream.rb +130 -0
- data/lib/cooklang/version.rb +1 -1
- data/lib/cooklang.rb +31 -3
- data/spec/comprehensive_spec.rb +179 -0
- data/spec/cooklang_spec.rb +38 -0
- data/spec/fixtures/canonical.yaml +837 -0
- data/spec/formatters/text_spec.rb +189 -0
- data/spec/integration/canonical_spec.rb +211 -0
- data/spec/lexer_spec.rb +357 -0
- data/spec/models/cookware_spec.rb +116 -0
- data/spec/models/ingredient_spec.rb +192 -0
- data/spec/models/metadata_spec.rb +241 -0
- data/spec/models/note_spec.rb +65 -0
- data/spec/models/recipe_spec.rb +374 -0
- data/spec/models/section_spec.rb +65 -0
- data/spec/models/step_spec.rb +236 -0
- data/spec/models/timer_spec.rb +173 -0
- data/spec/parser_spec.rb +398 -0
- data/spec/spec_helper.rb +23 -0
- data/spec/token_stream_spec.rb +278 -0
- metadata +159 -4
data/README.md
CHANGED
@@ -1,5 +1,10 @@
|
|
1
1
|
# Cooklang
|
2
2
|
|
3
|
+

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