Skip to content

Commit 1b2e874

Browse files
authored
feat: Aiming for Cooklang feature completenes (#15)
* feat: implement note blocks per Cooklang spec proposal Add support for note blocks using the > syntax at start of lines. Notes are supplementary information that appears in recipe details but not during cooking mode. Syntax: > This is a single-line note. > This is a multi-line note > that continues on multiple lines. Implementation: - Add NOTE token type to token/token.go - Implement readNote() in lexer to handle > at start of line - Add Note struct with StepComponent interface methods - Update parser to handle NOTE tokens (creates separate step) - Update ToCooklangRecipe to convert note components - Update HTML and Markdown renderers to render notes as blockquotes - Add comprehensive tests for note lexing Per spec proposal: https://github.com/cooklang/spec/blob/main/proposals/0005-note-blocks.md * test: add comprehensive tests for comments, sections, and notes - Add lexer tests for single-line comments (-- comment) - Add parser tests for notes (TestNoteParsing, TestMultipleNotes, TestNoteWithSections) - Fix parser to handle NOTE tokens after newlines (was falling through to default case) This ensures notes work correctly when appearing after section headers or other block-level elements. * feat: add canonical_extensions.yaml spec tests for block comments, sections, and notes Add comprehensive YAML test spec file covering: - Block comments [- comment -] - Sections (= Section Name =) - Notes (> note text) - Combined extended features These tests document the current parser behavior for extended Cooklang syntax features from the official spec's Advanced section. * feat: implement fixed quantities for ingredients (=prefix) - Add Fixed field to Component struct (parser) and Ingredient struct - Update parseQuantityAndUnit to detect leading '=' and set isFixed flag - Update ToCooklangRecipe to propagate Fixed field to Ingredient - Update Ingredient.Render() to output '=' prefix for fixed quantities - Add spec tests for fixed quantities in canonical_extensions.yaml - Fix flaky example test by sorting ingredients for deterministic output Fixed quantities (e.g., @salt{=1%tsp}) indicate amounts that should not scale when adjusting recipe servings, as documented in the Cooklang spec. * test: add unit tests for token package - TestLookupIdent: verify keyword lookup for @, #, ~ and fallback to IDENT - TestTokenType: ensure all token type constants are unique and non-empty - TestToken: basic struct instantiation test
1 parent 937d2b1 commit 1b2e874

12 files changed

Lines changed: 961 additions & 18 deletions

File tree

cooklang.go

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,13 +219,18 @@ func (Cookware) isStepComponent() {}
219219
func (Ingredient) isStepComponent() {}
220220
func (Section) isStepComponent() {}
221221
func (Comment) isStepComponent() {}
222+
func (Note) isStepComponent() {}
222223

223224
// Render returns the Cooklang syntax representation of this ingredient.
224-
// Examples: "@flour{500%g}", "@salt{}", "@milk{2%cups}(cold)"
225+
// Examples: "@flour{500%g}", "@salt{}", "@milk{2%cups}(cold)", "@yeast{=1%packet}"
225226
func (i Ingredient) Render() string {
226227
var result string
228+
fixedPrefix := ""
229+
if i.Fixed {
230+
fixedPrefix = "="
231+
}
227232
if i.Quantity > 0 {
228-
result = fmt.Sprintf("@%s{%g%%%s}", i.Name, i.Quantity, i.Unit)
233+
result = fmt.Sprintf("@%s{%s%g%%%s}", i.Name, fixedPrefix, i.Quantity, i.Unit)
229234
} else if i.Quantity == -1 {
230235
// -1 indicates "some" quantity
231236
result = fmt.Sprintf("@%s{}", i.Name)
@@ -349,16 +354,29 @@ func (cm Comment) RenderDisplay() string {
349354
return cm.Text
350355
}
351356

357+
// Render returns the Cooklang syntax representation of this note.
358+
// Example: "> This is a note"
359+
func (n Note) Render() string {
360+
return fmt.Sprintf("> %s", n.Text)
361+
}
362+
363+
// RenderDisplay returns note text suitable for display.
364+
func (n Note) RenderDisplay() string {
365+
return n.Text
366+
}
367+
352368
// Ingredient represents a recipe ingredient with quantity, unit, and optional annotations.
353369
// Ingredients support unit conversion and consolidation for shopping lists.
354370
//
355371
// Example Cooklang syntax: @flour{500%g}, @salt{}, @milk{2%cups}
356372
//
357373
// The Quantity field uses -1 to represent "some" (unspecified amount).
374+
// The Fixed field indicates a quantity that should not scale with servings (e.g., @salt{=1%tsp}).
358375
type Ingredient struct {
359376
Name string `json:"name,omitempty"` // Ingredient name (e.g., "flour", "sugar")
360377
Quantity float32 `json:"quantity,omitempty"` // Amount (-1 means "some", 0 means none specified)
361378
Unit string `json:"unit,omitempty"` // Unit of measurement (e.g., "g", "cup", "tbsp")
379+
Fixed bool `json:"fixed,omitempty"` // Fixed quantity doesn't scale with servings
362380
TypedUnit *units.Unit `json:"typed_unit,omitempty"` // Typed unit for conversion operations
363381
Subinstruction string `json:"value,omitempty"` // Additional preparation instructions
364382
Annotation string `json:"annotation,omitempty"` // Optional annotation (e.g., "finely chopped")
@@ -445,6 +463,20 @@ type Comment struct {
445463
CooklangRenderable
446464
}
447465

466+
// Note represents a note block in a recipe.
467+
// Notes are supplementary information that appears in recipe details but not during cooking mode.
468+
// They are used for background stories, tips, or personal anecdotes related to the recipe.
469+
//
470+
// Example Cooklang syntax:
471+
// > This dish is even better the next day, after the flavors have melded overnight.
472+
// > This is a multi-line note
473+
// > that continues here.
474+
type Note struct {
475+
Text string `json:"text,omitempty"` // Note text
476+
NextComponent StepComponent `json:"next_component,omitempty"` // Next component in the step
477+
CooklangRenderable
478+
}
479+
448480
// ParseFile reads and parses a Cooklang recipe file, returning a Recipe object.
449481
// It automatically detects and includes associated image files matching the recipe filename.
450482
//
@@ -721,6 +753,7 @@ func ToCooklangRecipe(pRecipe *parser.Recipe) *Recipe {
721753
Name: component.Name,
722754
Quantity: quant,
723755
Unit: component.Unit,
756+
Fixed: component.Fixed,
724757
TypedUnit: CreateTypedUnit(component.Unit),
725758
Annotation: component.Value,
726759
}
@@ -759,6 +792,10 @@ func ToCooklangRecipe(pRecipe *parser.Recipe) *Recipe {
759792
Text: component.Value,
760793
IsBlock: true,
761794
}
795+
case "note":
796+
stepComp = &Note{
797+
Text: component.Value,
798+
}
762799
}
763800

764801
if stepComp != nil {
@@ -1044,6 +1081,15 @@ func (cm *Comment) GetNext() StepComponent {
10441081
return cm.NextComponent
10451082
}
10461083

1084+
// SetNext and GetNext methods for Note
1085+
func (n *Note) SetNext(next StepComponent) {
1086+
n.NextComponent = next
1087+
}
1088+
1089+
func (n *Note) GetNext() StepComponent {
1090+
return n.NextComponent
1091+
}
1092+
10471093
// IngredientList represents a collection of ingredients with unit consolidation capabilities.
10481094
// It provides methods for grouping, converting, and consolidating ingredients for shopping lists
10491095
// and recipe scaling operations.

example_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,11 @@ Second layer: @cheese{150%g} and @tomato{3}.`
218218
log.Fatal(err)
219219
}
220220

221+
// Sort ingredients by name for consistent output
222+
sort.Slice(collected.Ingredients, func(i, j int) bool {
223+
return collected.Ingredients[i].Name < collected.Ingredients[j].Name
224+
})
225+
221226
fmt.Println("Shopping list:")
222227
for _, ing := range collected.Ingredients {
223228
if ing.Unit != "" {

lexer/lexer.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ func (l *Lexer) NextToken() token.Token {
8686
return l.readSectionHeader()
8787
}
8888
tok = newToken(token.SECTION, l.ch)
89+
case '>': // Note block
90+
// Check if this is at the start of a line (note block)
91+
if l.position == 0 || (l.position > 0 && (l.input[l.position-1] == '\n' || l.input[l.position-1] == '\r')) {
92+
return l.readNote()
93+
}
94+
// Otherwise treat as regular text
95+
tok = newToken(token.ILLEGAL, l.ch)
8996
case '-':
9097
if l.peekChar() == '-' {
9198
if l.peekCharAt(1) == '-' {
@@ -460,3 +467,73 @@ func (l *Lexer) readSectionHeader() token.Token {
460467
Literal: sectionName,
461468
}
462469
}
470+
471+
// readNote reads a note block starting with > and continuing until a blank line
472+
// Notes can span multiple lines, each optionally starting with >
473+
// Per spec: all > markers are stripped from the output
474+
func (l *Lexer) readNote() token.Token {
475+
var noteContent strings.Builder
476+
477+
for {
478+
// Skip the > character at start of line
479+
if l.ch == '>' {
480+
l.readChar()
481+
}
482+
483+
// Skip whitespace after >
484+
for l.ch == ' ' || l.ch == '\t' {
485+
l.readChar()
486+
}
487+
488+
// Read content until end of line
489+
lineStart := l.position
490+
for l.ch != '\n' && l.ch != '\r' && l.ch != 0 {
491+
l.readChar()
492+
}
493+
494+
// Add the line content
495+
lineContent := l.input[lineStart:l.position]
496+
if noteContent.Len() > 0 && len(lineContent) > 0 {
497+
noteContent.WriteString(" ") // Join lines with space
498+
}
499+
noteContent.WriteString(lineContent)
500+
501+
// Check for newline
502+
switch l.ch {
503+
case '\r':
504+
l.readChar() // consume \r
505+
if l.ch == '\n' {
506+
l.readChar() // consume \n in CRLF
507+
}
508+
case '\n':
509+
l.readChar() // consume \n
510+
}
511+
512+
// Check if next line continues the note (starts with > or is non-blank continuation)
513+
// A blank line ends the note
514+
if l.ch == 0 {
515+
break // EOF
516+
}
517+
518+
// Check for blank line (end of note)
519+
if l.ch == '\n' || l.ch == '\r' {
520+
break // Blank line ends the note
521+
}
522+
523+
// If next line starts with >, continue reading the note
524+
if l.ch == '>' {
525+
continue
526+
}
527+
528+
// Per spec: lines can continue without > too (until blank line)
529+
// But we need to check if it's actually content or another block type
530+
// For simplicity, only continue if line starts with > (stricter interpretation)
531+
// This avoids consuming regular recipe steps as part of notes
532+
break
533+
}
534+
535+
return token.Token{
536+
Type: token.NOTE,
537+
Literal: strings.TrimSpace(noteContent.String()),
538+
}
539+
}

0 commit comments

Comments
 (0)