From 80099ccf4536d99944afae51b6d531ff440b3021 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20Hilligs=C3=B8e?= Date: Fri, 23 Jan 2026 20:38:24 +0100 Subject: [PATCH] feat: add @? optional ingredient syntax Implement the @? operator for marking ingredients as optional, following the cooklang-rs extension convention. Optional ingredients are displayed with '(optional)' suffix in all output formats. Changes: - Add OPTIONAL_INGREDIENT token type and lexer support - Update parser to handle @? and set Optional field on components - Add Optional bool field to Ingredient struct - Update Render() and RenderDisplay() methods for optional ingredients - Add optional ingredient support to HTML, Markdown, and Print renderers - Add spec tests and parser/lexer unit tests - Add example Gin_and_Tonic.cook recipe demonstrating optional garnishes Example usage: Add @?thyme{2%sprigs} if desired. Garnish with @?lime wedge{1} or @?lemon wheel{1}. --- cooklang.go | 21 +++-- example_recipes/Gin_and_Tonic.cook | 32 +++++++ lexer/lexer.go | 16 +++- lexer/lexer_test.go | 131 +++++++++++++++++++++++++++++ parser/parser.go | 13 ++- parser/parser_test.go | 110 ++++++++++++++++++++++++ renderers/html.go | 13 ++- renderers/markdown.go | 17 ++-- renderers/print.go | 29 ++++++- spec/canonical_extensions.yaml | 112 ++++++++++++++++++++++++ token/token.go | 7 +- 11 files changed, 478 insertions(+), 23 deletions(-) create mode 100644 example_recipes/Gin_and_Tonic.cook diff --git a/cooklang.go b/cooklang.go index 17cc60f..91c67af 100644 --- a/cooklang.go +++ b/cooklang.go @@ -244,20 +244,24 @@ func (Comment) isStepComponent() {} func (Note) isStepComponent() {} // Render returns the Cooklang syntax representation of this ingredient. -// Examples: "@flour{500%g}", "@salt{}", "@milk{2%cups}(cold)", "@yeast{=1%packet}" +// Examples: "@flour{500%g}", "@salt{}", "@milk{2%cups}(cold)", "@yeast{=1%packet}", "@?thyme{2%sprigs}" func (i Ingredient) Render() string { var result string + prefix := "@" + if i.Optional { + prefix = "@?" + } fixedPrefix := "" if i.Fixed { fixedPrefix = "=" } if i.Quantity > 0 { - result = fmt.Sprintf("@%s{%s%g%%%s}", i.Name, fixedPrefix, i.Quantity, i.Unit) + result = fmt.Sprintf("%s%s{%s%g%%%s}", prefix, i.Name, fixedPrefix, i.Quantity, i.Unit) } else if i.Quantity == -1 { // -1 indicates "some" quantity - result = fmt.Sprintf("@%s{}", i.Name) + result = fmt.Sprintf("%s%s{}", prefix, i.Name) } else { - result = fmt.Sprintf("@%s{}", i.Name) + result = fmt.Sprintf("%s%s{}", prefix, i.Name) } if i.Annotation != "" { result += fmt.Sprintf("(%s)", i.Annotation) @@ -266,9 +270,10 @@ func (i Ingredient) Render() string { } // RenderDisplay returns ingredient in plain text format suitable for display. -// Examples: "2 cups flour", "500 g flour", "salt" +// Examples: "2 cups flour", "500 g flour", "salt", "2 sprigs thyme (optional)" // Uses bartender-friendly fraction formatting (e.g., "1/2 oz" instead of "0.5 oz") // When quantity is unspecified (e.g., @salt{}), returns just the ingredient name. +// Optional ingredients have "(optional)" appended. func (i Ingredient) RenderDisplay() string { var result string if i.Quantity > 0 && i.Unit != "" { @@ -281,6 +286,9 @@ func (i Ingredient) RenderDisplay() string { // Quantity == -1 (unspecified) or 0: just use the ingredient name result = i.Name } + if i.Optional { + result += " (optional)" + } return result } @@ -394,11 +402,13 @@ func (n Note) RenderDisplay() string { // // The Quantity field uses -1 to represent "some" (unspecified amount). // The Fixed field indicates a quantity that should not scale with servings (e.g., @salt{=1%tsp}). +// The Optional field indicates an optional ingredient (e.g., @?thyme{2%sprigs}). type Ingredient struct { Name string `json:"name,omitempty"` // Ingredient name (e.g., "flour", "sugar") Quantity float32 `json:"quantity,omitempty"` // Amount (-1 means "some", 0 means none specified) Unit string `json:"unit,omitempty"` // Unit of measurement (e.g., "g", "cup", "tbsp") Fixed bool `json:"fixed,omitempty"` // Fixed quantity doesn't scale with servings + Optional bool `json:"optional,omitempty"` // Optional ingredient (can be omitted) TypedUnit *units.Unit `json:"typed_unit,omitempty"` // Typed unit for conversion operations Subinstruction string `json:"value,omitempty"` // Additional preparation instructions Annotation string `json:"annotation,omitempty"` // Optional annotation (e.g., "finely chopped") @@ -780,6 +790,7 @@ func ToCooklangRecipe(pRecipe *parser.Recipe) *Recipe { Quantity: quant, Unit: component.Unit, Fixed: component.Fixed, + Optional: component.Optional, TypedUnit: CreateTypedUnit(component.Unit), Annotation: component.Value, } diff --git a/example_recipes/Gin_and_Tonic.cook b/example_recipes/Gin_and_Tonic.cook new file mode 100644 index 0000000..1101acf --- /dev/null +++ b/example_recipes/Gin_and_Tonic.cook @@ -0,0 +1,32 @@ +--- +title: Gin and Tonic +category: Cocktails +sub_category: Classic +cuisine: British +locale: en +tags: + - classic + - refreshing + - gin + - highball + - summer +servings: 1 +time: 3 minutes +image: Gin_and_Tonic.jpg +--- + +-- The classic G&T with optional garnish variations + +Fill a #highball glass{} with @ice cubes{}. + +Pour @gin{50%ml}(London Dry style works best) over the ice. + +Top with @tonic water{150%ml}(premium tonic recommended) and stir gently for ~{5%seconds}. + += Garnish = + +Add @?lime wedge{1} or @?lemon wheel{1} for citrus brightness. + +For a botanical twist, try @?cucumber ribbon{1} or @?fresh rosemary{1%sprig}. + +> Tip: The garnish should complement your gin's botanicals. Citrus-forward gins pair well with lime, while floral gins shine with cucumber or herbs. diff --git a/lexer/lexer.go b/lexer/lexer.go index c2c0a57..cd6b727 100644 --- a/lexer/lexer.go +++ b/lexer/lexer.go @@ -128,8 +128,20 @@ func (l *Lexer) NextToken() token.Token { case '}': tok = newToken(token.RBRACE, l.ch) case '@': - // Only treat as INGREDIENT if immediately followed by an identifier character or underscore - if isIdentifierChar(l.peekChar()) || l.peekChar() == '_' { + // Check for optional ingredient: @? + if l.peekChar() == '?' { + // Peek at the character after the '?' to see if it's an identifier + charAfterQuestion := l.peekCharAt(1) + if isIdentifierChar(charAfterQuestion) || charAfterQuestion == '_' { + ch := l.ch + l.readChar() // consume '?' + tok = token.Token{Type: token.OPTIONAL_INGREDIENT, Literal: string(ch) + string(l.ch)} + } else { + // @? not followed by identifier - treat @ as text + tok = newToken(token.ILLEGAL, l.ch) + } + } else if isIdentifierChar(l.peekChar()) || l.peekChar() == '_' { + // Only treat as INGREDIENT if immediately followed by an identifier character or underscore tok = newToken(token.INGREDIENT, l.ch) } else { // Treat as regular text if followed by whitespace or other characters diff --git a/lexer/lexer_test.go b/lexer/lexer_test.go index 24cae1d..cb7ea59 100644 --- a/lexer/lexer_test.go +++ b/lexer/lexer_test.go @@ -650,3 +650,134 @@ func TestNote(t *testing.T) { }) } } + +// TestOptionalIngredient tests optional ingredient tokenization @? +func TestOptionalIngredient(t *testing.T) { + tests := []struct { + name string + input string + expectedTokens []struct { + tokenType token.TokenType + literal string + } + }{ + { + name: "simple optional ingredient", + input: "@?thyme", + expectedTokens: []struct { + tokenType token.TokenType + literal string + }{ + {token.OPTIONAL_INGREDIENT, "@?"}, + {token.IDENT, "thyme"}, + {token.EOF, ""}, + }, + }, + { + name: "optional ingredient with braces", + input: "@?thyme{2%sprigs}", + expectedTokens: []struct { + tokenType token.TokenType + literal string + }{ + {token.OPTIONAL_INGREDIENT, "@?"}, + {token.IDENT, "thyme"}, + {token.LBRACE, "{"}, + {token.IDENT, "2"}, + {token.PERCENT, "%"}, + {token.IDENT, "sprigs"}, + {token.RBRACE, "}"}, + {token.EOF, ""}, + }, + }, + { + name: "optional ingredient inline", + input: "Add @?parsley for garnish", + expectedTokens: []struct { + tokenType token.TokenType + literal string + }{ + {token.IDENT, "Add"}, + {token.WHITESPACE, " "}, + {token.OPTIONAL_INGREDIENT, "@?"}, + {token.IDENT, "parsley"}, + {token.WHITESPACE, " "}, + {token.IDENT, "for"}, + {token.WHITESPACE, " "}, + {token.IDENT, "garnish"}, + {token.EOF, ""}, + }, + }, + { + name: "@? not followed by identifier - treated as text", + input: "Use @? as placeholder", + expectedTokens: []struct { + tokenType token.TokenType + literal string + }{ + {token.IDENT, "Use"}, + {token.WHITESPACE, " "}, + {token.ILLEGAL, "@"}, + {token.ILLEGAL, "?"}, + {token.WHITESPACE, " "}, + {token.IDENT, "as"}, + {token.WHITESPACE, " "}, + {token.IDENT, "placeholder"}, + {token.EOF, ""}, + }, + }, + { + name: "@? at end of input", + input: "test @?", + expectedTokens: []struct { + tokenType token.TokenType + literal string + }{ + {token.IDENT, "test"}, + {token.WHITESPACE, " "}, + {token.ILLEGAL, "@"}, + {token.ILLEGAL, "?"}, + {token.EOF, ""}, + }, + }, + { + name: "mixed regular and optional ingredients", + input: "@flour{500%g} and @?herbs{}", + expectedTokens: []struct { + tokenType token.TokenType + literal string + }{ + {token.INGREDIENT, "@"}, + {token.IDENT, "flour"}, + {token.LBRACE, "{"}, + {token.IDENT, "500"}, + {token.PERCENT, "%"}, + {token.IDENT, "g"}, + {token.RBRACE, "}"}, + {token.WHITESPACE, " "}, + {token.IDENT, "and"}, + {token.WHITESPACE, " "}, + {token.OPTIONAL_INGREDIENT, "@?"}, + {token.IDENT, "herbs"}, + {token.LBRACE, "{"}, + {token.RBRACE, "}"}, + {token.EOF, ""}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l := New(tt.input) + for i, expected := range tt.expectedTokens { + tok := l.NextToken() + if tok.Type != expected.tokenType { + t.Errorf("token[%d]: expected type %s, got %s", i, expected.tokenType, tok.Type) + } + if tok.Literal != expected.literal { + t.Errorf("token[%d]: expected literal %q, got %q", i, expected.literal, tok.Literal) + } + } + }) + } +} diff --git a/parser/parser.go b/parser/parser.go index 9af2d30..a00df4b 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -30,7 +30,8 @@ type Component struct { Name string `json:"name,omitempty" yaml:"name,omitempty"` Quantity string `json:"quantity,omitempty" yaml:"quantity,omitempty"` Unit string `json:"unit,omitempty" yaml:"units,omitempty"` - Fixed bool `json:"fixed,omitempty" yaml:"fixed,omitempty"` // Fixed quantity doesn't scale with servings + Fixed bool `json:"fixed,omitempty" yaml:"fixed,omitempty"` // Fixed quantity doesn't scale with servings + Optional bool `json:"optional,omitempty" yaml:"optional,omitempty"` // Optional ingredient } // CooklangParser handles parsing of cooklang recipes @@ -113,11 +114,14 @@ func (p *CooklangParser) parseTokens(l *lexer.Lexer) (*Recipe, error) { } // Process the next token immediately here switch nextTok.Type { - case token.INGREDIENT: + case token.INGREDIENT, token.OPTIONAL_INGREDIENT: ingredient, err := p.parseIngredient(l) if err != nil { return nil, fmt.Errorf("failed to parse ingredient: %w", err) } + if nextTok.Type == token.OPTIONAL_INGREDIENT { + ingredient.Optional = true + } currentStep.Components = append(currentStep.Components, ingredient) case token.COOKWARE: cookware, err := p.parseCookware(l) @@ -240,12 +244,15 @@ func (p *CooklangParser) parseTokens(l *lexer.Lexer) (*Recipe, error) { recipe.Steps = append(recipe.Steps, currentStep) currentStep = Step{Components: []Component{}} - case token.INGREDIENT: + case token.INGREDIENT, token.OPTIONAL_INGREDIENT: // Parse ingredient ingredient, err := p.parseIngredient(l) if err != nil { return nil, fmt.Errorf("failed to parse ingredient: %w", err) } + if tok.Type == token.OPTIONAL_INGREDIENT { + ingredient.Optional = true + } currentStep.Components = append(currentStep.Components, ingredient) case token.COOKWARE: diff --git a/parser/parser_test.go b/parser/parser_test.go index ab89611..6f25cc0 100644 --- a/parser/parser_test.go +++ b/parser/parser_test.go @@ -1321,3 +1321,113 @@ Cook for ~{10%minutes}.` t.Error("Expected to find cooking note") } } + +// TestOptionalIngredient tests parsing of optional ingredients with @? syntax +func TestOptionalIngredient(t *testing.T) { + tests := []struct { + name string + input string + expected []Component + }{ + { + name: "simple optional ingredient with quantity", + input: "Add @?thyme{2%sprigs} if desired.", + expected: []Component{ + {Type: "text", Value: "Add "}, + {Type: "ingredient", Name: "thyme", Quantity: "2", Unit: "sprigs", Optional: true}, + {Type: "text", Value: " if desired."}, + }, + }, + { + name: "optional ingredient without braces", + input: "Garnish with @?parsley.", + expected: []Component{ + {Type: "text", Value: "Garnish with "}, + {Type: "ingredient", Name: "parsley", Quantity: "some", Optional: true}, + {Type: "text", Value: "."}, + }, + }, + { + name: "optional ingredient with empty braces", + input: "Add @?herbs{} for flavor.", + expected: []Component{ + {Type: "text", Value: "Add "}, + {Type: "ingredient", Name: "herbs", Quantity: "some", Optional: true}, + {Type: "text", Value: " for flavor."}, + }, + }, + { + name: "optional and fixed combined", + input: "Add @?salt{=1%pinch} to taste.", + expected: []Component{ + {Type: "text", Value: "Add "}, + {Type: "ingredient", Name: "salt", Quantity: "1", Unit: "pinch", Optional: true, Fixed: true}, + {Type: "text", Value: " to taste."}, + }, + }, + { + name: "mixed regular and optional ingredients", + input: "Mix @flour{500%g} with @?herbs{}.", + expected: []Component{ + {Type: "text", Value: "Mix "}, + {Type: "ingredient", Name: "flour", Quantity: "500", Unit: "g"}, + {Type: "text", Value: " with "}, + {Type: "ingredient", Name: "herbs", Quantity: "some", Optional: true}, + {Type: "text", Value: "."}, + }, + }, + { + name: "optional ingredient with multi-word name", + input: "Add @?fresh thyme{1%sprig} for aroma.", + expected: []Component{ + {Type: "text", Value: "Add "}, + {Type: "ingredient", Name: "fresh thyme", Quantity: "1", Unit: "sprig", Optional: true}, + {Type: "text", Value: " for aroma."}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := New() + recipe, err := p.ParseString(tt.input) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if len(recipe.Steps) == 0 { + t.Fatal("Expected at least one step") + } + + step := recipe.Steps[0] + if len(step.Components) != len(tt.expected) { + t.Fatalf("Expected %d components, got %d: %+v", len(tt.expected), len(step.Components), step.Components) + } + + for i, expected := range tt.expected { + actual := step.Components[i] + if actual.Type != expected.Type { + t.Errorf("Component %d: expected type %q, got %q", i, expected.Type, actual.Type) + } + if actual.Name != expected.Name { + t.Errorf("Component %d: expected name %q, got %q", i, expected.Name, actual.Name) + } + if actual.Value != expected.Value { + t.Errorf("Component %d: expected value %q, got %q", i, expected.Value, actual.Value) + } + if actual.Quantity != expected.Quantity { + t.Errorf("Component %d: expected quantity %q, got %q", i, expected.Quantity, actual.Quantity) + } + if actual.Unit != expected.Unit { + t.Errorf("Component %d: expected unit %q, got %q", i, expected.Unit, actual.Unit) + } + if actual.Optional != expected.Optional { + t.Errorf("Component %d: expected optional %v, got %v", i, expected.Optional, actual.Optional) + } + if actual.Fixed != expected.Fixed { + t.Errorf("Component %d: expected fixed %v, got %v", i, expected.Fixed, actual.Fixed) + } + } + }) + } +} diff --git a/renderers/html.go b/renderers/html.go index e3394fa..121bc38 100644 --- a/renderers/html.go +++ b/renderers/html.go @@ -148,11 +148,18 @@ func (hr HTMLRenderer) RenderRecipe(recipe *cooklang.Recipe) string { func (hr HTMLRenderer) renderComponent(result *strings.Builder, currentComponent cooklang.StepComponent) { switch comp := currentComponent.(type) { case *cooklang.Ingredient: + ingredientClass := "ingredient" + if comp.Optional { + ingredientClass = "ingredient optional" + } if comp.Quantity > 0 { - fmt.Fprintf(result, "%s (%g %s)", - html.EscapeString(comp.Name), comp.Quantity, html.EscapeString(comp.Unit)) + fmt.Fprintf(result, "%s (%g %s)", + ingredientClass, html.EscapeString(comp.Name), comp.Quantity, html.EscapeString(comp.Unit)) } else { - fmt.Fprintf(result, "%s", html.EscapeString(comp.Name)) + fmt.Fprintf(result, "%s", ingredientClass, html.EscapeString(comp.Name)) + } + if comp.Optional { + result.WriteString(" (optional)") } if comp.Annotation != "" { fmt.Fprintf(result, " (%s)", html.EscapeString(comp.Annotation)) diff --git a/renderers/markdown.go b/renderers/markdown.go index bdabf53..80a7f61 100644 --- a/renderers/markdown.go +++ b/renderers/markdown.go @@ -97,21 +97,25 @@ func (mr MarkdownRenderer) RenderRecipe(recipe *cooklang.Recipe) string { for _, ingredient := range ingredients.Ingredients { result.WriteString("- ") + optionalSuffix := "" + if ingredient.Optional { + optionalSuffix = " *(optional)*" + } if ingredient.Quantity > 0 { if ingredient.Unit != "" { - result.WriteString(fmt.Sprintf("**%g %s** %s\n", ingredient.Quantity, ingredient.Unit, ingredient.Name)) + result.WriteString(fmt.Sprintf("**%g %s** %s%s\n", ingredient.Quantity, ingredient.Unit, ingredient.Name, optionalSuffix)) } else { - result.WriteString(fmt.Sprintf("**%g** %s\n", ingredient.Quantity, ingredient.Name)) + result.WriteString(fmt.Sprintf("**%g** %s%s\n", ingredient.Quantity, ingredient.Name, optionalSuffix)) } } else if ingredient.Quantity == -1 { // "some" quantity if ingredient.Unit != "" { - result.WriteString(fmt.Sprintf("**some %s** %s\n", ingredient.Unit, ingredient.Name)) + result.WriteString(fmt.Sprintf("**some %s** %s%s\n", ingredient.Unit, ingredient.Name, optionalSuffix)) } else { - result.WriteString(fmt.Sprintf("**some** %s\n", ingredient.Name)) + result.WriteString(fmt.Sprintf("**some** %s%s\n", ingredient.Name, optionalSuffix)) } } else { - result.WriteString(fmt.Sprintf("%s\n", ingredient.Name)) + result.WriteString(fmt.Sprintf("%s%s\n", ingredient.Name, optionalSuffix)) } } result.WriteString("\n") @@ -177,6 +181,9 @@ func (mr MarkdownRenderer) renderComponent(result *strings.Builder, currentCompo if comp.Annotation != "" { fmt.Fprintf(result, " (%s)", comp.Annotation) } + if comp.Optional { + result.WriteString(" *(optional)*") + } case *cooklang.Cookware: if comp.Quantity > 1 { fmt.Fprintf(result, "*%s* (x%d)", comp.Name, comp.Quantity) diff --git a/renderers/print.go b/renderers/print.go index 42e359b..cea8f34 100644 --- a/renderers/print.go +++ b/renderers/print.go @@ -188,6 +188,17 @@ const printCSS = ` font-size: 10pt; } + .optional { + font-style: italic; + color: #666; + } + + .optional-marker { + font-size: 9pt; + color: #888; + font-style: italic; + } + .recipe-footer { margin-top: 1em; padding-top: 0.5em; @@ -301,12 +312,19 @@ func (pr PrintRenderer) RenderRecipe(recipe *cooklang.Recipe) string { if len(ingredients.Ingredients) > 0 { result.WriteString(" \n") @@ -325,11 +343,18 @@ func (pr PrintRenderer) RenderRecipe(recipe *cooklang.Recipe) string { for currentComponent != nil { switch comp := currentComponent.(type) { case *cooklang.Ingredient: - result.WriteString(fmt.Sprintf("%s", html.EscapeString(comp.Name))) + optionalClass := "" + if comp.Optional { + optionalClass = " optional" + } + result.WriteString(fmt.Sprintf("%s", optionalClass, html.EscapeString(comp.Name))) if comp.Quantity > 0 { qtyStr := pr.formatQuantity(comp.Quantity, comp.Unit) result.WriteString(fmt.Sprintf(" (%s)", qtyStr)) } + if comp.Optional { + result.WriteString(" (optional)") + } case *cooklang.Cookware: result.WriteString(fmt.Sprintf("%s", html.EscapeString(comp.Name))) case *cooklang.Timer: diff --git a/spec/canonical_extensions.yaml b/spec/canonical_extensions.yaml index bf67454..1dc48e2 100644 --- a/spec/canonical_extensions.yaml +++ b/spec/canonical_extensions.yaml @@ -322,3 +322,115 @@ tests: - type: text value: " to the mix." metadata: {} + + # ========================================== + # Optional Ingredients @? + # ========================================== + + testOptionalIngredientSimple: + source: | + Add @?thyme{2%sprigs} if desired. + result: + steps: + - + - type: text + value: "Add " + - type: ingredient + name: "thyme" + quantity: 2 + units: "sprigs" + optional: true + - type: text + value: " if desired." + metadata: {} + + testOptionalIngredientNoQuantity: + source: | + Garnish with @?parsley. + result: + steps: + - + - type: text + value: "Garnish with " + - type: ingredient + name: "parsley" + quantity: "some" + units: "" + optional: true + - type: text + value: "." + metadata: {} + + testOptionalIngredientWithFixed: + source: | + Add @?salt{=1%pinch} to taste. + result: + steps: + - + - type: text + value: "Add " + - type: ingredient + name: "salt" + quantity: 1 + units: "pinch" + optional: true + fixed: true + - type: text + value: " to taste." + metadata: {} + + testOptionalAndRegularMixed: + source: | + Mix @flour{500%g} with @water{300%ml} and @?herbs{}. + result: + steps: + - + - type: text + value: "Mix " + - type: ingredient + name: "flour" + quantity: 500 + units: "g" + - type: text + value: " with " + - type: ingredient + name: "water" + quantity: 300 + units: "ml" + - type: text + value: " and " + - type: ingredient + name: "herbs" + quantity: "some" + units: "" + optional: true + - type: text + value: "." + metadata: {} + + testOptionalIngredientMultiWord: + source: | + Add @?fresh thyme{1%sprig} for aroma. + result: + steps: + - + - type: text + value: "Add " + - type: ingredient + name: "fresh thyme" + quantity: 1 + units: "sprig" + optional: true + - type: text + value: " for aroma." + metadata: {} + + testOptionalIngredientAsText: + source: | + Use @? as a placeholder. + result: + steps: + - + - type: text + value: "Use @? as a placeholder." + metadata: {} diff --git a/token/token.go b/token/token.go index af30896..286ba6b 100644 --- a/token/token.go +++ b/token/token.go @@ -25,9 +25,10 @@ const ( IDENT = "IDENT" INT = "INT" - COOKTIME = "~" - COOKWARE = "#" - INGREDIENT = "@" + COOKTIME = "~" + COOKWARE = "#" + INGREDIENT = "@" + OPTIONAL_INGREDIENT = "@?" // Delimiters COMMA = ","