feat: merge origin

This commit is contained in:
2025-10-01 16:45:49 +02:00
127 changed files with 6007 additions and 285 deletions

View File

@@ -1,11 +1,8 @@
// Package template contains the logic for parsing template blocks.
// Package template contains the logic for parsing templates.
package template
import (
"fmt"
"strings"
"go.yaml.in/yaml/v4"
)
// TemplateType represents whether a template is short, long, or invalid.
@@ -18,8 +15,8 @@ const (
)
// DetectTemplateType checks if the template is short or long.
func DetectTemplateType(tmpl string) TemplateType {
trimmed := strings.TrimSpace(tmpl)
func DetectTemplateType(tmpl Slice) TemplateType {
trimmed := strings.TrimSpace(tmpl.String())
// Short type: starts with "{" and ends with "}" on a single line,
// and contains "|" or "," inside for inline definition
@@ -45,123 +42,15 @@ func DetectTemplateType(tmpl string) TemplateType {
return InvalidTemplate
}
func cleanTemplate(input string) string {
s := strings.TrimSpace(input)
func cleanTemplate(input Slice) string {
s := strings.TrimSpace(input.String())
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 "number":
block.Codec = CodecNumber
case "text":
block.Codec = CodecText
case "hashtags":
block.Codec = CodecHashtags
default:
return block, fmt.Errorf("unknown codec option: %s", option)
}
}
}
return block, nil
}
type yamlBlock struct {
Path string `yaml:"path"`
Codec string `yaml:"codec"`
Value any `yaml:"value,omitempty"`
Fields []yamlField `yaml:"fields"`
ListTemplate string `yaml:"listTemplate,omitempty"`
Hidden bool `yaml:"hidden,omitempty"`
}
type yamlField struct {
Path string `yaml:"path"`
Value any `yaml:"value,omitempty"`
Codec string `yaml:"codec"`
Hidden bool `yaml:"hidden,omitempty"`
}
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,
Value: field.Value,
Hidden: field.Hidden,
})
}
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) {
func ParseTemplateBlock(template Slice, blockType BlockType) (block Block, err error) {
if blockType == MatchingBlock {
return Block{
Type: MatchingBlock,
@@ -176,5 +65,5 @@ func ParseTemplateBlock(template string, blockType BlockType) (block Block, err
return parseYamlTemplate(template)
}
return block, fmt.Errorf("invalid template")
return block, NewErrorf("invalid template: '%s'", template.String()).WithPosition(template.start, template.end)
}

39
template/blocks_short.go Normal file
View File

@@ -0,0 +1,39 @@
package template
import (
"fmt"
"strings"
)
func parseShortTemplate(input Slice) (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 {
for option := range strings.SplitSeq(split[1], ",") {
switch strings.TrimSpace(option) {
case "number":
block.Codec = CodecNumber
case "text":
block.Codec = CodecText
case "hashtags":
block.Codec = CodecHashtags
case "optional":
block.Optional = true
default:
return block, fmt.Errorf("unknown codec option: %s", option)
}
}
}
return block, nil
}

86
template/blocks_yaml.go Normal file
View File

@@ -0,0 +1,86 @@
package template
import (
"fmt"
"strings"
"go.yaml.in/yaml/v4"
)
type yamlBlock struct {
Path string `yaml:"path"`
Codec string `yaml:"codec"`
Value any `yaml:"value,omitempty"`
Fields []yamlField `yaml:"fields"`
ListTemplate string `yaml:"listTemplate,omitempty"`
Hidden bool `yaml:"hidden,omitempty"`
PathAlias []string `yaml:"pathAlias,omitempty"`
}
type yamlField struct {
Path string `yaml:"path"`
Value any `yaml:"value,omitempty"`
Codec string `yaml:"codec"`
Hidden bool `yaml:"hidden,omitempty"`
PathAlias string `yaml:"pathAlias,omitempty"`
}
func parseYamlTemplate(input Slice) (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, NewErrorf("failed to parse yaml -> %w", err).WithPosition(input.start, input.end)
}
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,
Value: field.Value,
Hidden: field.Hidden,
})
}
return Block{
Type: DataBlock,
Path: blk.Path,
Codec: codec,
Fields: fields,
ListTemplate: blk.ListTemplate,
content: input,
}, nil
}

View File

@@ -1,13 +1,13 @@
package template
import (
"fmt"
)
import "strings"
// 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) {
func CompileTemplate(templateSource string) ([]Block, error) {
templateSource = strings.TrimSuffix(templateSource, "\n")
var out []Block
var curlyIndex int
@@ -16,14 +16,15 @@ func CompileTemplate(template string) ([]Block, error) {
var start int
var blockType BlockType
template := NewSlice(templateSource)
if len(template) > 0 && template[0] == OPENING {
if template.Len() > 0 && template.At(0) == OPENING {
blockType = DataBlock
} else {
blockType = MatchingBlock
}
for i, r := range template {
for i, r := range template.Chars() {
nextCurlyIndex := curlyIndex
@@ -36,27 +37,26 @@ func CompileTemplate(template string) ([]Block, error) {
if curlyIndex == 0 && nextCurlyIndex == 1 {
if i > start {
block, err := ParseTemplateBlock(template[start:i], blockType)
block, err := ParseTemplateBlock(template.Slice(start, i), blockType)
if err != nil {
return nil, fmt.Errorf("cannot parse block: %w", err)
return nil, NewErrorf("cannot parse block @pos -> %w", err).WithPosition(start, i)
}
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)
block, err := ParseTemplateBlock(template.Slice(start, i+1), blockType)
if err != nil {
return nil, fmt.Errorf("cannot parse block: %w", err)
return nil, NewErrorf("cannot parse block @pos -> %w", err).WithPosition(start, i+1)
}
out = append(out, block)
}
nextChar := ' '
if i+1 < len(template) {
nextChar = rune(template[i+1])
if i+1 < template.Len() {
nextChar = rune(template.At(i + 1))
}
if nextChar == OPENING {
@@ -71,5 +71,17 @@ func CompileTemplate(template string) ([]Block, error) {
curlyIndex = nextCurlyIndex
}
if curlyIndex != 0 {
return nil, NewErrorf("unclosed block").WithPosition(start, template.Len())
}
if start < template.Len() {
block, err := ParseTemplateBlock(template.Slice(start, template.Len()), blockType)
if err != nil {
return nil, NewErrorf("cannot parse final block @pos -> %w", err).WithPosition(start, template.Len())
}
out = append(out, block)
}
return out, nil
}

View File

@@ -1,6 +1,7 @@
package template_test
import (
"fmt"
"testing"
"git.max-richter.dev/max/marka/registry"
@@ -10,16 +11,20 @@ import (
func TestExtractBlocks(t *testing.T) {
src, err := registry.GetTemplate("Recipe")
if err != nil {
t.Errorf("Failed to extract blocks: %s", err.Error())
t.Errorf("failed to load template 'Recipe' -> %s", err.Error())
t.FailNow()
}
templateBlocks, err := template.CompileTemplate(src)
if err != nil {
t.Errorf("Failed to extract blocks: %s", err.Error())
t.Errorf("failed to compile template -> %s", err.Error())
t.FailNow()
}
for i, b := range templateBlocks {
fmt.Printf("Block#%d: %q\n", i, b.GetContent())
}
expected := []template.Block{
{
Type: template.MatchingBlock,
@@ -30,13 +35,13 @@ func TestExtractBlocks(t *testing.T) {
Path: ".",
Fields: []template.BlockField{
{
Path: "@type",
Path: "_type",
},
{
Path: "image",
},
{
Path: "author.@type",
Path: "author._type",
},
{
Path: "author.name",

36
template/error.go Normal file
View File

@@ -0,0 +1,36 @@
package template
import (
"fmt"
"strings"
)
type Error struct {
err error
start, end int
}
func (e Error) Error() string {
content := e.err.Error()
if strings.Contains(content, " @pos ") {
if e.start == e.end {
return strings.ReplaceAll(content, " @pos ", " ")
}
return strings.ReplaceAll(content, " @pos ", fmt.Sprintf(" position=%d:%d ", e.start, e.end))
}
return content
}
func NewErrorf(msg string, args ...any) Error {
return Error{
err: fmt.Errorf(msg, args...),
}
}
func (e Error) WithPosition(start, end int) Error {
e.start = start
e.end = end
return e
}

View File

@@ -1,5 +1,8 @@
module git.max-richter.dev/max/marka/template
go 1.24.3
go 1.24.7
require go.yaml.in/yaml/v4 v4.0.0-rc.1
require (
git.max-richter.dev/max/marka/registry v0.0.0-20250819170608-69c2550f448e
go.yaml.in/yaml/v4 v4.0.0-rc.1
)

View File

@@ -1,2 +1,4 @@
git.max-richter.dev/max/marka/registry v0.0.0-20250819170608-69c2550f448e h1:eXAE0JHDvLGqtYSSlX5mw1XAuK+Cmu74c52PyveRhlE=
git.max-richter.dev/max/marka/registry v0.0.0-20250819170608-69c2550f448e/go.mod h1:n793S7TENIfgHpZLz0lm0qorM7eCx3zBLby3Fb++hZA=
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=

46
template/slice.go Normal file
View File

@@ -0,0 +1,46 @@
package template
func NewSlice(s string) Slice {
return Slice{
source: &s,
start: 0,
end: len(s),
}
}
type Slice struct {
source *string
start, end int
}
func (s Slice) Chars() []byte {
return []byte((*s.source)[s.start:s.end])
}
func (s Slice) Len() int {
return s.end - s.start
}
func (s Slice) At(i int) byte {
return (*s.source)[s.start+i]
}
func SliceFromString(s string, start, end int) Slice {
return Slice{
source: &s,
start: start,
end: end,
}
}
func (s Slice) String() string {
return (*s.source)[s.start:s.end]
}
func (s Slice) Slice(start, end int) Slice {
return Slice{
source: s.source,
start: s.start + start,
end: s.start + end,
}
}

View File

@@ -20,10 +20,11 @@ type Block struct {
Codec CodecType
ListTemplate string
Fields []BlockField
Optional bool
Value any
content string
content Slice
}
func (b Block) GetContent() string {
return b.content
return b.content.String()
}