Skip to content

Commit b0c4c68

Browse files
authored
feat: implement sections and improve comment rendering (#13)
- Add SECTION_HEADER token for parsing section headers (= Section Name, == Name ==) - Add Section and Comment types to cooklang.go with StepComponent interface - Update parser to handle section headers and create section components - Update ToCooklangRecipe to convert section and comment components - Update HTML renderer: sections as <h3>, comments as styled spans, use fmt.Fprintf - Update Markdown renderer: sections as ### headings, comments as italicized text - Add comprehensive tests for section lexing and parsing - Support various section formats: = Name, == Name, == Name ==, === Name === - Sections properly reset step numbering in rendered output
1 parent 1e37ca4 commit b0c4c68

11 files changed

Lines changed: 709 additions & 89 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,4 @@ go.work.sum
2323

2424
# env file
2525
.env
26+
bin

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,46 @@ Toss everything together and serve.`
5959

6060
See the [Cooklang specification](https://github.com/cooklang/spec/) for details.
6161

62+
### Extended Syntax
63+
64+
This parser supports additional syntax extensions beyond the base Cooklang specification:
65+
66+
#### Ingredient Annotations
67+
68+
Ingredients can have annotations in parentheses to specify preparation notes or state:
69+
70+
```cooklang
71+
@milk{1%l}(cold)
72+
```
73+
74+
The annotation `(cold)` is stored as the ingredient's `value` field.
75+
76+
#### Cookware Annotations
77+
78+
Similarly, cookware items can have annotations for usage hints:
79+
80+
```cooklang
81+
#pan{}(for frying)
82+
```
83+
84+
#### Named Timers
85+
86+
Timers can have descriptive multi-word names:
87+
88+
```cooklang
89+
~roast time{4%hours}
90+
```
91+
92+
#### Comment Preservation
93+
94+
Comments are preserved as a distinct type in the parsed output rather than being discarded:
95+
96+
```cooklang
97+
-- This is a comment
98+
```
99+
100+
Comments are accessible with `type: comment` and their text in the `value` field.
101+
62102
## Developing
63103

64104
### Prerequisites (Well, not really)

Taskfile.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,4 @@ tasks:
2424

2525
cli:
2626
cmds:
27-
- go build -o cook ./cmd/cook
27+
- go build -o ./bin/cook ./cmd/cook

cooklang.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,8 @@ func (Instruction) isStepComponent() {}
217217
func (Timer) isStepComponent() {}
218218
func (Cookware) isStepComponent() {}
219219
func (Ingredient) isStepComponent() {}
220+
func (Section) isStepComponent() {}
221+
func (Comment) isStepComponent() {}
220222

221223
// Render returns the Cooklang syntax representation of this ingredient.
222224
// Examples: "@flour{500%g}", "@salt{}", "@milk{2%cups}(cold)"
@@ -319,6 +321,34 @@ func (c Cookware) RenderDisplay() string {
319321
return c.Name
320322
}
321323

324+
// Render returns the Cooklang syntax representation of this section.
325+
// Examples: "== Section Name =="
326+
func (s Section) Render() string {
327+
if s.Name != "" {
328+
return fmt.Sprintf("== %s ==", s.Name)
329+
}
330+
return "=="
331+
}
332+
333+
// RenderDisplay returns section name suitable for display.
334+
func (s Section) RenderDisplay() string {
335+
return s.Name
336+
}
337+
338+
// Render returns the Cooklang syntax representation of this comment.
339+
// Examples: "-- comment text" for line comments, "[- comment text -]" for block comments
340+
func (cm Comment) Render() string {
341+
if cm.IsBlock {
342+
return fmt.Sprintf("[- %s -]", cm.Text)
343+
}
344+
return fmt.Sprintf("-- %s", cm.Text)
345+
}
346+
347+
// RenderDisplay returns comment text suitable for display.
348+
func (cm Comment) RenderDisplay() string {
349+
return cm.Text
350+
}
351+
322352
// Ingredient represents a recipe ingredient with quantity, unit, and optional annotations.
323353
// Ingredients support unit conversion and consolidation for shopping lists.
324354
//
@@ -392,6 +422,29 @@ type Cookware struct {
392422
CooklangRenderable
393423
}
394424

425+
// Section represents a section header in a recipe.
426+
// Sections divide complex recipes into logical parts (e.g., "Dough", "Filling").
427+
//
428+
// Example Cooklang syntax: = Dough, == Filling ==
429+
type Section struct {
430+
Name string `json:"name,omitempty"` // Section name (e.g., "Dough", "Filling")
431+
NextComponent StepComponent `json:"next_component,omitempty"` // Next component in the step
432+
CooklangRenderable
433+
}
434+
435+
// Comment represents a comment in a recipe.
436+
// Comments are notes that don't affect the cooking instructions.
437+
//
438+
// Example Cooklang syntax:
439+
// - Line comment: -- This is a comment
440+
// - Block comment: [- This is a block comment -]
441+
type Comment struct {
442+
Text string `json:"text,omitempty"` // Comment text
443+
IsBlock bool `json:"is_block,omitempty"` // True if this is a block comment [- -]
444+
NextComponent StepComponent `json:"next_component,omitempty"` // Next component in the step
445+
CooklangRenderable
446+
}
447+
395448
// ParseFile reads and parses a Cooklang recipe file, returning a Recipe object.
396449
// It automatically detects and includes associated image files matching the recipe filename.
397450
//
@@ -692,6 +745,20 @@ func ToCooklangRecipe(pRecipe *parser.Recipe) *Recipe {
692745
stepComp = &Instruction{
693746
Text: component.Value,
694747
}
748+
case "section":
749+
stepComp = &Section{
750+
Name: component.Name,
751+
}
752+
case "comment":
753+
stepComp = &Comment{
754+
Text: component.Value,
755+
IsBlock: false,
756+
}
757+
case "blockComment":
758+
stepComp = &Comment{
759+
Text: component.Value,
760+
IsBlock: true,
761+
}
695762
}
696763

697764
if stepComp != nil {
@@ -959,6 +1026,24 @@ func (c *Cookware) GetNext() StepComponent {
9591026
return c.NextComponent
9601027
}
9611028

1029+
// SetNext and GetNext methods for Section
1030+
func (s *Section) SetNext(next StepComponent) {
1031+
s.NextComponent = next
1032+
}
1033+
1034+
func (s *Section) GetNext() StepComponent {
1035+
return s.NextComponent
1036+
}
1037+
1038+
// SetNext and GetNext methods for Comment
1039+
func (cm *Comment) SetNext(next StepComponent) {
1040+
cm.NextComponent = next
1041+
}
1042+
1043+
func (cm *Comment) GetNext() StepComponent {
1044+
return cm.NextComponent
1045+
}
1046+
9621047
// IngredientList represents a collection of ingredients with unit consolidation capabilities.
9631048
// It provides methods for grouping, converting, and consolidating ingredients for shopping lists
9641049
// and recipe scaling operations.

lexer/lexer.go

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,11 @@ func (l *Lexer) NextToken() token.Token {
8080
l.readChar() // consume \n in CRLF sequence
8181
}
8282
return token.Token{Type: token.NEWLINE, Literal: "\n"}
83-
case '=': // = or ==
83+
case '=': // Section header
84+
// Check if this is at the start of a line (section header)
85+
if l.position == 0 || (l.position > 0 && (l.input[l.position-1] == '\n' || l.input[l.position-1] == '\r')) {
86+
return l.readSectionHeader()
87+
}
8488
tok = newToken(token.SECTION, l.ch)
8589
case '-':
8690
if l.peekChar() == '-' {
@@ -397,3 +401,62 @@ func (l *Lexer) readBlockComment() token.Token {
397401
Literal: strings.TrimSpace(commentContent),
398402
}
399403
}
404+
405+
// readSectionHeader reads a section header starting with = and containing an optional name
406+
// Formats: "= Dough", "== Filling ==", "=== Section Name ==="
407+
// The number of = symbols doesn't matter, and trailing = symbols are optional
408+
func (l *Lexer) readSectionHeader() token.Token {
409+
// Skip leading = characters
410+
for l.ch == '=' {
411+
l.readChar()
412+
}
413+
414+
// Skip whitespace after leading =
415+
for l.ch == ' ' || l.ch == '\t' {
416+
l.readChar()
417+
}
418+
419+
// Read the section name until we hit trailing = or end of line
420+
start := l.position
421+
for l.ch != '\n' && l.ch != '\r' && l.ch != 0 {
422+
// Check if we're hitting trailing = characters
423+
if l.ch == '=' {
424+
// Check if rest of line is just = and whitespace
425+
allEquals := true
426+
for i := l.position; i < len(l.input); i++ {
427+
ch := l.input[i]
428+
if ch == '\n' || ch == '\r' {
429+
break
430+
}
431+
if ch != '=' && ch != ' ' && ch != '\t' {
432+
allEquals = false
433+
break
434+
}
435+
}
436+
if allEquals {
437+
break
438+
}
439+
}
440+
l.readChar()
441+
}
442+
443+
// Extract the section name
444+
sectionName := strings.TrimSpace(l.input[start:l.position])
445+
446+
// Skip trailing = characters
447+
for l.ch == '=' {
448+
l.readChar()
449+
}
450+
451+
// Skip trailing whitespace
452+
for l.ch == ' ' || l.ch == '\t' {
453+
l.readChar()
454+
}
455+
456+
// Don't consume the newline - let normal token processing handle it
457+
458+
return token.Token{
459+
Type: token.SECTION_HEADER,
460+
Literal: sectionName,
461+
}
462+
}

0 commit comments

Comments
 (0)