big tings
This commit is contained in:
176
template/blocks.go
Normal file
176
template/blocks.go
Normal file
@@ -0,0 +1,176 @@
|
||||
// Package template contains the logic for parsing template blocks.
|
||||
package template
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// TemplateType represents whether a template is short, long, or invalid.
|
||||
type TemplateType int
|
||||
|
||||
const (
|
||||
InvalidTemplate TemplateType = iota
|
||||
ShortTemplate
|
||||
ExtendedTemplate
|
||||
)
|
||||
|
||||
// DetectTemplateType checks if the template is short or long.
|
||||
func DetectTemplateType(tmpl string) TemplateType {
|
||||
trimmed := strings.TrimSpace(tmpl)
|
||||
|
||||
// Short type: starts with "{" and ends with "}" on a single line,
|
||||
// and contains "|" or "," inside for inline definition
|
||||
// Matchs for example { name | text,required }
|
||||
if strings.HasPrefix(trimmed, "{") &&
|
||||
strings.HasSuffix(trimmed, "}") &&
|
||||
!strings.Contains(trimmed, "\n") {
|
||||
return ShortTemplate
|
||||
}
|
||||
|
||||
// Long type: multiline and contains keys like "path:" or "codec:" inside
|
||||
// Matches for example:
|
||||
// {
|
||||
// path: name
|
||||
// codec: text
|
||||
// required: true
|
||||
// }
|
||||
if strings.Contains(trimmed, "\n") &&
|
||||
(strings.Contains(trimmed, "path:") || strings.Contains(trimmed, "codec:")) {
|
||||
return ExtendedTemplate
|
||||
}
|
||||
|
||||
return InvalidTemplate
|
||||
}
|
||||
|
||||
func cleanTemplate(input string) string {
|
||||
s := strings.TrimSpace(input)
|
||||
s = strings.TrimPrefix(s, "{")
|
||||
s = strings.TrimSuffix(s, "}")
|
||||
s = strings.Trim(s, "\n")
|
||||
return s
|
||||
}
|
||||
|
||||
func parseShortTemplate(input string) (Block, error) {
|
||||
split := strings.Split(cleanTemplate(input), "|")
|
||||
if len(split) < 1 {
|
||||
return Block{}, fmt.Errorf("invalid short template")
|
||||
}
|
||||
|
||||
block := Block{
|
||||
Type: DataBlock,
|
||||
Path: strings.TrimSpace(split[0]),
|
||||
Codec: CodecText,
|
||||
content: input,
|
||||
}
|
||||
|
||||
if len(split) > 1 {
|
||||
optionSplit := strings.SplitSeq(split[1], ",")
|
||||
for option := range optionSplit {
|
||||
switch strings.TrimSpace(option) {
|
||||
case "required":
|
||||
block.Required = true
|
||||
case "number":
|
||||
block.Codec = CodecNumber
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return block, nil
|
||||
}
|
||||
|
||||
type yamlBlock struct {
|
||||
Path string `yaml:"path"`
|
||||
Codec string `yaml:"codec"`
|
||||
Required bool `yaml:"required,omitempty"`
|
||||
Value any `yaml:"value,omitempty"`
|
||||
Fields []yamlField `yaml:"fields"`
|
||||
ListTemplate string `yaml:"listTemplate,omitempty"`
|
||||
}
|
||||
|
||||
type yamlField struct {
|
||||
Path string `yaml:"path"`
|
||||
Value any `yaml:"value,omitempty"`
|
||||
Codec string `yaml:"codec"`
|
||||
Required bool `yaml:"required"`
|
||||
}
|
||||
|
||||
func parseYamlTemplate(input string) (block Block, err error) {
|
||||
var blk yamlBlock
|
||||
|
||||
cleaned := cleanTemplate(input)
|
||||
|
||||
dec := yaml.NewDecoder(strings.NewReader(cleaned))
|
||||
dec.KnownFields(true)
|
||||
|
||||
if err := dec.Decode(&blk); err != nil {
|
||||
return block, fmt.Errorf("content '%q': %w", cleaned, err)
|
||||
}
|
||||
|
||||
if blk.Path == "" {
|
||||
return block, fmt.Errorf("missing top-level 'path'")
|
||||
}
|
||||
|
||||
if blk.Codec == "" {
|
||||
blk.Codec = "text"
|
||||
}
|
||||
|
||||
codec, err := parseCodecType(blk.Codec)
|
||||
if err != nil {
|
||||
return block, fmt.Errorf("failed to parse codec: %w", err)
|
||||
}
|
||||
|
||||
var fields []BlockField
|
||||
|
||||
for _, field := range blk.Fields {
|
||||
if field.Path == "" {
|
||||
return block, fmt.Errorf("failed to parse field: %v", field)
|
||||
}
|
||||
|
||||
if field.Codec == "" {
|
||||
field.Codec = "text"
|
||||
}
|
||||
|
||||
fieldCodec, err := parseCodecType(field.Codec)
|
||||
if err != nil {
|
||||
return block, fmt.Errorf("failed to parse codec: %w", err)
|
||||
}
|
||||
|
||||
fields = append(fields, BlockField{
|
||||
Path: field.Path,
|
||||
CodecType: fieldCodec,
|
||||
Required: field.Required,
|
||||
Value: field.Value,
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
return Block{
|
||||
Type: DataBlock,
|
||||
Path: blk.Path,
|
||||
Codec: codec,
|
||||
Fields: fields,
|
||||
ListTemplate: blk.ListTemplate,
|
||||
content: input,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ParseTemplateBlock(template string, blockType BlockType) (block Block, err error) {
|
||||
if blockType == MatchingBlock {
|
||||
return Block{
|
||||
Type: MatchingBlock,
|
||||
content: template,
|
||||
}, nil
|
||||
}
|
||||
|
||||
switch DetectTemplateType(template) {
|
||||
case ShortTemplate:
|
||||
return parseShortTemplate(template)
|
||||
case ExtendedTemplate:
|
||||
return parseYamlTemplate(template)
|
||||
}
|
||||
|
||||
return block, fmt.Errorf("invalid template")
|
||||
}
|
30
template/codecs.go
Normal file
30
template/codecs.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package template
|
||||
|
||||
import "fmt"
|
||||
|
||||
// CodecType represents the type of codec used to encode/render a value
|
||||
type CodecType string
|
||||
|
||||
const (
|
||||
CodecText CodecType = "text"
|
||||
CodecNumber CodecType = "number"
|
||||
CodecYaml CodecType = "yaml"
|
||||
CodecList CodecType = "list"
|
||||
CodecConst CodecType = "const"
|
||||
)
|
||||
|
||||
func parseCodecType(input string) (CodecType, error) {
|
||||
switch input {
|
||||
case "number":
|
||||
return CodecNumber, nil
|
||||
case "yaml":
|
||||
return CodecYaml, nil
|
||||
case "list":
|
||||
return CodecList, nil
|
||||
case "text":
|
||||
return CodecText, nil
|
||||
case "const":
|
||||
return CodecConst, nil
|
||||
}
|
||||
return CodecText, fmt.Errorf("unknown codec: '%s'", input)
|
||||
}
|
75
template/compile.go
Normal file
75
template/compile.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// CompileTemplate scans once, emitting:
|
||||
// - data blocks: inner content between a line that's exactly "{" and a line that's exactly "}"
|
||||
// - matching blocks: gaps between data blocks (excluding the brace lines themselves)
|
||||
func CompileTemplate(template string) ([]Block, error) {
|
||||
var out []Block
|
||||
var curlyIndex int
|
||||
|
||||
const OPENING = '{'
|
||||
const CLOSING = '}'
|
||||
|
||||
var start int
|
||||
var blockType BlockType
|
||||
|
||||
if len(template) > 0 && template[0] == OPENING {
|
||||
blockType = DataBlock
|
||||
} else {
|
||||
blockType = MatchingBlock
|
||||
}
|
||||
|
||||
for i, r := range template {
|
||||
|
||||
nextCurlyIndex := curlyIndex
|
||||
|
||||
switch r {
|
||||
case OPENING:
|
||||
nextCurlyIndex++
|
||||
case CLOSING:
|
||||
nextCurlyIndex--
|
||||
}
|
||||
|
||||
if curlyIndex == 0 && nextCurlyIndex == 1 {
|
||||
if i > start {
|
||||
block, err := ParseTemplateBlock(template[start:i], blockType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse block: %w", err)
|
||||
}
|
||||
out = append(out, block)
|
||||
}
|
||||
start = i
|
||||
blockType = DataBlock
|
||||
} else if curlyIndex == 1 && nextCurlyIndex == 0 {
|
||||
|
||||
if i > start {
|
||||
block, err := ParseTemplateBlock(template[start:i+1], blockType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse block: %w", err)
|
||||
}
|
||||
out = append(out, block)
|
||||
}
|
||||
|
||||
nextChar := ' '
|
||||
if i+1 < len(template) {
|
||||
nextChar = rune(template[i+1])
|
||||
}
|
||||
|
||||
if nextChar == OPENING {
|
||||
start = i + 1
|
||||
blockType = DataBlock
|
||||
} else {
|
||||
start = i + 1
|
||||
blockType = MatchingBlock
|
||||
}
|
||||
}
|
||||
|
||||
curlyIndex = nextCurlyIndex
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
100
template/compile_test.go
Normal file
100
template/compile_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package template_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.max-richter.dev/max/marka/registry"
|
||||
"git.max-richter.dev/max/marka/template"
|
||||
)
|
||||
|
||||
func TestExtractBlocks(t *testing.T) {
|
||||
src, err := registry.GetTemplate("Recipe")
|
||||
if err != nil {
|
||||
t.Errorf("Failed to extract blocks: %s", err.Error())
|
||||
t.FailNow()
|
||||
}
|
||||
|
||||
templateBlocks, err := template.CompileTemplate(src)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to extract blocks: %s", err.Error())
|
||||
t.FailNow()
|
||||
}
|
||||
|
||||
expected := []template.Block{
|
||||
{
|
||||
Type: template.MatchingBlock,
|
||||
},
|
||||
{
|
||||
Type: template.DataBlock,
|
||||
Codec: "yaml",
|
||||
Path: ".",
|
||||
Fields: []template.BlockField{
|
||||
{
|
||||
Path: "@type",
|
||||
},
|
||||
{
|
||||
Path: "image",
|
||||
},
|
||||
{
|
||||
Path: "author.@type",
|
||||
},
|
||||
{
|
||||
Path: "author.name",
|
||||
},
|
||||
{
|
||||
Path: "datePublished",
|
||||
},
|
||||
{
|
||||
Path: "prepTime",
|
||||
},
|
||||
{
|
||||
Path: "cookTime",
|
||||
},
|
||||
{
|
||||
Path: "recipeYield",
|
||||
},
|
||||
},
|
||||
},
|
||||
{Type: template.MatchingBlock},
|
||||
{Type: template.DataBlock, Path: "name", Codec: "text"},
|
||||
{Type: template.MatchingBlock},
|
||||
{Type: template.DataBlock, Path: "description", Codec: "text"},
|
||||
{Type: template.MatchingBlock},
|
||||
{Type: template.DataBlock, Path: "recipeIngredient", Codec: "list", ListTemplate: "- { . }"},
|
||||
{Type: template.MatchingBlock},
|
||||
{Type: template.DataBlock, Path: "recipeInstructions", Codec: "list", ListTemplate: "{ @index }. { . }"},
|
||||
}
|
||||
|
||||
if len(templateBlocks) != len(expected) {
|
||||
t.Fatalf("expected %d blocks, got %d", len(expected), len(templateBlocks))
|
||||
}
|
||||
|
||||
for i, b := range templateBlocks {
|
||||
exp := expected[i]
|
||||
if b.Type != exp.Type {
|
||||
t.Errorf("Block#%d Type '%s' did not match expected type '%s'", i, b.Type, exp.Type)
|
||||
}
|
||||
if b.Path != exp.Path {
|
||||
t.Errorf("Block#%d Path '%s' did not match expected path '%s'", i, b.Path, exp.Path)
|
||||
}
|
||||
if b.Codec != exp.Codec {
|
||||
t.Errorf("Block#%d Codec '%s' did not match expected codec '%s'", i, b.Codec, exp.Codec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractInlineTemplate(t *testing.T) {
|
||||
testCases := []string{
|
||||
"- { . }",
|
||||
"- { amount } { type }",
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
_, err := template.CompileTemplate(tc)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to extract blocks: %s", err.Error())
|
||||
t.FailNow()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
5
template/go.mod
Normal file
5
template/go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module git.max-richter.dev/max/marka/template
|
||||
|
||||
go 1.24.3
|
||||
|
||||
require go.yaml.in/yaml/v4 v4.0.0-rc.1
|
2
template/go.sum
Normal file
2
template/go.sum
Normal file
@@ -0,0 +1,2 @@
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.1 h1:4J1+yLKUIPGexM/Si+9d3pij4hdc7aGO04NhrElqXbY=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.1/go.mod h1:CBdeces52/nUXndfQ5OY8GEQuNR9uEEOJPZj/Xq5IzU=
|
30
template/structs.go
Normal file
30
template/structs.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package template
|
||||
|
||||
type BlockType string
|
||||
|
||||
const (
|
||||
DataBlock BlockType = "data" // content between lines "{" and "}"
|
||||
MatchingBlock BlockType = "matching" // everything outside data blocks
|
||||
)
|
||||
|
||||
type BlockField struct {
|
||||
Path string
|
||||
CodecType CodecType
|
||||
Required bool
|
||||
Value any
|
||||
}
|
||||
|
||||
type Block struct {
|
||||
Type BlockType
|
||||
Path string
|
||||
Codec CodecType
|
||||
Required bool
|
||||
ListTemplate string
|
||||
Fields []BlockField
|
||||
Value any
|
||||
content string
|
||||
}
|
||||
|
||||
func (b Block) GetContent() string {
|
||||
return b.content
|
||||
}
|
Reference in New Issue
Block a user