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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a13e5e798824a4c9fa2d7405a7466f03b59ef20a0a80ed16f1de789cf40df0db
4
- data.tar.gz: ade82856860ca28430e38f325e64b15a8f2a0694ebaba9a56cfd058af1f658e5
3
+ metadata.gz: fa4eed0618449146de288cbfa4b51b05d7ac994fca5e3aca0161c6cfe90d2551
4
+ data.tar.gz: 45c683a6a2daea2da5d48116cf91181532370df0b1b2e5de0d1b72a749f2e245
5
5
  SHA512:
6
- metadata.gz: 8ff63ccbc36d06328546b6508429a93d94d5d97a98fdec2d506854d39661d4015f18630269f2e61a838dcb9083b9850e70b0d2db1503f7a7631d6a246a2bd42f
7
- data.tar.gz: 010e6b452e3d7102b7c347f6875d132f137b5b55cba594d0b29a3d28ad1cf19dfebbe6b700441f65b763db9c021675e8b7405f90615925fc1ca1baf9191f5e2e
6
+ metadata.gz: b4cfc4bad69e170f6fff3b5aef1d35b31ba72232b1c9579129f1b3ec1e5f1174af9501a3455be440da077a7138c3af1b822a6f11f232cc6c420621cea8d06552
7
+ data.tar.gz: 6d84e7c23687b56076c2b9aef18e1f801dbf471ce2fd3e5f216f62016c33656152c7634d3d6d0fa95c9b15181d6f1b05196c225301da0c5ec184a1e136375e73
@@ -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.5'
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
@@ -10,3 +10,4 @@
10
10
 
11
11
  # rspec failure tracking
12
12
  .rspec_status
13
+ Gemfile.lock
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 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:
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 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
273
294
 
274
- - **Ingredients**: `@salt`, `@flour{125%g}`, `@onion{1}(diced)`
275
- - **Cookware**: `#pan`, `#mixing bowl{}`
276
- - **Timers**: `~{5%minutes}`, `~pasta{10%minutes}`
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 = ">= 3.2.0"
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", "~> 2.7"
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"
@@ -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
@@ -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
- 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.2"
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