cooklang 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f4a3402d1f6f0e5864c0e59c8f237f0d06ca9a469ef3783f51b49e48d2ab1adb
4
- data.tar.gz: bba07497106184771d712868f8abaaca2538bff3b85278357ba8d62438e0d452
3
+ metadata.gz: 31ab63d47990eb2eaa6eb3dd09af9ba81d760cfbcfde937467f7060282195409
4
+ data.tar.gz: 2fb316c77fcfaabdc679943997cfa68607d0dbf61599def411743ff53be8f0a6
5
5
  SHA512:
6
- metadata.gz: 9d89c0f9d7828567958e37b3b98f2318d5d69e6bb64b31ef2a1536a14e142c031143c798236e36da450dd5f1ac15370306fa2694ef8358cbe790358c621e642d
7
- data.tar.gz: f1d1881eccc351da77bb473447e4b1eb8508d951ed71ad044e082bc8ed7ab92cbbbd8cb9904a996595db74b04ee91ef56896a9b3d75b1c12e51a39503d3615fc
6
+ metadata.gz: 7665a15f2b3ce79f5b7314068d58e1534e2dea35ee6268bbb2921a804972f8e366abe9d867e33fc4fd281d295f60ade7fac4903af263b5ab57fa88eba1fe29cf
7
+ data.tar.gz: 0cfaa9510dd5a81d68ba8205cdf497eb62f7983f2b03a645a0d1ebacd6eb4d4c30d13e5af09ea0f2b81e2d6a2687bd23d368b4e4d9b841d3633aff9a305cb991
data/README.md CHANGED
@@ -60,7 +60,7 @@ recipe = Cooklang.parse_file('pancakes.cook')
60
60
 
61
61
  ## Formatters
62
62
 
63
- The gem provides three built-in formatters for different output needs. Here's an example using 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,23 @@ 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, fixed quantities
285
+ - **Cookware** (`#`): Single/multi-word names, quantities
286
+ - **Timers** (`~`): Anonymous and named timers
287
+ - **Comments**: Line (`--`) and block (`[- -]`) comments
288
+ - **Metadata**: YAML front matter and inline metadata (`>>`)
289
+ - **Steps**: Paragraph-based with blank line separation
290
+ - **Notes** (`>`): Standalone notes
291
+ - **Sections** (`=`): Recipe sections with optional names
292
+ - **Canonical Tests** Passes all [Cooklang canonical tests](https://github.com/cooklang/spec/blob/main/tests/canonical.yaml) as of version 7
293
+
294
+ ### Not Yet Implemented
277
295
 
278
- - **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
296
+ - **Recipe References**: `@./path/to/recipe{}`
297
+ - **Shopping Lists**: `aisle.txt` configuration files
298
+ - **Images**: Convention-based image loading
283
299
 
284
300
  ## Development
285
301
 
@@ -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
 
@@ -231,7 +231,9 @@ module Cooklang
231
231
 
232
232
  def format_ingredient_data
233
233
  recipe.ingredients.map do |ingredient|
234
- if ingredient.quantity && ingredient.quantity != "some"
234
+ if ingredient.fixed?
235
+ "fixed"
236
+ elsif ingredient.quantity && ingredient.quantity != "some"
235
237
  "scaled"
236
238
  else
237
239
  "noQuantity"
@@ -11,6 +11,52 @@ module Cooklang
11
11
 
12
12
  sections.join("\n").strip
13
13
  end
14
+
15
+ private
16
+ def ingredients_section
17
+ return "" if recipe.ingredients.empty?
18
+
19
+ ingredient_lines = recipe.ingredients.map do |ingredient|
20
+ format_ingredient(ingredient)
21
+ end
22
+
23
+ "Ingredients:\n#{ingredient_lines.join("\n")}\n"
24
+ end
25
+
26
+ def steps_section
27
+ return "" if recipe.steps.empty?
28
+
29
+ step_lines = recipe.steps.each_with_index.map do |step, index|
30
+ " #{index + 1}. #{step.to_text}"
31
+ end
32
+
33
+ "Steps:\n#{step_lines.join("\n")}\n"
34
+ end
35
+
36
+ def format_ingredient(ingredient)
37
+ name = ingredient.name
38
+ quantity_unit = format_quantity_unit(ingredient)
39
+
40
+ # Add 4 spaces after the longest ingredient name for alignment
41
+ name_width = max_ingredient_name_length + 4
42
+ " #{name.ljust(name_width)}#{quantity_unit}"
43
+ end
44
+
45
+ def format_quantity_unit(ingredient)
46
+ if ingredient.quantity && ingredient.unit
47
+ "#{ingredient.quantity} #{ingredient.unit}"
48
+ elsif ingredient.quantity
49
+ ingredient.quantity.to_s
50
+ elsif ingredient.unit
51
+ ingredient.unit
52
+ else
53
+ "some"
54
+ end
55
+ end
56
+
57
+ def max_ingredient_name_length
58
+ @max_ingredient_name_length ||= recipe.ingredients.map(&:name).map(&:length).max || 0
59
+ end
14
60
  end
15
61
  end
16
62
  end
@@ -2,13 +2,14 @@
2
2
 
3
3
  module Cooklang
4
4
  class Ingredient
5
- attr_reader :name, :quantity, :unit, :notes
5
+ attr_reader :name, :quantity, :unit, :notes, :fixed
6
6
 
7
- def initialize(name:, quantity: nil, unit: nil, notes: nil)
7
+ def initialize(name:, quantity: nil, unit: nil, notes: nil, fixed: false)
8
8
  @name = name.to_s.freeze
9
9
  @quantity = quantity
10
10
  @unit = unit&.to_s&.freeze
11
11
  @notes = notes&.to_s&.freeze
12
+ @fixed = fixed
12
13
  end
13
14
 
14
15
  def to_s
@@ -24,7 +25,8 @@ module Cooklang
24
25
  name: @name,
25
26
  quantity: @quantity,
26
27
  unit: @unit,
27
- notes: @notes
28
+ notes: @notes,
29
+ fixed: @fixed
28
30
  }.compact
29
31
  end
30
32
 
@@ -34,7 +36,8 @@ module Cooklang
34
36
  name == other.name &&
35
37
  quantity == other.quantity &&
36
38
  unit == other.unit &&
37
- notes == other.notes
39
+ notes == other.notes &&
40
+ fixed == other.fixed
38
41
  end
39
42
 
40
43
  def eql?(other)
@@ -42,7 +45,7 @@ module Cooklang
42
45
  end
43
46
 
44
47
  def hash
45
- [name, quantity, unit, notes].hash
48
+ [name, quantity, unit, notes, fixed].hash
46
49
  end
47
50
 
48
51
  def has_quantity?
@@ -56,5 +59,9 @@ module Cooklang
56
59
  def has_notes?
57
60
  !@notes.nil?
58
61
  end
62
+
63
+ def fixed?
64
+ @fixed
65
+ end
59
66
  end
60
67
  end
@@ -23,7 +23,7 @@ module Cooklang
23
23
  comment_block_start: "[-",
24
24
  comment_block_end: "-]",
25
25
  metadata_marker: ">>",
26
- section_marker: "=",
26
+ equals: "=",
27
27
  note_marker: ">",
28
28
  newline: "\n",
29
29
  yaml_delimiter: "---"
@@ -40,12 +40,17 @@ module Cooklang
40
40
  def tokenize
41
41
  @tokens = []
42
42
 
43
+ # Check for YAML frontmatter at the beginning
44
+ if @scanner.check(/^---/)
45
+ handle_yaml_frontmatter
46
+ end
47
+
43
48
  until @scanner.eos?
44
49
  if match_yaml_delimiter
45
50
  elsif match_comment_block
46
51
  elsif match_comment_line
47
52
  elsif match_metadata_marker
48
- elsif match_section_marker
53
+ elsif match_equals
49
54
  elsif match_note_marker
50
55
  elsif match_special_chars
51
56
  elsif match_newline
@@ -61,6 +66,50 @@ module Cooklang
61
66
  end
62
67
 
63
68
  private
69
+ def handle_yaml_frontmatter
70
+ # Emit the opening ---
71
+ position = current_position
72
+ line = current_line
73
+ column = current_column
74
+ value = @scanner.scan("---")
75
+ @tokens << Token.new(:yaml_delimiter, value, position, line, column)
76
+ advance_position(value)
77
+
78
+ # Scan until we find the closing --- or EOF
79
+ yaml_content = ""
80
+ until @scanner.eos?
81
+ if @scanner.check(/\n---\s*(\n|$)/)
82
+ # Found closing delimiter
83
+ # Add the newline before it
84
+ if @scanner.check(/\n/)
85
+ char = @scanner.getch
86
+ yaml_content += char
87
+ advance_position(char)
88
+ end
89
+ break
90
+ else
91
+ char = @scanner.getch
92
+ yaml_content += char
93
+ advance_position(char)
94
+ end
95
+ end
96
+
97
+ # Emit the YAML content as a single token if we have any
98
+ if !yaml_content.empty?
99
+ @tokens << Token.new(:yaml_content, yaml_content.rstrip, current_position, current_line, current_column)
100
+ end
101
+
102
+ # Emit the closing --- if present
103
+ if @scanner.check(/---/)
104
+ position = current_position
105
+ line = current_line
106
+ column = current_column
107
+ value = @scanner.scan("---")
108
+ @tokens << Token.new(:yaml_delimiter, value, position, line, column)
109
+ advance_position(value)
110
+ end
111
+ end
112
+
64
113
  def current_position
65
114
  @scanner.pos
66
115
  end
@@ -179,13 +228,13 @@ module Cooklang
179
228
  end
180
229
  end
181
230
 
182
- def match_section_marker
231
+ def match_equals
183
232
  if @scanner.check(/=+/)
184
233
  position = current_position
185
234
  line = current_line
186
235
  column = current_column
187
236
  value = @scanner.scan(/=+/)
188
- @tokens << Token.new(:section_marker, value, position, line, column)
237
+ @tokens << Token.new(:equals, value, position, line, column)
189
238
  advance_position(value)
190
239
  true
191
240
  else
@@ -30,69 +30,5 @@ module Cooklang
30
30
  def to_h
31
31
  super
32
32
  end
33
-
34
- def servings
35
- self["servings"]&.to_i
36
- end
37
-
38
- def servings=(value)
39
- self["servings"] = value
40
- end
41
-
42
- def prep_time
43
- self["prep_time"] || self["prep-time"]
44
- end
45
-
46
- def prep_time=(value)
47
- self["prep_time"] = value
48
- end
49
-
50
- def cook_time
51
- self["cook_time"] || self["cook-time"]
52
- end
53
-
54
- def cook_time=(value)
55
- self["cook_time"] = value
56
- end
57
-
58
- def total_time
59
- self["total_time"] || self["total-time"]
60
- end
61
-
62
- def total_time=(value)
63
- self["total_time"] = value
64
- end
65
-
66
- def title
67
- self["title"]
68
- end
69
-
70
- def title=(value)
71
- self["title"] = value
72
- end
73
-
74
- def source
75
- self["source"]
76
- end
77
-
78
- def source=(value)
79
- self["source"] = value
80
- end
81
-
82
- def tags
83
- value = self["tags"]
84
- case value
85
- when Array
86
- value
87
- when String
88
- value.split(",").map(&:strip)
89
- else
90
- []
91
- end
92
- end
93
-
94
- def tags=(value)
95
- self["tags"] = value
96
- end
97
33
  end
98
34
  end
@@ -35,7 +35,7 @@ module Cooklang
35
35
  name = extract_name_until_brace(brace_index)
36
36
  @stream.consume(:open_brace) # Skip open brace
37
37
 
38
- quantity, unit = extract_quantity_and_unit
38
+ quantity, unit, fixed = extract_quantity_and_unit
39
39
  @stream.consume(:close_brace) # Skip close brace
40
40
 
41
41
  notes = extract_notes
@@ -44,7 +44,7 @@ module Cooklang
44
44
  quantity = "some" if (quantity.nil? || quantity == "") && (unit.nil? || unit == "")
45
45
  unit = nil if unit == ""
46
46
 
47
- Ingredient.new(name: name, quantity: quantity, unit: unit, notes: notes)
47
+ Ingredient.new(name: name, quantity: quantity, unit: unit, notes: notes, fixed: fixed)
48
48
  end
49
49
 
50
50
  def parse_simple_ingredient
@@ -85,8 +85,14 @@ module Cooklang
85
85
  def extract_quantity_and_unit
86
86
  quantity = nil
87
87
  unit = nil
88
+ fixed = false
88
89
  text_parts = []
89
90
 
91
+ if @stream.current&.type == :equals
92
+ fixed = true
93
+ @stream.consume
94
+ end
95
+
90
96
  while !@stream.eof? && @stream.current.type != :close_brace
91
97
  case @stream.current.type
92
98
  when :percent
@@ -106,6 +112,7 @@ module Cooklang
106
112
 
107
113
  if !text_parts.empty?
108
114
  combined_text = text_parts.join.strip
115
+
109
116
  if quantity.nil?
110
117
  # Try to parse as numeric quantity first, fallback to string if not purely numeric
111
118
  if combined_text.match?(/^\d+$/)
@@ -124,7 +131,7 @@ module Cooklang
124
131
  end
125
132
  end
126
133
 
127
- [quantity, unit]
134
+ [quantity, unit, fixed]
128
135
  end
129
136
 
130
137
  def parse_quantity_value(text)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "yaml"
4
+
3
5
  module Cooklang
4
6
  module Processors
5
7
  class MetadataProcessor
@@ -7,7 +9,6 @@ module Cooklang
7
9
  def extract_metadata(tokens)
8
10
  metadata = Metadata.new
9
11
  content_tokens = []
10
- 0
11
12
 
12
13
  # Check for YAML front matter
13
14
  if tokens.first&.type == :yaml_delimiter
@@ -26,32 +27,17 @@ module Cooklang
26
27
  def extract_yaml_frontmatter(tokens)
27
28
  metadata = Metadata.new
28
29
  i = 1 # Skip the first ---
29
- 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
 
@@ -96,7 +96,7 @@ module Cooklang
96
96
  when :open_brace, :close_brace, :open_paren, :close_paren, :percent
97
97
  builder.add_text(token.value)
98
98
  stream.consume
99
- when :section_marker
99
+ when :equals # section marker (= or ==)
100
100
  process_section_with_stream(stream, builder)
101
101
  else
102
102
  stream.consume
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cooklang
4
- VERSION = "1.0.3"
4
+ VERSION = "1.1.0"
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