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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f4a3402d1f6f0e5864c0e59c8f237f0d06ca9a469ef3783f51b49e48d2ab1adb
4
- data.tar.gz: bba07497106184771d712868f8abaaca2538bff3b85278357ba8d62438e0d452
3
+ metadata.gz: fa4eed0618449146de288cbfa4b51b05d7ac994fca5e3aca0161c6cfe90d2551
4
+ data.tar.gz: 45c683a6a2daea2da5d48116cf91181532370df0b1b2e5de0d1b72a749f2e245
5
5
  SHA512:
6
- metadata.gz: 9d89c0f9d7828567958e37b3b98f2318d5d69e6bb64b31ef2a1536a14e142c031143c798236e36da450dd5f1ac15370306fa2694ef8358cbe790358c621e642d
7
- data.tar.gz: f1d1881eccc351da77bb473447e4b1eb8508d951ed71ad044e082bc8ed7ab92cbbbd8cb9904a996595db74b04ee91ef56896a9b3d75b1c12e51a39503d3615fc
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 Uncle Roger's Fried Rice recipe:
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 Syntax
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
- - **Ingredients**: `@salt`, `@flour{125%g}`, `@onion{1}(diced)`
279
- - **Cookware**: `#pan`, `#mixing bowl{}`
280
- - **Timers**: `~{5%minutes}`, `~pasta{10%minutes}`
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
 
@@ -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.transform_values(&:to_s)
21
+ map: recipe.metadata.to_h
22
22
  }
23
23
  end
24
24
 
@@ -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
@@ -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
@@ -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
- yaml_content = []
30
-
31
- # Find the closing --- delimiter
32
- while i < tokens.length
33
- if tokens[i].type == :yaml_delimiter
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
- # Simple YAML parsing - split by lines and parse key-value pairs
63
- yaml_text.split("\n").each do |line|
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
- # Remove quotes if present
72
- value = value.gsub(/^["']|["']$/, "") if value.match?(/^["'].*["']$/)
51
+ parsed = YAML.safe_load(fixed_yaml, permitted_classes: [Date, Time, DateTime])
73
52
 
74
- # Parse numeric values
75
- parsed_value = parse_metadata_value(value)
76
- metadata[key] = parsed_value unless value.empty?
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cooklang
4
- VERSION = "1.0.3"
4
+ VERSION = "1.0.4"
5
5
  end
@@ -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
- let(:recipe) do
7
- Cooklang::Recipe.new(
8
- ingredients: ingredients,
9
- steps: steps,
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
- context "with steps only" do
47
- let(:ingredients) { [] }
48
- let(:steps) do
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
- context "with both ingredients and steps" do
75
- let(:ingredients) do
76
- [
77
- Cooklang::Ingredient.new(name: "butter"),
78
- Cooklang::Ingredient.new(name: "eggs", quantity: 3),
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
- context "with empty recipe" do
132
- let(:ingredients) { [] }
133
- let(:steps) { [] }
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
- it "formats quantity with unit" do
170
- ingredient = Cooklang::Ingredient.new(name: "flour", quantity: 125, unit: "g")
171
- expect(formatter.send(:format_quantity_unit, ingredient)).to eq("125 g")
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 "formats quantity without unit" do
175
- ingredient = Cooklang::Ingredient.new(name: "eggs", quantity: 3)
176
- expect(formatter.send(:format_quantity_unit, ingredient)).to eq("3")
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 "formats unit without quantity" do
180
- ingredient = Cooklang::Ingredient.new(name: "olive oil", unit: "drizzle")
181
- expect(formatter.send(:format_quantity_unit, ingredient)).to eq("drizzle")
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 "handles missing quantity and unit" do
185
- ingredient = Cooklang::Ingredient.new(name: "salt")
186
- expect(formatter.send(:format_quantity_unit, ingredient)).to eq("some")
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 "recipe-specific accessors" do
84
+ describe "direct access to metadata" do
85
85
  let(:metadata) { described_class.new }
86
86
 
87
- describe "#servings" do
88
- it "returns integer servings" do
89
- metadata["servings"] = "4"
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
- expect(metadata.servings).to eq(4)
92
- end
93
-
94
- it "returns nil when not set" do
95
- expect(metadata.servings).to be_nil
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
- describe "#tags=" do
234
- it "sets tags" do
235
- metadata.tags = ["breakfast", "quick"]
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
- expect(metadata["tags"]).to eq(["breakfast", "quick"])
238
- end
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
@@ -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" => "4", "prep_time" => "15 minutes" } },
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" => "4", "prep_time" => "15 minutes" } },
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.3
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