cooklang 1.0.2 → 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/.github/workflows/test.yml +2 -2
- data/.gitignore +1 -0
- data/README.md +26 -7
- data/cooklang.gemspec +2 -2
- 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 +2 -66
- 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 +16 -7
- data/Gemfile.lock +0 -84
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/.github/workflows/test.yml
CHANGED
@@ -11,7 +11,7 @@ jobs:
|
|
11
11
|
runs-on: ubuntu-latest
|
12
12
|
strategy:
|
13
13
|
matrix:
|
14
|
-
ruby-version: ['3.2', '3.3', '3.4', '3.5']
|
14
|
+
ruby-version: ['2.7', '3.0', '3.1', '3.2', '3.3', '3.4', '3.5']
|
15
15
|
|
16
16
|
steps:
|
17
17
|
- uses: actions/checkout@v4
|
@@ -29,7 +29,7 @@ jobs:
|
|
29
29
|
run: bundle exec rubocop
|
30
30
|
|
31
31
|
- uses: qltysh/qlty-action/coverage@v2
|
32
|
-
if: matrix.ruby-version == '3.
|
32
|
+
if: matrix.ruby-version == '3.4'
|
33
33
|
with:
|
34
34
|
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
|
35
35
|
files: coverage/.resultset.json
|
data/.gitignore
CHANGED
data/README.md
CHANGED
@@ -7,6 +7,10 @@
|
|
7
7
|
|
8
8
|
A Ruby parser for the [Cooklang](https://cooklang.org) recipe markup language.
|
9
9
|
|
10
|
+
## Requirements
|
11
|
+
|
12
|
+
- Ruby 2.7 or higher
|
13
|
+
|
10
14
|
## Installation
|
11
15
|
|
12
16
|
Add to your Gemfile:
|
@@ -56,7 +60,7 @@ recipe = Cooklang.parse_file('pancakes.cook')
|
|
56
60
|
|
57
61
|
## Formatters
|
58
62
|
|
59
|
-
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:
|
60
64
|
|
61
65
|
```ruby
|
62
66
|
>> title: Uncle Roger's Fried Rice
|
@@ -253,6 +257,12 @@ recipe.timers # Array of Timer objects
|
|
253
257
|
recipe.steps # Array of Step objects
|
254
258
|
recipe.steps_text # Array of plain text steps
|
255
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
|
+
|
256
266
|
# Ingredient
|
257
267
|
ingredient.name # "flour"
|
258
268
|
ingredient.quantity # 125
|
@@ -269,13 +279,22 @@ timer.duration # 10
|
|
269
279
|
timer.unit # "minutes"
|
270
280
|
```
|
271
281
|
|
272
|
-
## 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
|
273
294
|
|
274
|
-
- **
|
275
|
-
- **
|
276
|
-
- **
|
277
|
-
- **Comments**: `-- line comment`, `[- block comment -]`
|
278
|
-
- **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
|
279
298
|
|
280
299
|
## Development
|
281
300
|
|
data/cooklang.gemspec
CHANGED
@@ -14,7 +14,7 @@ Gem::Specification.new do |spec|
|
|
14
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
15
|
spec.homepage = "https://github.com/jamesbrooks/cooklang"
|
16
16
|
spec.license = "MIT"
|
17
|
-
spec.required_ruby_version = ">=
|
17
|
+
spec.required_ruby_version = ">= 2.7.0"
|
18
18
|
|
19
19
|
spec.metadata["homepage_uri"] = spec.homepage
|
20
20
|
spec.metadata["source_code_uri"] = "https://github.com/jamesbrooks/cooklang"
|
@@ -25,7 +25,7 @@ Gem::Specification.new do |spec|
|
|
25
25
|
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
26
26
|
spec.require_paths = ["lib"]
|
27
27
|
|
28
|
-
spec.add_development_dependency "bundler", "
|
28
|
+
spec.add_development_dependency "bundler", ">= 2.1"
|
29
29
|
spec.add_development_dependency "rake", "~> 13.0"
|
30
30
|
spec.add_development_dependency "rspec", "~> 3.13"
|
31
31
|
spec.add_development_dependency "rubocop", "~> 1.80"
|
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
@@ -23,76 +23,12 @@ module Cooklang
|
|
23
23
|
super(key.to_s)
|
24
24
|
end
|
25
25
|
|
26
|
-
def fetch(key, *)
|
27
|
-
super(key.to_s, *)
|
26
|
+
def fetch(key, *args)
|
27
|
+
super(key.to_s, *args)
|
28
28
|
end
|
29
29
|
|
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
|