cooklang 1.0.3 → 1.0.4
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 +22 -7
- data/lib/cooklang/formatter.rb +0 -46
- data/lib/cooklang/formatters/hash.rb +1 -1
- data/lib/cooklang/formatters/text.rb +46 -0
- data/lib/cooklang/lexer.rb +49 -0
- data/lib/cooklang/metadata.rb +0 -64
- data/lib/cooklang/processors/metadata_processor.rb +15 -35
- 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 +63 -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/models/metadata_spec.rb +19 -149
- data/spec/models/recipe_spec.rb +2 -2
- metadata +11 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: fa4eed0618449146de288cbfa4b51b05d7ac994fca5e3aca0161c6cfe90d2551
|
4
|
+
data.tar.gz: 45c683a6a2daea2da5d48116cf91181532370df0b1b2e5de0d1b72a749f2e245
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b4cfc4bad69e170f6fff3b5aef1d35b31ba72232b1c9579129f1b3ec1e5f1174af9501a3455be440da077a7138c3af1b822a6f11f232cc6c420621cea8d06552
|
7
|
+
data.tar.gz: 6d84e7c23687b56076c2b9aef18e1f801dbf471ce2fd3e5f216f62016c33656152c7634d3d6d0fa95c9b15181d6f1b05196c225301da0c5ec184a1e136375e73
|
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,22 @@ 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
|
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
|
+
|
293
|
+
### Not Yet Implemented
|
277
294
|
|
278
|
-
- **
|
279
|
-
- **
|
280
|
-
- **
|
281
|
-
- **Comments**: `-- line comment`, `[- block comment -]`
|
282
|
-
- **Metadata**: `>> key: value` or YAML front matter
|
295
|
+
- **Recipe References**: `@./path/to/recipe{}`
|
296
|
+
- **Shopping Lists**: `aisle.txt` configuration files
|
297
|
+
- **Images**: Convention-based image loading
|
283
298
|
|
284
299
|
## Development
|
285
300
|
|
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
|
@@ -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/lexer.rb
CHANGED
@@ -40,6 +40,11 @@ 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
|
@@ -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
|
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
|
@@ -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
|
|
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
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "spec_helper"
|
4
|
+
|
5
|
+
RSpec.describe Cooklang::Formatters::Hash 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(:result) { described_class.new(recipe).generate }
|
12
|
+
|
13
|
+
it "generates hash with all required top-level keys" do
|
14
|
+
expect(result).to have_key(:metadata)
|
15
|
+
expect(result).to have_key(:sections)
|
16
|
+
expect(result).to have_key(:ingredients)
|
17
|
+
expect(result).to have_key(:cookware)
|
18
|
+
expect(result).to have_key(:timers)
|
19
|
+
expect(result).to have_key(:data)
|
20
|
+
end
|
21
|
+
|
22
|
+
it "includes expected metadata" do
|
23
|
+
metadata = result[:metadata][:map]
|
24
|
+
expect(metadata["title"]).to eq("Uncle Roger's Ultimate Fried Rice & Dumplings")
|
25
|
+
expect(metadata["servings"]).to eq(4)
|
26
|
+
expect(metadata["difficulty"]).to eq("intermediate")
|
27
|
+
end
|
28
|
+
|
29
|
+
it "parses ingredients with decimal quantities and nil quantities" do
|
30
|
+
ingredients = result[:ingredients]
|
31
|
+
|
32
|
+
# Find MSG with decimal quantity
|
33
|
+
msg = ingredients.find { |i| i[:name] == "msg" }
|
34
|
+
expect(msg[:quantity][:value][:value][:value]).to eq(0.5)
|
35
|
+
|
36
|
+
# Find ingredient with nil quantity (like "some")
|
37
|
+
white_pepper = ingredients.find { |i| i[:name] == "white pepper" }
|
38
|
+
expect(white_pepper[:quantity]).to be_nil
|
39
|
+
end
|
40
|
+
|
41
|
+
it "includes timers with named and decimal durations" do
|
42
|
+
timers = result[:timers]
|
43
|
+
expect(timers.size).to be >= 5
|
44
|
+
|
45
|
+
# Find named timer
|
46
|
+
named_timer = timers.find { |t| t[:name] == "quick scramble" }
|
47
|
+
expect(named_timer).not_to be_nil
|
48
|
+
|
49
|
+
# Find decimal duration timer
|
50
|
+
decimal_timer = timers.find { |t| t[:quantity][:value][:value][:value] == 2.5 }
|
51
|
+
expect(decimal_timer).not_to be_nil
|
52
|
+
end
|
53
|
+
|
54
|
+
it "generates steps with proper indexing" do
|
55
|
+
steps = result[:sections].first[:content]
|
56
|
+
expect(steps.size).to be >= 10
|
57
|
+
|
58
|
+
# Check step numbers are sequential
|
59
|
+
step_numbers = steps.map { |s| s[:value][:number] }
|
60
|
+
expect(step_numbers).to eq((1..step_numbers.size).to_a)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "spec_helper"
|
4
|
+
|
5
|
+
RSpec.describe Cooklang::Formatters::Json 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(:json_output) { described_class.new(recipe).generate }
|
12
|
+
let(:parsed) { JSON.parse(json_output) }
|
13
|
+
|
14
|
+
it "generates valid JSON" do
|
15
|
+
expect { JSON.parse(json_output) }.not_to raise_error
|
16
|
+
end
|
17
|
+
|
18
|
+
it "includes metadata with proper JSON types" do
|
19
|
+
expect(parsed["metadata"]["map"]["title"]).to eq("Uncle Roger's Ultimate Fried Rice & Dumplings")
|
20
|
+
expect(parsed["metadata"]["map"]["servings"]).to eq(4)
|
21
|
+
end
|
22
|
+
|
23
|
+
it "converts decimal quantities correctly" do
|
24
|
+
ingredients = parsed["ingredients"]
|
25
|
+
|
26
|
+
# MSG with decimal quantity
|
27
|
+
msg = ingredients.find { |i| i["name"] == "msg" }
|
28
|
+
expect(msg["quantity"]["value"]["value"]["value"]).to eq(0.5)
|
29
|
+
|
30
|
+
# White pepper with null quantity
|
31
|
+
white_pepper = ingredients.find { |i| i["name"] == "white pepper" }
|
32
|
+
expect(white_pepper["quantity"]).to be_nil
|
33
|
+
end
|
34
|
+
|
35
|
+
it "handles named timers and decimal durations" do
|
36
|
+
timers = parsed["timers"]
|
37
|
+
|
38
|
+
# Named timer
|
39
|
+
named_timer = timers.find { |t| t["name"] == "quick scramble" }
|
40
|
+
expect(named_timer["quantity"]["value"]["value"]["value"]).to eq(45.0)
|
41
|
+
|
42
|
+
# Decimal duration
|
43
|
+
decimal_timer = timers.find { |t| t["quantity"]["value"]["value"]["value"] == 2.5 }
|
44
|
+
expect(decimal_timer).not_to be_nil
|
45
|
+
end
|
46
|
+
|
47
|
+
it "formats with indentation when requested" do
|
48
|
+
formatted = described_class.new(recipe).generate(indent: " ")
|
49
|
+
expect(formatted).to include(" \"metadata\"")
|
50
|
+
# Just check that it's longer than compact format (indentation adds space)
|
51
|
+
compact = described_class.new(recipe).generate
|
52
|
+
expect(formatted.length).to be > compact.length
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -3,187 +3,49 @@
|
|
3
3
|
require "spec_helper"
|
4
4
|
|
5
5
|
RSpec.describe Cooklang::Formatters::Text do
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
cookware: [],
|
11
|
-
timers: [],
|
12
|
-
metadata: {},
|
13
|
-
sections: [],
|
14
|
-
notes: []
|
15
|
-
)
|
16
|
-
end
|
17
|
-
let(:formatter) { described_class.new(recipe) }
|
18
|
-
|
19
|
-
describe "#to_s" do
|
20
|
-
context "with ingredients only" do
|
21
|
-
let(:ingredients) do
|
22
|
-
[
|
23
|
-
Cooklang::Ingredient.new(name: "flour", quantity: 125, unit: "g"),
|
24
|
-
Cooklang::Ingredient.new(name: "milk", quantity: 250, unit: "ml"),
|
25
|
-
Cooklang::Ingredient.new(name: "eggs", quantity: 3),
|
26
|
-
Cooklang::Ingredient.new(name: "butter"),
|
27
|
-
Cooklang::Ingredient.new(name: "sea salt", quantity: 1, unit: "pinch")
|
28
|
-
]
|
29
|
-
end
|
30
|
-
let(:steps) { [] }
|
31
|
-
|
32
|
-
it "formats ingredients with aligned columns" do
|
33
|
-
expected = <<~OUTPUT.strip
|
34
|
-
Ingredients:
|
35
|
-
flour 125 g
|
36
|
-
milk 250 ml
|
37
|
-
eggs 3
|
38
|
-
butter some
|
39
|
-
sea salt 1 pinch
|
40
|
-
OUTPUT
|
41
|
-
|
42
|
-
expect(formatter.to_s).to eq(expected)
|
43
|
-
end
|
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)
|
44
10
|
end
|
11
|
+
let(:output) { described_class.new(recipe).generate }
|
45
12
|
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
[
|
50
|
-
Cooklang::Step.new(segments: [
|
51
|
-
{ type: "text", value: "Crack the " },
|
52
|
-
{ type: "ingredient", value: "eggs", name: "eggs" },
|
53
|
-
{ type: "text", value: " into a blender" }
|
54
|
-
]),
|
55
|
-
Cooklang::Step.new(segments: [
|
56
|
-
{ type: "text", value: "Pour into a bowl and leave to stand for " },
|
57
|
-
{ type: "timer", value: "15 minutes", name: nil },
|
58
|
-
{ type: "text", value: "." }
|
59
|
-
])
|
60
|
-
]
|
61
|
-
end
|
62
|
-
|
63
|
-
it "formats steps with numbered list" do
|
64
|
-
expected = <<~OUTPUT.strip
|
65
|
-
Steps:
|
66
|
-
1. Crack the eggs into a blender
|
67
|
-
2. Pour into a bowl and leave to stand for 15 minutes.
|
68
|
-
OUTPUT
|
69
|
-
|
70
|
-
expect(formatter.to_s).to eq(expected)
|
71
|
-
end
|
13
|
+
it "includes Ingredients and Steps sections" do
|
14
|
+
expect(output).to include("Ingredients:")
|
15
|
+
expect(output).to include("Steps:")
|
72
16
|
end
|
73
17
|
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
Cooklang::Ingredient.new(name: "flour", quantity: 125, unit: "g"),
|
80
|
-
Cooklang::Ingredient.new(name: "milk", quantity: 250, unit: "ml"),
|
81
|
-
Cooklang::Ingredient.new(name: "sea salt", quantity: 1, unit: "pinch")
|
82
|
-
]
|
83
|
-
end
|
84
|
-
let(:steps) do
|
85
|
-
[
|
86
|
-
Cooklang::Step.new(segments: [
|
87
|
-
{ type: "text", value: "Crack the " },
|
88
|
-
{ type: "ingredient", value: "eggs", name: "eggs" },
|
89
|
-
{ type: "text", value: " into a blender, then add the " },
|
90
|
-
{ type: "ingredient", value: "flour", name: "flour" },
|
91
|
-
{ type: "text", value: ", " },
|
92
|
-
{ type: "ingredient", value: "milk", name: "milk" },
|
93
|
-
{ type: "text", value: " and " },
|
94
|
-
{ type: "ingredient", value: "sea salt", name: "sea salt" },
|
95
|
-
{ type: "text", value: "." }
|
96
|
-
]),
|
97
|
-
Cooklang::Step.new(segments: [
|
98
|
-
{ type: "text", value: "Pour into a bowl and leave to stand for " },
|
99
|
-
{ type: "timer", value: "15 minutes", name: nil },
|
100
|
-
{ type: "text", value: "." }
|
101
|
-
]),
|
102
|
-
Cooklang::Step.new(segments: [
|
103
|
-
{ type: "text", value: "Melt the " },
|
104
|
-
{ type: "ingredient", value: "butter", name: "butter" },
|
105
|
-
{ type: "text", value: " in a large non-stick " },
|
106
|
-
{ type: "cookware", value: "frying pan", name: "frying pan" },
|
107
|
-
{ type: "text", value: "." }
|
108
|
-
])
|
109
|
-
]
|
110
|
-
end
|
111
|
-
|
112
|
-
it "formats complete recipe" do
|
113
|
-
expected = <<~OUTPUT.strip
|
114
|
-
Ingredients:
|
115
|
-
butter some
|
116
|
-
eggs 3
|
117
|
-
flour 125 g
|
118
|
-
milk 250 ml
|
119
|
-
sea salt 1 pinch
|
120
|
-
|
121
|
-
Steps:
|
122
|
-
1. Crack the eggs into a blender, then add the flour, milk and sea salt.
|
123
|
-
2. Pour into a bowl and leave to stand for 15 minutes.
|
124
|
-
3. Melt the butter in a large non-stick frying pan.
|
125
|
-
OUTPUT
|
126
|
-
|
127
|
-
expect(formatter.to_s).to eq(expected)
|
128
|
-
end
|
18
|
+
it "formats ingredients with quantities and units" do
|
19
|
+
expect(output).to match(/shallots\s+2/)
|
20
|
+
expect(output).to match(/garlic\s+4 cloves/)
|
21
|
+
expect(output).to match(/msg\s+0\.5 tsp/)
|
22
|
+
expect(output).to match(/white pepper\s+some/)
|
129
23
|
end
|
130
24
|
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
it "returns empty string" do
|
136
|
-
expect(formatter.to_s).to eq("")
|
137
|
-
end
|
138
|
-
end
|
139
|
-
|
140
|
-
context "with various ingredient formats" do
|
141
|
-
let(:ingredients) do
|
142
|
-
[
|
143
|
-
Cooklang::Ingredient.new(name: "onion", quantity: 1),
|
144
|
-
Cooklang::Ingredient.new(name: "olive oil", unit: "drizzle"),
|
145
|
-
Cooklang::Ingredient.new(name: "salt"),
|
146
|
-
Cooklang::Ingredient.new(name: "pepper", quantity: "some", unit: "grinds")
|
147
|
-
]
|
148
|
-
end
|
149
|
-
let(:steps) { [] }
|
150
|
-
|
151
|
-
it "handles missing quantities and units gracefully" do
|
152
|
-
expected = <<~OUTPUT.strip
|
153
|
-
Ingredients:
|
154
|
-
onion 1
|
155
|
-
olive oil drizzle
|
156
|
-
salt some
|
157
|
-
pepper some grinds
|
158
|
-
OUTPUT
|
159
|
-
|
160
|
-
expect(formatter.to_s).to eq(expected)
|
161
|
-
end
|
162
|
-
end
|
163
|
-
end
|
164
|
-
|
165
|
-
describe "#format_quantity_unit" do
|
166
|
-
let(:formatter) { described_class.new(recipe) }
|
167
|
-
let(:recipe) { double("recipe") }
|
25
|
+
it "formats steps with sequential numbering" do
|
26
|
+
lines = output.split("\n")
|
27
|
+
step_lines = lines.select { |line| line.match(/^\s*\d+\./) }
|
168
28
|
|
169
|
-
|
170
|
-
|
171
|
-
expect(
|
29
|
+
expect(step_lines.size).to be >= 10
|
30
|
+
expect(step_lines.first).to match(/1\..*/)
|
31
|
+
expect(step_lines.last).to match(/\d+\..*/)
|
172
32
|
end
|
173
33
|
|
174
|
-
it "
|
175
|
-
|
176
|
-
expect(
|
34
|
+
it "includes ingredient names in step text" do
|
35
|
+
expect(output).to include("shallots")
|
36
|
+
expect(output).to include("garlic")
|
37
|
+
expect(output).to include("day-old rice")
|
177
38
|
end
|
178
39
|
|
179
|
-
it "
|
180
|
-
|
181
|
-
expect(
|
40
|
+
it "includes timer references in steps" do
|
41
|
+
expect(output).to include("2 minutes")
|
42
|
+
expect(output).to include("30 seconds")
|
43
|
+
expect(output).to include("quick scramble")
|
182
44
|
end
|
183
45
|
|
184
|
-
it "
|
185
|
-
|
186
|
-
expect(
|
46
|
+
it "includes cookware references" do
|
47
|
+
expect(output).to include("wok")
|
48
|
+
expect(output).to include("mixing bowl")
|
187
49
|
end
|
188
50
|
end
|
189
51
|
end
|
@@ -0,0 +1,169 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "spec_helper"
|
4
|
+
|
5
|
+
RSpec.describe "Canonical Metadata Support" do
|
6
|
+
let(:parser) { Cooklang::Parser.new }
|
7
|
+
|
8
|
+
describe "Complex YAML frontmatter" do
|
9
|
+
it "supports arrays in metadata" do
|
10
|
+
recipe_text = <<~COOK
|
11
|
+
---
|
12
|
+
title: Pasta Dish
|
13
|
+
tags: [italian, pasta, quick]
|
14
|
+
diet:
|
15
|
+
- vegetarian
|
16
|
+
- gluten-free
|
17
|
+
---
|
18
|
+
@pasta{200%g}
|
19
|
+
COOK
|
20
|
+
|
21
|
+
recipe = parser.parse(recipe_text)
|
22
|
+
|
23
|
+
expect(recipe.metadata["tags"]).to eq(["italian", "pasta", "quick"])
|
24
|
+
expect(recipe.metadata["diet"]).to eq(["vegetarian", "gluten-free"])
|
25
|
+
end
|
26
|
+
|
27
|
+
it "supports nested hash structures" do
|
28
|
+
recipe_text = <<~COOK
|
29
|
+
---
|
30
|
+
title: Recipe
|
31
|
+
source:
|
32
|
+
name: My Cookbook
|
33
|
+
url: https://example.com/recipe
|
34
|
+
author: Jane Doe
|
35
|
+
time:
|
36
|
+
prep: 15 minutes
|
37
|
+
cook: 30 minutes
|
38
|
+
---
|
39
|
+
@flour{100%g}
|
40
|
+
COOK
|
41
|
+
|
42
|
+
recipe = parser.parse(recipe_text)
|
43
|
+
|
44
|
+
expect(recipe.metadata["source"]).to be_a(Hash)
|
45
|
+
expect(recipe.metadata["source"]["name"]).to eq("My Cookbook")
|
46
|
+
expect(recipe.metadata["source"]["url"]).to eq("https://example.com/recipe")
|
47
|
+
expect(recipe.metadata["source"]["author"]).to eq("Jane Doe")
|
48
|
+
|
49
|
+
expect(recipe.metadata["time"]).to be_a(Hash)
|
50
|
+
expect(recipe.metadata["time"]["prep"]).to eq("15 minutes")
|
51
|
+
expect(recipe.metadata["time"]["cook"]).to eq("30 minutes")
|
52
|
+
end
|
53
|
+
|
54
|
+
it "supports mixed data types" do
|
55
|
+
recipe_text = <<~COOK
|
56
|
+
---
|
57
|
+
title: Test Recipe
|
58
|
+
servings: 4
|
59
|
+
rating: 4.5
|
60
|
+
published: true
|
61
|
+
custom_field: Some value
|
62
|
+
numbers: [1, 2, 3]
|
63
|
+
---
|
64
|
+
@salt
|
65
|
+
COOK
|
66
|
+
|
67
|
+
recipe = parser.parse(recipe_text)
|
68
|
+
|
69
|
+
expect(recipe.metadata["title"]).to eq("Test Recipe")
|
70
|
+
expect(recipe.metadata["servings"]).to eq(4)
|
71
|
+
expect(recipe.metadata["rating"]).to eq(4.5)
|
72
|
+
expect(recipe.metadata["published"]).to eq(true)
|
73
|
+
expect(recipe.metadata["custom_field"]).to eq("Some value")
|
74
|
+
expect(recipe.metadata["numbers"]).to eq([1, 2, 3])
|
75
|
+
end
|
76
|
+
|
77
|
+
it "raises error on invalid YAML" do
|
78
|
+
recipe_text = <<~COOK
|
79
|
+
---
|
80
|
+
title: Test
|
81
|
+
tags: [invalid
|
82
|
+
not closed
|
83
|
+
---
|
84
|
+
@salt
|
85
|
+
COOK
|
86
|
+
|
87
|
+
expect { parser.parse(recipe_text) }.to raise_error(Psych::SyntaxError)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
describe "Canonical fields work as plain Hash values" do
|
92
|
+
let(:metadata) { Cooklang::Metadata.new }
|
93
|
+
|
94
|
+
it "stores servings as number or string" do
|
95
|
+
metadata["servings"] = 4
|
96
|
+
expect(metadata["servings"]).to eq(4)
|
97
|
+
|
98
|
+
metadata["servings"] = "6 cups worth"
|
99
|
+
expect(metadata["servings"]).to eq("6 cups worth")
|
100
|
+
end
|
101
|
+
|
102
|
+
it "stores time fields as nested or flat" do
|
103
|
+
metadata["time"] = { "prep" => "10 min", "cook" => "20 min" }
|
104
|
+
expect(metadata["time"]["prep"]).to eq("10 min")
|
105
|
+
expect(metadata["time"]["cook"]).to eq("20 min")
|
106
|
+
|
107
|
+
metadata.clear
|
108
|
+
metadata["prep_time"] = "15 minutes"
|
109
|
+
metadata["cook_time"] = "25 minutes"
|
110
|
+
expect(metadata["prep_time"]).to eq("15 minutes")
|
111
|
+
expect(metadata["cook_time"]).to eq("25 minutes")
|
112
|
+
end
|
113
|
+
|
114
|
+
it "stores source as string or nested hash" do
|
115
|
+
metadata["source"] = {
|
116
|
+
"name" => "The Cookbook",
|
117
|
+
"url" => "https://example.com",
|
118
|
+
"author" => "Chef Name"
|
119
|
+
}
|
120
|
+
expect(metadata["source"]["name"]).to eq("The Cookbook")
|
121
|
+
expect(metadata["source"]["url"]).to eq("https://example.com")
|
122
|
+
expect(metadata["source"]["author"]).to eq("Chef Name")
|
123
|
+
|
124
|
+
metadata.clear
|
125
|
+
metadata["source"] = "Grandma's recipe box"
|
126
|
+
expect(metadata["source"]).to eq("Grandma's recipe box")
|
127
|
+
end
|
128
|
+
|
129
|
+
it "stores tags and diet as arrays" do
|
130
|
+
metadata["tags"] = ["pasta", "italian"]
|
131
|
+
expect(metadata["tags"]).to eq(["pasta", "italian"])
|
132
|
+
|
133
|
+
metadata["diet"] = ["vegan", "gluten-free"]
|
134
|
+
expect(metadata["diet"]).to eq(["vegan", "gluten-free"])
|
135
|
+
end
|
136
|
+
|
137
|
+
it "stores other canonical fields" do
|
138
|
+
metadata["category"] = "dessert"
|
139
|
+
metadata["description"] = "A delicious recipe"
|
140
|
+
metadata["difficulty"] = "easy"
|
141
|
+
metadata["cuisine"] = "Italian"
|
142
|
+
metadata["locale"] = "en_US"
|
143
|
+
metadata["images"] = ["url1", "url2"]
|
144
|
+
|
145
|
+
expect(metadata["category"]).to eq("dessert")
|
146
|
+
expect(metadata["description"]).to eq("A delicious recipe")
|
147
|
+
expect(metadata["difficulty"]).to eq("easy")
|
148
|
+
expect(metadata["cuisine"]).to eq("Italian")
|
149
|
+
expect(metadata["locale"]).to eq("en_US")
|
150
|
+
expect(metadata["images"]).to eq(["url1", "url2"])
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
describe "Custom metadata fields" do
|
155
|
+
it "allows any custom fields" do
|
156
|
+
metadata = Cooklang::Metadata.new(
|
157
|
+
"wine_pairing" => "Pinot Noir",
|
158
|
+
"my_rating" => 5,
|
159
|
+
"special_equipment" => ["thermometer", "stand mixer"],
|
160
|
+
"notes_to_self" => "Double the garlic next time"
|
161
|
+
)
|
162
|
+
|
163
|
+
expect(metadata["wine_pairing"]).to eq("Pinot Noir")
|
164
|
+
expect(metadata["my_rating"]).to eq(5)
|
165
|
+
expect(metadata["special_equipment"]).to eq(["thermometer", "stand mixer"])
|
166
|
+
expect(metadata["notes_to_self"]).to eq("Double the garlic next time")
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
@@ -81,161 +81,31 @@ RSpec.describe Cooklang::Metadata do
|
|
81
81
|
end
|
82
82
|
end
|
83
83
|
|
84
|
-
describe "
|
84
|
+
describe "direct access to metadata" do
|
85
85
|
let(:metadata) { described_class.new }
|
86
86
|
|
87
|
-
|
88
|
-
|
89
|
-
|
87
|
+
it "stores any data types" do
|
88
|
+
metadata["servings"] = 4
|
89
|
+
metadata["tags"] = ["pasta", "italian"]
|
90
|
+
metadata["rating"] = 4.5
|
91
|
+
metadata["published"] = true
|
92
|
+
metadata["source"] = { "name" => "Cookbook", "url" => "https://example.com" }
|
90
93
|
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
end
|
97
|
-
end
|
98
|
-
|
99
|
-
describe "#servings=" do
|
100
|
-
it "sets servings" do
|
101
|
-
metadata.servings = 6
|
102
|
-
|
103
|
-
expect(metadata["servings"]).to eq(6)
|
104
|
-
end
|
105
|
-
end
|
106
|
-
|
107
|
-
describe "#prep_time" do
|
108
|
-
it "returns prep_time" do
|
109
|
-
metadata["prep_time"] = "15 minutes"
|
110
|
-
|
111
|
-
expect(metadata.prep_time).to eq("15 minutes")
|
112
|
-
end
|
113
|
-
|
114
|
-
it "returns prep-time as fallback" do
|
115
|
-
metadata["prep-time"] = "15 minutes"
|
116
|
-
|
117
|
-
expect(metadata.prep_time).to eq("15 minutes")
|
118
|
-
end
|
119
|
-
|
120
|
-
it "returns nil when not set" do
|
121
|
-
expect(metadata.prep_time).to be_nil
|
122
|
-
end
|
123
|
-
end
|
124
|
-
|
125
|
-
describe "#prep_time=" do
|
126
|
-
it "sets prep_time" do
|
127
|
-
metadata.prep_time = "20 minutes"
|
128
|
-
|
129
|
-
expect(metadata["prep_time"]).to eq("20 minutes")
|
130
|
-
end
|
131
|
-
end
|
132
|
-
|
133
|
-
describe "#cook_time" do
|
134
|
-
it "returns cook_time" do
|
135
|
-
metadata["cook_time"] = "30 minutes"
|
136
|
-
|
137
|
-
expect(metadata.cook_time).to eq("30 minutes")
|
138
|
-
end
|
139
|
-
|
140
|
-
it "returns cook-time as fallback" do
|
141
|
-
metadata["cook-time"] = "30 minutes"
|
142
|
-
|
143
|
-
expect(metadata.cook_time).to eq("30 minutes")
|
144
|
-
end
|
145
|
-
end
|
146
|
-
|
147
|
-
describe "#cook_time=" do
|
148
|
-
it "sets cook_time" do
|
149
|
-
metadata.cook_time = "25 minutes"
|
150
|
-
|
151
|
-
expect(metadata["cook_time"]).to eq("25 minutes")
|
152
|
-
end
|
153
|
-
end
|
154
|
-
|
155
|
-
describe "#total_time" do
|
156
|
-
it "returns total_time" do
|
157
|
-
metadata["total_time"] = "45 minutes"
|
158
|
-
|
159
|
-
expect(metadata.total_time).to eq("45 minutes")
|
160
|
-
end
|
161
|
-
|
162
|
-
it "returns total-time as fallback" do
|
163
|
-
metadata["total-time"] = "45 minutes"
|
164
|
-
|
165
|
-
expect(metadata.total_time).to eq("45 minutes")
|
166
|
-
end
|
167
|
-
end
|
168
|
-
|
169
|
-
describe "#total_time=" do
|
170
|
-
it "sets total_time" do
|
171
|
-
metadata.total_time = "50 minutes"
|
172
|
-
|
173
|
-
expect(metadata["total_time"]).to eq("50 minutes")
|
174
|
-
end
|
175
|
-
end
|
176
|
-
|
177
|
-
describe "#title" do
|
178
|
-
it "returns title" do
|
179
|
-
metadata["title"] = "Chocolate Cake"
|
180
|
-
|
181
|
-
expect(metadata.title).to eq("Chocolate Cake")
|
182
|
-
end
|
183
|
-
end
|
184
|
-
|
185
|
-
describe "#title=" do
|
186
|
-
it "sets title" do
|
187
|
-
metadata.title = "Vanilla Cake"
|
188
|
-
|
189
|
-
expect(metadata["title"]).to eq("Vanilla Cake")
|
190
|
-
end
|
191
|
-
end
|
192
|
-
|
193
|
-
describe "#source" do
|
194
|
-
it "returns source" do
|
195
|
-
metadata["source"] = "cookbook.com"
|
196
|
-
|
197
|
-
expect(metadata.source).to eq("cookbook.com")
|
198
|
-
end
|
199
|
-
end
|
200
|
-
|
201
|
-
describe "#source=" do
|
202
|
-
it "sets source" do
|
203
|
-
metadata.source = "my-blog.com"
|
204
|
-
|
205
|
-
expect(metadata["source"]).to eq("my-blog.com")
|
206
|
-
end
|
207
|
-
end
|
208
|
-
|
209
|
-
describe "#tags" do
|
210
|
-
it "returns array when tags is array" do
|
211
|
-
metadata["tags"] = ["dessert", "chocolate"]
|
212
|
-
|
213
|
-
expect(metadata.tags).to eq(["dessert", "chocolate"])
|
214
|
-
end
|
215
|
-
|
216
|
-
it "splits string tags on comma" do
|
217
|
-
metadata["tags"] = "dessert, chocolate, sweet"
|
218
|
-
|
219
|
-
expect(metadata.tags).to eq(["dessert", "chocolate", "sweet"])
|
220
|
-
end
|
221
|
-
|
222
|
-
it "returns empty array when not set" do
|
223
|
-
expect(metadata.tags).to eq([])
|
224
|
-
end
|
225
|
-
|
226
|
-
it "returns empty array for non-string, non-array values" do
|
227
|
-
metadata["tags"] = 123
|
228
|
-
|
229
|
-
expect(metadata.tags).to eq([])
|
230
|
-
end
|
94
|
+
expect(metadata["servings"]).to eq(4)
|
95
|
+
expect(metadata["tags"]).to eq(["pasta", "italian"])
|
96
|
+
expect(metadata["rating"]).to eq(4.5)
|
97
|
+
expect(metadata["published"]).to eq(true)
|
98
|
+
expect(metadata["source"]).to eq({ "name" => "Cookbook", "url" => "https://example.com" })
|
231
99
|
end
|
232
100
|
|
233
|
-
|
234
|
-
|
235
|
-
|
101
|
+
it "allows any custom fields" do
|
102
|
+
metadata["wine_pairing"] = "Pinot Noir"
|
103
|
+
metadata["my_custom_field"] = { "nested" => "data" }
|
104
|
+
metadata["notes"] = ["note1", "note2"]
|
236
105
|
|
237
|
-
|
238
|
-
|
106
|
+
expect(metadata["wine_pairing"]).to eq("Pinot Noir")
|
107
|
+
expect(metadata["my_custom_field"]).to eq({ "nested" => "data" })
|
108
|
+
expect(metadata["notes"]).to eq(["note1", "note2"])
|
239
109
|
end
|
240
110
|
end
|
241
111
|
end
|
data/spec/models/recipe_spec.rb
CHANGED
@@ -143,7 +143,7 @@ RSpec.describe Cooklang::Recipe do
|
|
143
143
|
|
144
144
|
let(:expected_hash) do
|
145
145
|
{
|
146
|
-
metadata: { map: { "title" => "Test Recipe", "servings" =>
|
146
|
+
metadata: { map: { "title" => "Test Recipe", "servings" => 4, "prep_time" => "15 minutes" } },
|
147
147
|
sections: [
|
148
148
|
{
|
149
149
|
name: nil,
|
@@ -174,7 +174,7 @@ RSpec.describe Cooklang::Recipe do
|
|
174
174
|
|
175
175
|
let(:expected_json) do
|
176
176
|
{
|
177
|
-
"metadata" => { "map" => { "title" => "Test Recipe", "servings" =>
|
177
|
+
"metadata" => { "map" => { "title" => "Test Recipe", "servings" => 4, "prep_time" => "15 minutes" } },
|
178
178
|
"sections" => [
|
179
179
|
{
|
180
180
|
"name" => nil,
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
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.4
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- James Brooks
|
@@ -157,8 +157,13 @@ files:
|
|
157
157
|
- spec/comprehensive_spec.rb
|
158
158
|
- spec/cooklang_spec.rb
|
159
159
|
- spec/fixtures/canonical.yaml
|
160
|
+
- spec/fixtures/comprehensive_recipe.cook
|
161
|
+
- spec/formatters/formatter_spec.rb
|
162
|
+
- spec/formatters/hash_spec.rb
|
163
|
+
- spec/formatters/json_spec.rb
|
160
164
|
- spec/formatters/text_spec.rb
|
161
165
|
- spec/integration/canonical_spec.rb
|
166
|
+
- spec/integration/metadata_canonical_spec.rb
|
162
167
|
- spec/lexer_spec.rb
|
163
168
|
- spec/models/cookware_spec.rb
|
164
169
|
- spec/models/ingredient_spec.rb
|
@@ -199,8 +204,13 @@ test_files:
|
|
199
204
|
- spec/comprehensive_spec.rb
|
200
205
|
- spec/cooklang_spec.rb
|
201
206
|
- spec/fixtures/canonical.yaml
|
207
|
+
- spec/fixtures/comprehensive_recipe.cook
|
208
|
+
- spec/formatters/formatter_spec.rb
|
209
|
+
- spec/formatters/hash_spec.rb
|
210
|
+
- spec/formatters/json_spec.rb
|
202
211
|
- spec/formatters/text_spec.rb
|
203
212
|
- spec/integration/canonical_spec.rb
|
213
|
+
- spec/integration/metadata_canonical_spec.rb
|
204
214
|
- spec/lexer_spec.rb
|
205
215
|
- spec/models/cookware_spec.rb
|
206
216
|
- spec/models/ingredient_spec.rb
|