cooklang 1.0.3 → 1.1.0
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/README.md +23 -7
- data/lib/cooklang/formatter.rb +0 -46
- data/lib/cooklang/formatters/hash.rb +4 -2
- data/lib/cooklang/formatters/text.rb +46 -0
- data/lib/cooklang/ingredient.rb +12 -5
- data/lib/cooklang/lexer.rb +53 -4
- data/lib/cooklang/metadata.rb +0 -64
- data/lib/cooklang/parsers/ingredient_parser.rb +10 -3
- data/lib/cooklang/processors/metadata_processor.rb +15 -35
- data/lib/cooklang/processors/step_processor.rb +1 -1
- data/lib/cooklang/version.rb +1 -1
- data/spec/fixtures/comprehensive_recipe.cook +51 -0
- data/spec/formatters/formatter_spec.rb +29 -0
- data/spec/formatters/hash_spec.rb +75 -0
- data/spec/formatters/json_spec.rb +55 -0
- data/spec/formatters/text_spec.rb +30 -168
- data/spec/integration/metadata_canonical_spec.rb +169 -0
- data/spec/lexer_spec.rb +4 -4
- data/spec/models/ingredient_spec.rb +59 -3
- data/spec/models/metadata_spec.rb +19 -149
- data/spec/models/recipe_spec.rb +2 -2
- data/spec/parser_spec.rb +67 -0
- metadata +12 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 31ab63d47990eb2eaa6eb3dd09af9ba81d760cfbcfde937467f7060282195409
|
|
4
|
+
data.tar.gz: 2fb316c77fcfaabdc679943997cfa68607d0dbf61599def411743ff53be8f0a6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7665a15f2b3ce79f5b7314068d58e1534e2dea35ee6268bbb2921a804972f8e366abe9d867e33fc4fd281d295f60ade7fac4903af263b5ab57fa88eba1fe29cf
|
|
7
|
+
data.tar.gz: 0cfaa9510dd5a81d68ba8205cdf497eb62f7983f2b03a645a0d1ebacd6eb4d4c30d13e5af09ea0f2b81e2d6a2687bd23d368b4e4d9b841d3633aff9a305cb991
|
data/README.md
CHANGED
|
@@ -60,7 +60,7 @@ recipe = Cooklang.parse_file('pancakes.cook')
|
|
|
60
60
|
|
|
61
61
|
## Formatters
|
|
62
62
|
|
|
63
|
-
The gem provides three built-in formatters for different output needs. Here's an example using
|
|
63
|
+
The gem provides three built-in formatters for different output needs. Here's an example using this fried rice recipe:
|
|
64
64
|
|
|
65
65
|
```ruby
|
|
66
66
|
>> title: Uncle Roger's Fried Rice
|
|
@@ -257,6 +257,12 @@ recipe.timers # Array of Timer objects
|
|
|
257
257
|
recipe.steps # Array of Step objects
|
|
258
258
|
recipe.steps_text # Array of plain text steps
|
|
259
259
|
|
|
260
|
+
# Metadata (Hash with string keys)
|
|
261
|
+
recipe.metadata["title"] # "Recipe Name"
|
|
262
|
+
recipe.metadata["servings"] # 4
|
|
263
|
+
recipe.metadata["tags"] # ["pasta", "italian"]
|
|
264
|
+
recipe.metadata["custom_field"] # Any value you set
|
|
265
|
+
|
|
260
266
|
# Ingredient
|
|
261
267
|
ingredient.name # "flour"
|
|
262
268
|
ingredient.quantity # 125
|
|
@@ -273,13 +279,23 @@ timer.duration # 10
|
|
|
273
279
|
timer.unit # "minutes"
|
|
274
280
|
```
|
|
275
281
|
|
|
276
|
-
## Cooklang
|
|
282
|
+
## Supported Cooklang Features
|
|
283
|
+
|
|
284
|
+
- **Ingredients** (`@`): Single/multi-word names, quantities, units, preparation notes, fixed quantities
|
|
285
|
+
- **Cookware** (`#`): Single/multi-word names, quantities
|
|
286
|
+
- **Timers** (`~`): Anonymous and named timers
|
|
287
|
+
- **Comments**: Line (`--`) and block (`[- -]`) comments
|
|
288
|
+
- **Metadata**: YAML front matter and inline metadata (`>>`)
|
|
289
|
+
- **Steps**: Paragraph-based with blank line separation
|
|
290
|
+
- **Notes** (`>`): Standalone notes
|
|
291
|
+
- **Sections** (`=`): Recipe sections with optional names
|
|
292
|
+
- **Canonical Tests** Passes all [Cooklang canonical tests](https://github.com/cooklang/spec/blob/main/tests/canonical.yaml) as of version 7
|
|
293
|
+
|
|
294
|
+
### Not Yet Implemented
|
|
277
295
|
|
|
278
|
-
- **
|
|
279
|
-
- **
|
|
280
|
-
- **
|
|
281
|
-
- **Comments**: `-- line comment`, `[- block comment -]`
|
|
282
|
-
- **Metadata**: `>> key: value` or YAML front matter
|
|
296
|
+
- **Recipe References**: `@./path/to/recipe{}`
|
|
297
|
+
- **Shopping Lists**: `aisle.txt` configuration files
|
|
298
|
+
- **Images**: Convention-based image loading
|
|
283
299
|
|
|
284
300
|
## Development
|
|
285
301
|
|
data/lib/cooklang/formatter.rb
CHANGED
|
@@ -15,51 +15,5 @@ module Cooklang
|
|
|
15
15
|
def to_s(*args)
|
|
16
16
|
generate(*args).to_s
|
|
17
17
|
end
|
|
18
|
-
|
|
19
|
-
private
|
|
20
|
-
def ingredients_section
|
|
21
|
-
return "" if recipe.ingredients.empty?
|
|
22
|
-
|
|
23
|
-
ingredient_lines = recipe.ingredients.map do |ingredient|
|
|
24
|
-
format_ingredient(ingredient)
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
"Ingredients:\n#{ingredient_lines.join("\n")}\n"
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def steps_section
|
|
31
|
-
return "" if recipe.steps.empty?
|
|
32
|
-
|
|
33
|
-
step_lines = recipe.steps.each_with_index.map do |step, index|
|
|
34
|
-
" #{index + 1}. #{step.to_text}"
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
"Steps:\n#{step_lines.join("\n")}\n"
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def format_ingredient(ingredient)
|
|
41
|
-
name = ingredient.name
|
|
42
|
-
quantity_unit = format_quantity_unit(ingredient)
|
|
43
|
-
|
|
44
|
-
# Add 4 spaces after the longest ingredient name for alignment
|
|
45
|
-
name_width = max_ingredient_name_length + 4
|
|
46
|
-
" #{name.ljust(name_width)}#{quantity_unit}"
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def format_quantity_unit(ingredient)
|
|
50
|
-
if ingredient.quantity && ingredient.unit
|
|
51
|
-
"#{ingredient.quantity} #{ingredient.unit}"
|
|
52
|
-
elsif ingredient.quantity
|
|
53
|
-
ingredient.quantity.to_s
|
|
54
|
-
elsif ingredient.unit
|
|
55
|
-
ingredient.unit
|
|
56
|
-
else
|
|
57
|
-
"some"
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def max_ingredient_name_length
|
|
62
|
-
@max_ingredient_name_length ||= recipe.ingredients.map(&:name).map(&:length).max || 0
|
|
63
|
-
end
|
|
64
18
|
end
|
|
65
19
|
end
|
|
@@ -18,7 +18,7 @@ module Cooklang
|
|
|
18
18
|
private
|
|
19
19
|
def format_metadata
|
|
20
20
|
{
|
|
21
|
-
map: recipe.metadata.to_h
|
|
21
|
+
map: recipe.metadata.to_h
|
|
22
22
|
}
|
|
23
23
|
end
|
|
24
24
|
|
|
@@ -231,7 +231,9 @@ module Cooklang
|
|
|
231
231
|
|
|
232
232
|
def format_ingredient_data
|
|
233
233
|
recipe.ingredients.map do |ingredient|
|
|
234
|
-
if ingredient.
|
|
234
|
+
if ingredient.fixed?
|
|
235
|
+
"fixed"
|
|
236
|
+
elsif ingredient.quantity && ingredient.quantity != "some"
|
|
235
237
|
"scaled"
|
|
236
238
|
else
|
|
237
239
|
"noQuantity"
|
|
@@ -11,6 +11,52 @@ module Cooklang
|
|
|
11
11
|
|
|
12
12
|
sections.join("\n").strip
|
|
13
13
|
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
def ingredients_section
|
|
17
|
+
return "" if recipe.ingredients.empty?
|
|
18
|
+
|
|
19
|
+
ingredient_lines = recipe.ingredients.map do |ingredient|
|
|
20
|
+
format_ingredient(ingredient)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
"Ingredients:\n#{ingredient_lines.join("\n")}\n"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def steps_section
|
|
27
|
+
return "" if recipe.steps.empty?
|
|
28
|
+
|
|
29
|
+
step_lines = recipe.steps.each_with_index.map do |step, index|
|
|
30
|
+
" #{index + 1}. #{step.to_text}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
"Steps:\n#{step_lines.join("\n")}\n"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def format_ingredient(ingredient)
|
|
37
|
+
name = ingredient.name
|
|
38
|
+
quantity_unit = format_quantity_unit(ingredient)
|
|
39
|
+
|
|
40
|
+
# Add 4 spaces after the longest ingredient name for alignment
|
|
41
|
+
name_width = max_ingredient_name_length + 4
|
|
42
|
+
" #{name.ljust(name_width)}#{quantity_unit}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def format_quantity_unit(ingredient)
|
|
46
|
+
if ingredient.quantity && ingredient.unit
|
|
47
|
+
"#{ingredient.quantity} #{ingredient.unit}"
|
|
48
|
+
elsif ingredient.quantity
|
|
49
|
+
ingredient.quantity.to_s
|
|
50
|
+
elsif ingredient.unit
|
|
51
|
+
ingredient.unit
|
|
52
|
+
else
|
|
53
|
+
"some"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def max_ingredient_name_length
|
|
58
|
+
@max_ingredient_name_length ||= recipe.ingredients.map(&:name).map(&:length).max || 0
|
|
59
|
+
end
|
|
14
60
|
end
|
|
15
61
|
end
|
|
16
62
|
end
|
data/lib/cooklang/ingredient.rb
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
module Cooklang
|
|
4
4
|
class Ingredient
|
|
5
|
-
attr_reader :name, :quantity, :unit, :notes
|
|
5
|
+
attr_reader :name, :quantity, :unit, :notes, :fixed
|
|
6
6
|
|
|
7
|
-
def initialize(name:, quantity: nil, unit: nil, notes: nil)
|
|
7
|
+
def initialize(name:, quantity: nil, unit: nil, notes: nil, fixed: false)
|
|
8
8
|
@name = name.to_s.freeze
|
|
9
9
|
@quantity = quantity
|
|
10
10
|
@unit = unit&.to_s&.freeze
|
|
11
11
|
@notes = notes&.to_s&.freeze
|
|
12
|
+
@fixed = fixed
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
def to_s
|
|
@@ -24,7 +25,8 @@ module Cooklang
|
|
|
24
25
|
name: @name,
|
|
25
26
|
quantity: @quantity,
|
|
26
27
|
unit: @unit,
|
|
27
|
-
notes: @notes
|
|
28
|
+
notes: @notes,
|
|
29
|
+
fixed: @fixed
|
|
28
30
|
}.compact
|
|
29
31
|
end
|
|
30
32
|
|
|
@@ -34,7 +36,8 @@ module Cooklang
|
|
|
34
36
|
name == other.name &&
|
|
35
37
|
quantity == other.quantity &&
|
|
36
38
|
unit == other.unit &&
|
|
37
|
-
notes == other.notes
|
|
39
|
+
notes == other.notes &&
|
|
40
|
+
fixed == other.fixed
|
|
38
41
|
end
|
|
39
42
|
|
|
40
43
|
def eql?(other)
|
|
@@ -42,7 +45,7 @@ module Cooklang
|
|
|
42
45
|
end
|
|
43
46
|
|
|
44
47
|
def hash
|
|
45
|
-
[name, quantity, unit, notes].hash
|
|
48
|
+
[name, quantity, unit, notes, fixed].hash
|
|
46
49
|
end
|
|
47
50
|
|
|
48
51
|
def has_quantity?
|
|
@@ -56,5 +59,9 @@ module Cooklang
|
|
|
56
59
|
def has_notes?
|
|
57
60
|
!@notes.nil?
|
|
58
61
|
end
|
|
62
|
+
|
|
63
|
+
def fixed?
|
|
64
|
+
@fixed
|
|
65
|
+
end
|
|
59
66
|
end
|
|
60
67
|
end
|
data/lib/cooklang/lexer.rb
CHANGED
|
@@ -23,7 +23,7 @@ module Cooklang
|
|
|
23
23
|
comment_block_start: "[-",
|
|
24
24
|
comment_block_end: "-]",
|
|
25
25
|
metadata_marker: ">>",
|
|
26
|
-
|
|
26
|
+
equals: "=",
|
|
27
27
|
note_marker: ">",
|
|
28
28
|
newline: "\n",
|
|
29
29
|
yaml_delimiter: "---"
|
|
@@ -40,12 +40,17 @@ module Cooklang
|
|
|
40
40
|
def tokenize
|
|
41
41
|
@tokens = []
|
|
42
42
|
|
|
43
|
+
# Check for YAML frontmatter at the beginning
|
|
44
|
+
if @scanner.check(/^---/)
|
|
45
|
+
handle_yaml_frontmatter
|
|
46
|
+
end
|
|
47
|
+
|
|
43
48
|
until @scanner.eos?
|
|
44
49
|
if match_yaml_delimiter
|
|
45
50
|
elsif match_comment_block
|
|
46
51
|
elsif match_comment_line
|
|
47
52
|
elsif match_metadata_marker
|
|
48
|
-
elsif
|
|
53
|
+
elsif match_equals
|
|
49
54
|
elsif match_note_marker
|
|
50
55
|
elsif match_special_chars
|
|
51
56
|
elsif match_newline
|
|
@@ -61,6 +66,50 @@ module Cooklang
|
|
|
61
66
|
end
|
|
62
67
|
|
|
63
68
|
private
|
|
69
|
+
def handle_yaml_frontmatter
|
|
70
|
+
# Emit the opening ---
|
|
71
|
+
position = current_position
|
|
72
|
+
line = current_line
|
|
73
|
+
column = current_column
|
|
74
|
+
value = @scanner.scan("---")
|
|
75
|
+
@tokens << Token.new(:yaml_delimiter, value, position, line, column)
|
|
76
|
+
advance_position(value)
|
|
77
|
+
|
|
78
|
+
# Scan until we find the closing --- or EOF
|
|
79
|
+
yaml_content = ""
|
|
80
|
+
until @scanner.eos?
|
|
81
|
+
if @scanner.check(/\n---\s*(\n|$)/)
|
|
82
|
+
# Found closing delimiter
|
|
83
|
+
# Add the newline before it
|
|
84
|
+
if @scanner.check(/\n/)
|
|
85
|
+
char = @scanner.getch
|
|
86
|
+
yaml_content += char
|
|
87
|
+
advance_position(char)
|
|
88
|
+
end
|
|
89
|
+
break
|
|
90
|
+
else
|
|
91
|
+
char = @scanner.getch
|
|
92
|
+
yaml_content += char
|
|
93
|
+
advance_position(char)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Emit the YAML content as a single token if we have any
|
|
98
|
+
if !yaml_content.empty?
|
|
99
|
+
@tokens << Token.new(:yaml_content, yaml_content.rstrip, current_position, current_line, current_column)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Emit the closing --- if present
|
|
103
|
+
if @scanner.check(/---/)
|
|
104
|
+
position = current_position
|
|
105
|
+
line = current_line
|
|
106
|
+
column = current_column
|
|
107
|
+
value = @scanner.scan("---")
|
|
108
|
+
@tokens << Token.new(:yaml_delimiter, value, position, line, column)
|
|
109
|
+
advance_position(value)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
64
113
|
def current_position
|
|
65
114
|
@scanner.pos
|
|
66
115
|
end
|
|
@@ -179,13 +228,13 @@ module Cooklang
|
|
|
179
228
|
end
|
|
180
229
|
end
|
|
181
230
|
|
|
182
|
-
def
|
|
231
|
+
def match_equals
|
|
183
232
|
if @scanner.check(/=+/)
|
|
184
233
|
position = current_position
|
|
185
234
|
line = current_line
|
|
186
235
|
column = current_column
|
|
187
236
|
value = @scanner.scan(/=+/)
|
|
188
|
-
@tokens << Token.new(:
|
|
237
|
+
@tokens << Token.new(:equals, value, position, line, column)
|
|
189
238
|
advance_position(value)
|
|
190
239
|
true
|
|
191
240
|
else
|
data/lib/cooklang/metadata.rb
CHANGED
|
@@ -30,69 +30,5 @@ module Cooklang
|
|
|
30
30
|
def to_h
|
|
31
31
|
super
|
|
32
32
|
end
|
|
33
|
-
|
|
34
|
-
def servings
|
|
35
|
-
self["servings"]&.to_i
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def servings=(value)
|
|
39
|
-
self["servings"] = value
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def prep_time
|
|
43
|
-
self["prep_time"] || self["prep-time"]
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def prep_time=(value)
|
|
47
|
-
self["prep_time"] = value
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def cook_time
|
|
51
|
-
self["cook_time"] || self["cook-time"]
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def cook_time=(value)
|
|
55
|
-
self["cook_time"] = value
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def total_time
|
|
59
|
-
self["total_time"] || self["total-time"]
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def total_time=(value)
|
|
63
|
-
self["total_time"] = value
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def title
|
|
67
|
-
self["title"]
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def title=(value)
|
|
71
|
-
self["title"] = value
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def source
|
|
75
|
-
self["source"]
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def source=(value)
|
|
79
|
-
self["source"] = value
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def tags
|
|
83
|
-
value = self["tags"]
|
|
84
|
-
case value
|
|
85
|
-
when Array
|
|
86
|
-
value
|
|
87
|
-
when String
|
|
88
|
-
value.split(",").map(&:strip)
|
|
89
|
-
else
|
|
90
|
-
[]
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def tags=(value)
|
|
95
|
-
self["tags"] = value
|
|
96
|
-
end
|
|
97
33
|
end
|
|
98
34
|
end
|
|
@@ -35,7 +35,7 @@ module Cooklang
|
|
|
35
35
|
name = extract_name_until_brace(brace_index)
|
|
36
36
|
@stream.consume(:open_brace) # Skip open brace
|
|
37
37
|
|
|
38
|
-
quantity, unit = extract_quantity_and_unit
|
|
38
|
+
quantity, unit, fixed = extract_quantity_and_unit
|
|
39
39
|
@stream.consume(:close_brace) # Skip close brace
|
|
40
40
|
|
|
41
41
|
notes = extract_notes
|
|
@@ -44,7 +44,7 @@ module Cooklang
|
|
|
44
44
|
quantity = "some" if (quantity.nil? || quantity == "") && (unit.nil? || unit == "")
|
|
45
45
|
unit = nil if unit == ""
|
|
46
46
|
|
|
47
|
-
Ingredient.new(name: name, quantity: quantity, unit: unit, notes: notes)
|
|
47
|
+
Ingredient.new(name: name, quantity: quantity, unit: unit, notes: notes, fixed: fixed)
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def parse_simple_ingredient
|
|
@@ -85,8 +85,14 @@ module Cooklang
|
|
|
85
85
|
def extract_quantity_and_unit
|
|
86
86
|
quantity = nil
|
|
87
87
|
unit = nil
|
|
88
|
+
fixed = false
|
|
88
89
|
text_parts = []
|
|
89
90
|
|
|
91
|
+
if @stream.current&.type == :equals
|
|
92
|
+
fixed = true
|
|
93
|
+
@stream.consume
|
|
94
|
+
end
|
|
95
|
+
|
|
90
96
|
while !@stream.eof? && @stream.current.type != :close_brace
|
|
91
97
|
case @stream.current.type
|
|
92
98
|
when :percent
|
|
@@ -106,6 +112,7 @@ module Cooklang
|
|
|
106
112
|
|
|
107
113
|
if !text_parts.empty?
|
|
108
114
|
combined_text = text_parts.join.strip
|
|
115
|
+
|
|
109
116
|
if quantity.nil?
|
|
110
117
|
# Try to parse as numeric quantity first, fallback to string if not purely numeric
|
|
111
118
|
if combined_text.match?(/^\d+$/)
|
|
@@ -124,7 +131,7 @@ module Cooklang
|
|
|
124
131
|
end
|
|
125
132
|
end
|
|
126
133
|
|
|
127
|
-
[quantity, unit]
|
|
134
|
+
[quantity, unit, fixed]
|
|
128
135
|
end
|
|
129
136
|
|
|
130
137
|
def parse_quantity_value(text)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
3
5
|
module Cooklang
|
|
4
6
|
module Processors
|
|
5
7
|
class MetadataProcessor
|
|
@@ -7,7 +9,6 @@ module Cooklang
|
|
|
7
9
|
def extract_metadata(tokens)
|
|
8
10
|
metadata = Metadata.new
|
|
9
11
|
content_tokens = []
|
|
10
|
-
0
|
|
11
12
|
|
|
12
13
|
# Check for YAML front matter
|
|
13
14
|
if tokens.first&.type == :yaml_delimiter
|
|
@@ -26,32 +27,17 @@ module Cooklang
|
|
|
26
27
|
def extract_yaml_frontmatter(tokens)
|
|
27
28
|
metadata = Metadata.new
|
|
28
29
|
i = 1 # Skip the first ---
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
# Found closing delimiter
|
|
35
|
-
break
|
|
36
|
-
elsif tokens[i].type == :text
|
|
37
|
-
yaml_content << tokens[i].value
|
|
38
|
-
elsif tokens[i].type == :newline
|
|
39
|
-
yaml_content << "\n"
|
|
40
|
-
elsif tokens[i].type == :metadata_marker
|
|
41
|
-
yaml_content << ">>"
|
|
42
|
-
end
|
|
30
|
+
|
|
31
|
+
# Look for yaml_content token
|
|
32
|
+
if i < tokens.length && tokens[i].type == :yaml_content
|
|
33
|
+
yaml_text = tokens[i].value
|
|
34
|
+
parse_yaml_content(yaml_text, metadata) unless yaml_text.empty?
|
|
43
35
|
i += 1
|
|
44
36
|
end
|
|
45
37
|
|
|
46
38
|
# Skip the closing --- if we found it
|
|
47
39
|
i += 1 if i < tokens.length && tokens[i].type == :yaml_delimiter
|
|
48
40
|
|
|
49
|
-
# Parse YAML content if we have any
|
|
50
|
-
if yaml_content.any?
|
|
51
|
-
yaml_text = yaml_content.join.strip
|
|
52
|
-
parse_yaml_content(yaml_text, metadata) unless yaml_text.empty?
|
|
53
|
-
end
|
|
54
|
-
|
|
55
41
|
# Return remaining tokens
|
|
56
42
|
remaining_tokens = tokens[i..]
|
|
57
43
|
|
|
@@ -59,21 +45,15 @@ module Cooklang
|
|
|
59
45
|
end
|
|
60
46
|
|
|
61
47
|
def parse_yaml_content(yaml_text, metadata)
|
|
62
|
-
#
|
|
63
|
-
yaml_text.
|
|
64
|
-
line = line.strip
|
|
65
|
-
next if line.empty? || line.start_with?("#")
|
|
66
|
-
|
|
67
|
-
if line.match(/^([^:]+):\s*(.*)$/)
|
|
68
|
-
key = ::Regexp.last_match(1).strip
|
|
69
|
-
value = ::Regexp.last_match(2).strip
|
|
48
|
+
# Fix malformed YAML with spaces before colon (non-standard YAML but permits the canonical test testMetadataMultiwordKeyWithSpaces to pass)
|
|
49
|
+
fixed_yaml = yaml_text.gsub(/^(\s*)([^:\n]+?)\s+:(\S)/, '\1\2: \3')
|
|
70
50
|
|
|
71
|
-
|
|
72
|
-
value = value.gsub(/^["']|["']$/, "") if value.match?(/^["'].*["']$/)
|
|
51
|
+
parsed = YAML.safe_load(fixed_yaml, permitted_classes: [Date, Time, DateTime])
|
|
73
52
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
53
|
+
# Merge parsed YAML into metadata if it's a Hash
|
|
54
|
+
if parsed.is_a?(Hash)
|
|
55
|
+
parsed.each do |key, value|
|
|
56
|
+
metadata[key.to_s] = value
|
|
77
57
|
end
|
|
78
58
|
end
|
|
79
59
|
end
|
|
@@ -88,7 +68,7 @@ module Cooklang
|
|
|
88
68
|
if index + 1 < tokens.length && tokens[index + 1].type == :text
|
|
89
69
|
text = tokens[index + 1].value.strip
|
|
90
70
|
|
|
91
|
-
if text.match(/^([^:]+):\s*(
|
|
71
|
+
if text.match(/^([^:]+):\s*(.+?)[\n\\n]*$/)
|
|
92
72
|
key = ::Regexp.last_match(1).strip
|
|
93
73
|
value = ::Regexp.last_match(2).strip
|
|
94
74
|
|
|
@@ -96,7 +96,7 @@ module Cooklang
|
|
|
96
96
|
when :open_brace, :close_brace, :open_paren, :close_paren, :percent
|
|
97
97
|
builder.add_text(token.value)
|
|
98
98
|
stream.consume
|
|
99
|
-
when :
|
|
99
|
+
when :equals # section marker (= or ==)
|
|
100
100
|
process_section_with_stream(stream, builder)
|
|
101
101
|
else
|
|
102
102
|
stream.consume
|
data/lib/cooklang/version.rb
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Uncle Roger's Ultimate Fried Rice & Dumplings
|
|
3
|
+
servings: 4
|
|
4
|
+
prep_time: 15 minutes
|
|
5
|
+
cook_time: 12 minutes
|
|
6
|
+
difficulty: intermediate
|
|
7
|
+
cuisine: Chinese
|
|
8
|
+
dietary_restrictions:
|
|
9
|
+
- can be made vegetarian
|
|
10
|
+
- gluten-free option available
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
[- This is a comprehensive recipe that tests all Cooklang features including
|
|
14
|
+
complex ingredients, cookware, timers, metadata, and various edge cases -]
|
|
15
|
+
|
|
16
|
+
= Prep Work
|
|
17
|
+
|
|
18
|
+
First, prepare all ingredients. Dice @shallots{2}(finely chopped) and mince @garlic{4%cloves}(crushed).
|
|
19
|
+
|
|
20
|
+
Beat @eggs{3}(room temperature) in a #mixing bowl{large}.
|
|
21
|
+
|
|
22
|
+
= Main Cooking
|
|
23
|
+
|
|
24
|
+
Heat @peanut oil{2%tbsp} and @sesame oil{1%tsp} in #wok{} over high heat for ~{2%minutes} until smoking.
|
|
25
|
+
|
|
26
|
+
Add the diced shallots and garlic, stir-fry for ~{30%seconds}. -- This is critical timing!
|
|
27
|
+
|
|
28
|
+
Pour in the beaten eggs and scramble for ~quick scramble{45%seconds} until just set.
|
|
29
|
+
|
|
30
|
+
Add @day-old rice{3%cups}(broken up by hand) and stir-fry, breaking up any clumps for ~{2.5%minutes}.
|
|
31
|
+
|
|
32
|
+
Season with @soy sauce{2%tbsp}, @fish sauce{1%tsp}, @msg{0.5%tsp}, and @white pepper{some}(to taste).
|
|
33
|
+
|
|
34
|
+
Add @frozen peas{0.5%cup} and @spring onions{3%stalks}(chopped), cook for ~final timer{1%minute}.
|
|
35
|
+
|
|
36
|
+
= Dumplings (Optional Side)
|
|
37
|
+
|
|
38
|
+
For dumplings, prepare @dumpling wrappers{20} and @ground pork{300%g}(seasoned).
|
|
39
|
+
|
|
40
|
+
Mix pork with @ginger{1%tbsp}(minced), @soy sauce{1%tbsp}, and @cornstarch{1%tsp}.
|
|
41
|
+
|
|
42
|
+
Steam in #bamboo steamer{} for ~{15%minutes}.
|
|
43
|
+
|
|
44
|
+
= Final Assembly
|
|
45
|
+
|
|
46
|
+
Serve immediately while hot! Garnish with @crispy shallots{some} and @cilantro{handful}(chopped).
|
|
47
|
+
|
|
48
|
+
[- Note: The "some" quantities and decimal timers test edge cases in parsing -]
|
|
49
|
+
|
|
50
|
+
-- Cooking tips: Use day-old rice for best texture
|
|
51
|
+
-- Serving suggestion: Pair with jasmine tea
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Cooklang::Formatter do
|
|
6
|
+
describe "#generate" do
|
|
7
|
+
let(:recipe) do
|
|
8
|
+
recipe_text = File.read(File.join(__dir__, "..", "fixtures", "comprehensive_recipe.cook"))
|
|
9
|
+
Cooklang.parse(recipe_text)
|
|
10
|
+
end
|
|
11
|
+
let(:formatter) { described_class.new(recipe) }
|
|
12
|
+
|
|
13
|
+
it "raises NotImplementedError for base class" do
|
|
14
|
+
expect { formatter.generate }.to raise_error(NotImplementedError, "Subclasses must implement #generate")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
describe "#to_s" do
|
|
19
|
+
let(:recipe) do
|
|
20
|
+
recipe_text = File.read(File.join(__dir__, "..", "fixtures", "comprehensive_recipe.cook"))
|
|
21
|
+
Cooklang.parse(recipe_text)
|
|
22
|
+
end
|
|
23
|
+
let(:formatter) { described_class.new(recipe) }
|
|
24
|
+
|
|
25
|
+
it "calls generate.to_s but fails because generate raises" do
|
|
26
|
+
expect { formatter.to_s }.to raise_error(NotImplementedError, "Subclasses must implement #generate")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|