diff --git a/Makefile b/Makefile index 156781d..b29bada 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -VERSION := v0.2.0-alpha +VERSION := v1.0.0-alpha BINARY_NAME := mend run: diff --git a/README.md b/README.md index ac98170..1e8a02a 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,10 @@ Mend is a simple HTML template processor designed to, but not limited to be used The produced HTML is always consistently formatted and sorted. -> [!IMPORTANT] -> Mend writes into **stdout** instead of a file. This is **not** a limitation, it's an [important advantage](https://github.com/bbfh-dev/mend/wiki#taking-advantage-of-stdout). - * [📥 Installation](#-installation) * [⚙️ Usage](#-usage) -* [📌 Developer notes](#-developer-notes) @@ -22,35 +18,36 @@ The produced HTML is always consistently formatted and sorted. Download the [latest release](https://github.com/bbfh-dev/mend/releases/latest) or install via the command line with: ```bash -go install github.com/bbfh-dev/mend +go install github.com/bbfh-dev/mend@latest ``` # ⚙️ Usage -Run `mend --help` to display usage information. +Check out the [Wiki](https://github.com/bbfh-dev/mend/wiki) for detailed documentation. + +Run `mend --help` to display CLI usage information. ```bash -Usage: - mend [html files...] +mend v1.0.0-alpha + +HTML template processor designed to, but not limited to be used to generate static websites -Commands: +Usage: + mend [options] Options: - --help, -h Print help and exit - --version, -V Print version and exit - --input, -i Set global input parameters - --indent Set amount of spaces to indent with. Gets ignored if --tabs is used - --tabs, -t Use tabs instead of spaces - --decomment Strips away any comments + --help + # Print this help message + --version + # Print the program version + --tabs, -t + # Use tabs for indentation + --indent (default: 4) + # The amount of spaces to be used for indentation (overwriten by --tabs) + --strip-comments + # Strips away HTML comments from the output + --input + # Provide input to the provided files in the following format: 'attr1=value1,attr2.a.b.c=value2,...' + --output + # (Optional) output path. Use '.' to substitute the same filename (e.g. './out/.' -> './out/input.html') ``` - -# 📌 Developer notes - -These are some important development notes, informing about parts of the project that need to be polished out. - -> **Expressions are very clunky.** -> -> 1. In code they require every node to implement its own processing while referencing a global function that handles them. Basically, it's just a big bowl of spaghetti. There's gotta be a better way of doing them. -> 1. The way expressions are parsed is very primitive, it could cause unexpected errors/behavior when using bad syntax. -> -> — [@bbfh-dev](https://github.com/bbfh-dev/) diff --git a/cli/cli.go b/cli/cli.go index b4b0383..dd0ec18 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -1,42 +1,49 @@ package cli import ( + "errors" "os" "path/filepath" "strings" - "github.com/bbfh-dev/mend/mend" - "github.com/bbfh-dev/mend/mend/settings" + "github.com/bbfh-dev/mend/lang" + "github.com/bbfh-dev/mend/lang/context" + "github.com/bbfh-dev/mend/lang/printer" ) var Options struct { - Input string `alt:"i" desc:"Set global input parameters"` - Indent int `desc:"Set amount of spaces to indent with. Gets ignored if --tabs is used"` Tabs bool `alt:"t" desc:"Use tabs for indentation"` - StripComments bool `desc:"Strips away any comments"` + Indent int `default:"4" desc:"The amount of spaces to be used for indentation (overwriten by --tabs)"` + StripComments bool `desc:"Strips away HTML comments from the output"` + Input string `desc:"Provide input to the provided files in the following format: 'attr1=value1,attr2.a.b.c=value2,...'"` + Output string `desc:"(Optional) output path. Use '.' to substitute the same filename (e.g. './out/.' -> './out/input.html')"` } func Main(args []string) error { - if len(args) == 0 { - return nil + printer.StripComments = Options.StripComments + if Options.Tabs { + printer.IndentString = "\t" + } else { + printer.IndentString = strings.Repeat(" ", Options.Indent) } - settings.KeepComments = !Options.StripComments - if Options.Tabs { - settings.IndentWith = "\t" - } else if Options.Indent != 0 { - settings.IndentWith = strings.Repeat(" ", Options.Indent) + context.GlobalContext = context.New() + if len(Options.Input) != 0 { + for prop := range strings.SplitSeq(Options.Input, ",") { + pair := strings.SplitN(prop, "=", 2) + if len(pair) != 2 { + return errors.New("input format must be: 'attr1=value1,attr2.a.b.c=value2,...'") + } + key, value := pair[0], pair[1] + context.GlobalContext.Set(strings.Split(key, "."), value) + } } - var err error for _, filename := range args { - filename, err = filepath.Abs(filename) - if err != nil { - return err - } - if _, err := os.Stat(filename); os.IsNotExist(err) { - return err - } + dir := filepath.Dir(filename) + base := filepath.Base(filename) + filename, _ := filepath.Abs(filename) + context.GlobalContext.Set([]string{"@file"}, filename) file, err := os.OpenFile(filename, os.O_RDONLY, os.ModePerm) if err != nil { @@ -44,19 +51,28 @@ func Main(args []string) error { } defer file.Close() - params := "{}" - if Options.Input != "" { - params = Options.Input + template := lang.New(0, context.GlobalContext, dir, base) + if err := template.Build(file); err != nil { + return err } - settings.GlobalParams = params - template := mend.NewTemplate(filename, params) - err = template.Parse(file) - if err != nil { - return err + out := os.Stdout + if Options.Output != "" { + if strings.HasSuffix(Options.Output, ".") { + Options.Output = strings.TrimSuffix(Options.Output, ".") + base + } + + if err := os.MkdirAll(filepath.Dir(Options.Output), os.ModePerm); err != nil { + return err + } + + out, err = os.OpenFile(Options.Output, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, os.ModePerm) + if err != nil { + return err + } } - template.Root.Render(os.Stdout, 0) + template.Root().Render(out, -1) } return nil diff --git a/example/components/button.html b/example/components/button.html new file mode 100644 index 0000000..58d2e32 --- /dev/null +++ b/example/components/button.html @@ -0,0 +1,4 @@ + + + [[ this.label ]] + diff --git a/example/components/icon.html b/example/components/icon.html new file mode 100644 index 0000000..2c76078 --- /dev/null +++ b/example/components/icon.html @@ -0,0 +1 @@ + diff --git a/example/components/icons/notification-unread.svg b/example/components/icons/notification-unread.svg new file mode 100644 index 0000000..a0b5072 --- /dev/null +++ b/example/components/icons/notification-unread.svg @@ -0,0 +1,3 @@ + + + diff --git a/example/components/icons/popout.svg b/example/components/icons/popout.svg new file mode 100644 index 0000000..2f33168 --- /dev/null +++ b/example/components/icons/popout.svg @@ -0,0 +1,4 @@ + + + diff --git a/example/components/icons/search.svg b/example/components/icons/search.svg new file mode 100644 index 0000000..e08725f --- /dev/null +++ b/example/components/icons/search.svg @@ -0,0 +1,5 @@ + + + diff --git a/example/components/icons/smithed.svg b/example/components/icons/smithed.svg new file mode 100644 index 0000000..3e8fcac --- /dev/null +++ b/example/components/icons/smithed.svg @@ -0,0 +1 @@ + diff --git a/example/components/icons/user.svg b/example/components/icons/user.svg new file mode 100644 index 0000000..e299092 --- /dev/null +++ b/example/components/icons/user.svg @@ -0,0 +1,5 @@ + + + diff --git a/example/components/root.html b/example/components/root.html new file mode 100644 index 0000000..66e65af --- /dev/null +++ b/example/components/root.html @@ -0,0 +1,94 @@ + + + + + + + + + + + + [[ this.title ]] — Smithed™ + + + + + + + +
+ + +

Smithed

+
+ + + + + + + Weld + + + + +
+ + +
+
+ + +
+
+ + + Inbox + + + + +
+ +
+ +
+ +
+
+
+

+ ABOUT +

+ + + +
+
+

+ COMMUNITY +

+ + + +
+
+

+ LEGAL +

+ + + +
+
+
+ Copyright © 2018-2025 Smithed +

+ Not an official Minecraft product. Not approved by or associated with Mojang Studios +

+
+
+ + + diff --git a/example/components/unmarked-link.html b/example/components/unmarked-link.html new file mode 100644 index 0000000..252b269 --- /dev/null +++ b/example/components/unmarked-link.html @@ -0,0 +1,5 @@ + + + [[ this.label? ]] + + diff --git a/example/header.html b/example/header.html deleted file mode 100644 index 3e76dde..0000000 --- a/example/header.html +++ /dev/null @@ -1,4 +0,0 @@ -
- - This is a header! -
diff --git a/example/icon.svg b/example/icon.svg deleted file mode 100644 index ceb1cb0..0000000 --- a/example/icon.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - diff --git a/example/index.html b/example/index.html index a6aa880..8e78274 100644 --- a/example/index.html +++ b/example/index.html @@ -1,5 +1,32 @@ - -
- [[ .title || "Hello World" ]] -
+{{ define "index" }} + + +

Welcome!

+

+ Smithed is an open-source platform for exploring, sharing
+ and supercharging minecraft data & resource packs. +

+
+ + +
+
+ {{ range .Cards }} + {{ template "pack_card" .Trending }} + {{ end }} +
+
+
+
+ {{ range .Cards }} + {{ template "pack_card" .Newest }} + {{ end }} +
+
+
+ + + What is Smithed exactly? +
+{{ end }} diff --git a/example/layout/grid.html b/example/layout/grid.html new file mode 100644 index 0000000..caae756 --- /dev/null +++ b/example/layout/grid.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/example/layout/heading.html b/example/layout/heading.html new file mode 100644 index 0000000..3720934 --- /dev/null +++ b/example/layout/heading.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/example/layout/separator.html b/example/layout/separator.html new file mode 100644 index 0000000..e9ad10e --- /dev/null +++ b/example/layout/separator.html @@ -0,0 +1,5 @@ +
+
+ +
+
diff --git a/example/root.html b/example/root.html deleted file mode 100644 index c0f2c4d..0000000 --- a/example/root.html +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - [[ -to-lower -to-snake-case -capitalize -invert ^.page.title ]] ([[ -get-length ^.page.title ]]) - - - - - -
- -
- - FIRST! - - - NOT FIRST! - - [[ -quote @.name ]] -
- This is not always here: [[ -quote @.count ||]] -
-
-
-
- - - diff --git a/go.mod b/go.mod index 11752dc..288e5ae 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,17 @@ go 1.24.1 toolchain go1.24.2 require ( - github.com/bbfh-dev/parsex/v2 v2.0.1-beta + github.com/bbfh-dev/parsex/v2 v2.0.2-beta github.com/iancoleman/strcase v0.3.0 github.com/tidwall/gjson v1.18.0 golang.org/x/net v0.37.0 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/testify v1.10.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 6a5ccfb..ded9384 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,15 @@ github.com/bbfh-dev/parsex/v2 v2.0.1-beta h1:AJoVHPLVGlSk6Hiyr9KAE4QM+3S4rY+uiJswx4SWyU4= github.com/bbfh-dev/parsex/v2 v2.0.1-beta/go.mod h1:mUhq/3DUhv062sQz7wTDdMmzvtomf12iF5o3w7D2a7k= +github.com/bbfh-dev/parsex/v2 v2.0.2-beta h1:ro60ULutX+kXpmK7EnxfrPuc6/WNxR5QlF4UVgB2DIA= +github.com/bbfh-dev/parsex/v2 v2.0.2-beta/go.mod h1:mUhq/3DUhv062sQz7wTDdMmzvtomf12iF5o3w7D2a7k= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -11,3 +19,6 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/lang/attrs/attrs.go b/lang/attrs/attrs.go new file mode 100644 index 0000000..eba1e8e --- /dev/null +++ b/lang/attrs/attrs.go @@ -0,0 +1,58 @@ +package attrs + +import ( + "fmt" + + "github.com/bbfh-dev/mend/lang/printer" + "golang.org/x/net/html" +) + +// XML attributes in a sorted manner +type Attributes struct { + order []string + Values map[string]string +} + +func New(sourceAttrs []html.Attribute) *Attributes { + attrs := &Attributes{ + order: []string{}, + Values: map[string]string{}, + } + + for _, attr := range sourceAttrs { + attrs.order = append(attrs.order, attr.Key) + attrs.Values[attr.Key] = attr.Val + } + + return attrs.Sort() +} + +// NOTE: It prepends " " (space) to the output +func (attrs *Attributes) Render(out printer.Writer) { + for _, key := range attrs.order { + out.WriteString(" ") + attrs.renderKey(out, key) + } +} + +// Overrides or saves new attribute. Use %s to format the original attribute +func (attrs *Attributes) OverrideAttr(key string, value string) { + original, ok := attrs.Values[key] + if !ok { + attrs.order = append(attrs.order, key) + attrs.Values[key] = value + attrs.Sort() + return + } + + attrs.Values[key] = fmt.Sprintf(value, original) +} + +func (attrs *Attributes) renderKey(out printer.Writer, key string) { + if len(attrs.Values[key]) == 0 { + out.WriteString(key) + return + } + + fmt.Fprintf(out, "%s=%q", key, attrs.Values[key]) +} diff --git a/mend/attrs/attributes_sort.go b/lang/attrs/attrs_sort.go similarity index 86% rename from mend/attrs/attributes_sort.go rename to lang/attrs/attrs_sort.go index bbe224e..d31b7e4 100644 --- a/mend/attrs/attributes_sort.go +++ b/lang/attrs/attrs_sort.go @@ -2,7 +2,8 @@ package attrs import "sort" -func (attrs Attributes) sort() Attributes { +// Sorts all attributes based on [AttrSortOrder] +func (attrs *Attributes) Sort() *Attributes { order := make(map[string]int) for i, s := range AttrSortOrder { order[s] = i diff --git a/mend/attrs/list.go b/lang/attrs/sorting.go similarity index 93% rename from mend/attrs/list.go rename to lang/attrs/sorting.go index 05d5ca6..a630e85 100644 --- a/mend/attrs/list.go +++ b/lang/attrs/sorting.go @@ -22,10 +22,10 @@ var AttrSortOrder = []string{ "srcdoc", "poster", "controls", "autoplay", "muted", "loop", "preload", "media", "ismap", - "accept", "accept-charset", "charset", "color", "cite", "content", + "http-equiv", "accept", "accept-charset", "charset", "color", "cite", "content", "contenteditable", "coords", "data", "datetime", "default", "defer", "dir", "dirname", "draggable", "enterkeyhint", "headers", "hidden", "high", - "hreflang", "http-equiv", "inert", "inputmode", "kind", "list", "low", + "hreflang", "inert", "inputmode", "kind", "list", "low", "max", "maxlength", "min", "multiple", "optimum", "pattern", "popover", "popovertarget", "popovertargetaction", "reversed", "sandbox", "scope", "selected", "shape", "size", "sizes", "span", "spellcheck", "srclang", diff --git a/lang/context/context.go b/lang/context/context.go new file mode 100644 index 0000000..3cb1da3 --- /dev/null +++ b/lang/context/context.go @@ -0,0 +1,80 @@ +package context + +import ( + "fmt" + "strings" +) + +// The 'root.' context +var GlobalContext *Context + +// Template's context that's accessed using `this.` +type Context struct { + Values map[string]any +} + +func New() *Context { + return &Context{ + Values: map[string]any{}, + } +} + +func (ctx *Context) Query(path []string) (string, error) { + if len(path) == 0 { + return ctx.String(), nil + } + + value, ok := ctx.Values[path[0]] + if !ok { + return "", fmt.Errorf("undefined property %q in %q", path[0], ctx.String()) + } + + switch value := value.(type) { + case *Context: + return value.Query(path[1:]) + default: + return fmt.Sprintf("%v", value), nil + } +} + +func (ctx *Context) Set(path []string, newValue string) { + switch len(path) { + case 0: + return + case 1: + ctx.Values[path[0]] = newValue + return + } + + value, ok := ctx.Values[path[0]] + if !ok { + value = New() + ctx.Values[path[0]] = value + } + + switch value := value.(type) { + case *Context: + value.Set(path[1:], newValue) + case string: + ctx.Values[path[0]] = newValue + } +} + +func (ctx *Context) String() string { + var builder strings.Builder + + for key, value := range ctx.Values { + switch value := value.(type) { + case string: + fmt.Fprintf(&builder, "%s='%s' ", key, value) + default: + fmt.Fprintf(&builder, "%s=%s ", key, value) + } + } + + str := builder.String() + if len(str) == 0 { + return "{}" + } + return "{" + str[:len(str)-1] + "}" +} diff --git a/lang/context/context_compute.go b/lang/context/context_compute.go new file mode 100644 index 0000000..7dfa4f0 --- /dev/null +++ b/lang/context/context_compute.go @@ -0,0 +1,178 @@ +package context + +import ( + "fmt" + "path/filepath" + "slices" + "strings" + "unicode" + + "github.com/iancoleman/strcase" +) + +func (ctx *Context) Compute(expression string) (string, error) { + fields := getFields(expression) + switch len(fields) { + case 0: + return "", nil + case 1: + return ctx.queryPath(fields[0]) + } + + variable, err := ctx.queryPath(fields[0]) + if err != nil && !slices.Contains(fields, "||") { + return "", err + } + + if len(fields) < 3 { + return "", fmt.Errorf("expression requires a value to the right of %q", fields[1]) + } + + var result string + switch fields[1] { + case "==": + result = fmt.Sprintf("%v", variable == fields[2]) + case "!=": + result = fmt.Sprintf("%v", variable != fields[2]) + case "has": + result = fmt.Sprintf("%v", strings.Contains(variable, fields[2])) + case "lacks": + result = fmt.Sprintf("%v", !strings.Contains(variable, fields[2])) + case "||": + if err != nil { + result = fields[2] + break + } + result = variable + default: + return "", fmt.Errorf("unknown operation %q", fields[1]) + } + + if len(fields) < 5 { + return result, nil + } + + switch fields[3] { + case "||": + if err != nil { + result = fields[4] + break + } + default: + return "", fmt.Errorf( + "unsupported after-operation %q, only || is supported for chaining", + fields[3], + ) + } + + return result, nil +} + +func (ctx *Context) queryPath(fieldPath string) (result string, err error) { + field := strings.TrimSuffix(fieldPath, "?") + segments := strings.Split(field, ".")[1:] + path := make([]string, 0, len(segments)) + operations := make([]string, 0, len(segments)) + + for _, segment := range segments { + if strings.HasSuffix(segment, "()") { + operations = append(operations, segment[:len(segment)-2]) + continue + } + path = append(path, segment) + } + + if strings.HasPrefix(field, "this.") { + result, err = ctx.Query(path) + } + if strings.HasPrefix(field, "root.") { + result, err = GlobalContext.Query(path) + } + + if err != nil { + if strings.HasSuffix(fieldPath, "?") { + return "", nil + } + return "", fmt.Errorf("%s: %w", field, err) + } + + for _, operation := range operations { + switch operation { + case "to_lower": + result = strings.ToLower(result) + case "to_upper": + result = strings.ToUpper(result) + case "to_pascal_case": + result = strcase.ToCamel(result) + case "to_camel_case": + result = strcase.ToLowerCamel(result) + case "to_snake_case": + result = strcase.ToSnake(result) + case "to_kebab_case": + result = strcase.ToKebab(result) + case "capitalize": + result = strings.ToUpper(result[:1]) + result[1:] + case "length": + result = fmt.Sprintf("%d", len(result)) + case "filename": + result = filepath.Base(result) + case "dir": + result = filepath.Dir(result) + case "extension": + result = filepath.Ext(result) + case "trim_extension": + result = strings.TrimSuffix(result, filepath.Ext(result)) + default: + return "", fmt.Errorf("%s: unknown operation %q on %q", field, operation+"()", result) + } + } + + return result, nil +} + +func getFields(text string) (out []string) { + var ( + current []rune + inQuote bool + quoteChar rune + ) + + flush := func() { + if len(current) > 0 { + out = append(out, string(current)) + current = current[:0] + } + } + + for _, char := range text { + switch { + case (char == '"' || char == '\''): + if inQuote { + // closing quote? + if char == quoteChar { + inQuote = false + quoteChar = 0 + } else { + // different quote inside a quote: treat literally + current = append(current, char) + } + } else { + inQuote = true + quoteChar = char + } + + case unicode.IsSpace(char): + if inQuote { + current = append(current, char) + } else { + flush() + } + + default: + current = append(current, char) + } + } + + flush() + return +} diff --git a/lang/context/context_test.go b/lang/context/context_test.go new file mode 100644 index 0000000..e25709c --- /dev/null +++ b/lang/context/context_test.go @@ -0,0 +1,181 @@ +package context_test + +import ( + "strings" + "testing" + + "github.com/bbfh-dev/mend/lang/context" + "github.com/stretchr/testify/assert" +) + +func TestContextGetAndSet(test *testing.T) { + assert := assert.New(test) + + ctx := context.New() + assert.NotNil(ctx) + + ctx.Set([]string{"a", "b", "c"}, "Hello World!") + ctx.Set([]string{"a", "b", "d"}, "Another one!") + ctx.Set([]string{"e"}, "1") + + value, err := ctx.Query([]string{"a", "b", "c"}) + assert.NoError(err) + assert.Equal("Hello World!", value) + + value, err = ctx.Query([]string{"a", "b", "d"}) + assert.NoError(err) + assert.Equal("Another one!", value) + + value, err = ctx.Query([]string{"e"}) + assert.NoError(err) + assert.Equal("1", value) + + value, err = ctx.Query([]string{"unknown"}) + assert.Error(err) + assert.Equal("", value) +} + +func TestContextExpressions(test *testing.T) { + assert := assert.New(test) + + ctx := context.New() + assert.NotNil(ctx) + + ctx.Set([]string{"a", "b", "c"}, "Hello World!") + ctx.Set([]string{"a", "path"}, "/tmp/some/filename.html") + + var cases = []struct { + Expression string + ExpectErr bool + Expect string + }{ + { + Expression: "this.a.b.c", + ExpectErr: false, + Expect: "Hello World!", + }, + { + Expression: "this.a.b.c == 'Hello World!'", + ExpectErr: false, + Expect: "true", + }, + { + Expression: "this.a.b.c != 'Hello World!'", + ExpectErr: false, + Expect: "false", + }, + { + Expression: "this.a.unknown == 'Hello World!'", + ExpectErr: true, + }, + { + Expression: "this.a.b.c has 'Hello'", + ExpectErr: false, + Expect: "true", + }, + { + Expression: "this.a.b.c lacks 'Hello'", + ExpectErr: false, + Expect: "false", + }, + { + Expression: "this.a.b.c || 'Fallback'", + ExpectErr: false, + Expect: "Hello World!", + }, + { + Expression: "this.a.unknown || 'Fallback'", + ExpectErr: false, + Expect: "Fallback", + }, + { + Expression: "this.a.b.c.to_lower()", + ExpectErr: false, + Expect: "hello world!", + }, + { + Expression: "this.a.b.c.to_upper()", + ExpectErr: false, + Expect: "HELLO WORLD!", + }, + { + Expression: "this.a.b.c.to_pascal_case()", + ExpectErr: false, + Expect: "HelloWorld", + }, + { + Expression: "this.a.b.c.to_camel_case()", + ExpectErr: false, + Expect: "helloWorld", + }, + { + Expression: "this.a.b.c.to_snake_case()", + ExpectErr: false, + Expect: "hello_world!", + }, + { + Expression: "this.a.b.c.to_kebab_case()", + ExpectErr: false, + Expect: "hello-world!", + }, + { + Expression: "this.a.b.c.to_lower().capitalize()", + ExpectErr: false, + Expect: "Hello world!", + }, + { + Expression: "this.a.b.c.length()", + ExpectErr: false, + Expect: "12", + }, + { + Expression: "this.a.b.c.length() == this.a.b.c.to_camel_case().length()", + ExpectErr: false, + Expect: "false", + }, + { + Expression: "this.a.path.extension()", + ExpectErr: false, + Expect: ".html", + }, + { + Expression: "this.a.path.filename().trim_extension()", + ExpectErr: false, + Expect: "filename", + }, + { + Expression: "this.a.b.unknown == 69", + ExpectErr: true, + }, + { + Expression: "this.a.b.unknown == 69 || true", + ExpectErr: false, + Expect: "true", + }, + { + Expression: "this.a.b.c has '!' || 'Fallback'", + ExpectErr: false, + Expect: "true", + }, + { + Expression: "", + ExpectErr: false, + Expect: "", + }, + } + + for _, testCase := range cases { + test.Run( + strings.ReplaceAll(testCase.Expression, " ", "__"), + func(test *testing.T) { + result, err := ctx.Compute(testCase.Expression) + if testCase.ExpectErr { + assert.Error(err) + return + } + assert.NoError(err) + assert.Equal(testCase.Expect, result) + }, + ) + } +} diff --git a/lang/context/parse.go b/lang/context/parse.go new file mode 100644 index 0000000..17f39e7 --- /dev/null +++ b/lang/context/parse.go @@ -0,0 +1,92 @@ +package context + +import ( + "strings" + + "golang.org/x/net/html" +) + +func IsContextKey(key string) bool { + return strings.HasPrefix(key, ":") +} + +func ParseAttrs(attrs []html.Attribute) *Context { + ctx := New() + + for _, attr := range attrs { + if !IsContextKey(attr.Key) { + continue + } + key := attr.Key[1:] + value := strings.TrimSpace(attr.Val) + + if strings.HasPrefix(value, "{") && strings.HasSuffix(value, "}") { + ctx.Values[key] = parseDict(value) + continue + } + + ctx.Set([]string{key}, value) + } + + return ctx +} + +func parseDict(str string) *Context { + dict := New() + if len(str) == 2 { + return dict + } + + content := strings.TrimSpace(str[1 : len(str)-1]) + for len(content) > 0 { + // key up to '=' + eq := strings.Index(content, "=") + if eq < 0 { + break + } + key := content[:eq] + content = content[eq+1:] + if len(content) == 0 { + break + } + + switch { + // nested dict + case content[0] == '{': + depth := 1 + i := 1 + for ; i < len(content) && depth > 0; i++ { + switch content[i] { + case '{': + depth++ + case '}': + depth-- + } + } + sub := content[:i] + dict.Values[key] = parseDict(sub) + content = strings.TrimLeft(content[i:], " ") + + // quoted string + case content[0] == '\'': + i := 1 + for ; i < len(content) && content[i] != '\''; i++ { + } + dict.Values[key] = content[1:i] + content = strings.TrimLeft(content[i+1:], " ") + + // bare word + default: + i := strings.Index(content, " ") + if i < 0 { + dict.Values[key] = content + content = "" + } else { + dict.Values[key] = content[:i] + content = strings.TrimLeft(content[i+1:], " ") + } + } + } + + return dict +} diff --git a/lang/expressions/parse.go b/lang/expressions/parse.go new file mode 100644 index 0000000..cf5a891 --- /dev/null +++ b/lang/expressions/parse.go @@ -0,0 +1,42 @@ +package expressions + +import "strings" + +const bracketOpen = "[[" +const bracketClose = "]]" + +func Parse(text string, callback func(string) (string, error)) (string, error) { + var builder strings.Builder + i := 0 + + for { + start := strings.Index(text[i:], bracketOpen) + if start == -1 { + // No more expressions; write the remaining text. + builder.WriteString(text[i:]) + break + } + + builder.WriteString(text[i : i+start]) + i += start + len(bracketOpen) + + // Find the closing bracket. + end := strings.Index(text[i:], bracketClose) + if end == -1 { + // Unmatched bracket; treat the remainder as plain text. + builder.WriteString(bracketOpen) + builder.WriteString(text[i:]) + break + } + + // Evaluate the expression inside the brackets. + expr, err := callback(strings.TrimSpace(text[i : i+end])) + if err != nil { + return builder.String(), err + } + builder.WriteString(expr) + i += end + len(bracketClose) + } + + return builder.String(), nil +} diff --git a/lang/printer/printer.go b/lang/printer/printer.go new file mode 100644 index 0000000..d2b8134 --- /dev/null +++ b/lang/printer/printer.go @@ -0,0 +1,21 @@ +package printer + +import ( + "io" + "strings" +) + +var IndentString string +var StripComments bool + +type Writer interface { + io.Writer + io.StringWriter +} + +func WriteIndent(writer Writer, indent int) { + if indent < 0 { + return + } + writer.WriteString(strings.Repeat(IndentString, indent)) +} diff --git a/lang/tags/tag.go b/lang/tags/tag.go new file mode 100644 index 0000000..5baafec --- /dev/null +++ b/lang/tags/tag.go @@ -0,0 +1,26 @@ +package tags + +import ( + "github.com/bbfh-dev/mend/lang/printer" +) + +type visibility int + +const ( + INVISIBLE visibility = iota + VISIBLE + INLINE +) + +type Tag interface { + Render(writer printer.Writer, indent int) + Visibility() visibility + Clone() Tag + OverrideAttr(key string, value string) bool +} + +type PairedTag interface { + Tag + SetChildren(tags []Tag) + Append(tags ...Tag) +} diff --git a/lang/tags/tag_base.go b/lang/tags/tag_base.go new file mode 100644 index 0000000..3a9c44f --- /dev/null +++ b/lang/tags/tag_base.go @@ -0,0 +1,29 @@ +package tags + +import ( + "github.com/bbfh-dev/mend/lang/printer" +) + +type BaseTag struct { +} + +func NewBase() *BaseTag { + return &BaseTag{} +} + +func (tag *BaseTag) Render(writer printer.Writer, indent int) { + printer.WriteIndent(writer, indent) +} + +func (tag *BaseTag) Visibility() visibility { + return VISIBLE +} + +func (tag *BaseTag) Clone() Tag { + clone := *tag + return &clone +} + +func (tag *BaseTag) OverrideAttr(key string, value string) bool { + return false +} diff --git a/lang/tags/tag_base_default.go b/lang/tags/tag_base_default.go new file mode 100644 index 0000000..5fbbc2f --- /dev/null +++ b/lang/tags/tag_base_default.go @@ -0,0 +1,5 @@ +package tags + +type BaseDefaultTag struct { + *BasePairedTag +} diff --git a/lang/tags/tag_base_paired.go b/lang/tags/tag_base_paired.go new file mode 100644 index 0000000..2764ee2 --- /dev/null +++ b/lang/tags/tag_base_paired.go @@ -0,0 +1,47 @@ +package tags + +import ( + "github.com/bbfh-dev/mend/lang/printer" +) + +type BasePairedTag struct { + *BaseTag + Children []Tag +} + +func NewPairedBase() *BasePairedTag { + return &BasePairedTag{ + BaseTag: NewBase(), + Children: []Tag{}, + } +} + +func (tag *BasePairedTag) Render(writer printer.Writer, indent int) { + for _, child := range tag.Children { + // fmt.Fprintf(writer, "\n", reflect.TypeOf(child), child) + switch child.Visibility() { + case VISIBLE: + child.Render(writer, indent+1) + writer.WriteString("\n") + case INLINE: + child.Render(writer, indent) + } + } +} + +func (tag *BasePairedTag) SetChildren(tags []Tag) { + tag.Children = tags +} + +func (tag *BasePairedTag) Append(tags ...Tag) { + tag.Children = append(tag.Children, tags...) +} + +func (tag *BasePairedTag) OverrideAttr(key string, value string) bool { + for _, child := range tag.Children { + if child.OverrideAttr(key, value) { + return true + } + } + return false +} diff --git a/lang/tags/tag_comment.go b/lang/tags/tag_comment.go new file mode 100644 index 0000000..bba62a8 --- /dev/null +++ b/lang/tags/tag_comment.go @@ -0,0 +1,24 @@ +package tags + +import ( + "fmt" + + "github.com/bbfh-dev/mend/lang/printer" +) + +type CommentTag struct { + *BaseTag + Comment string +} + +func NewComment(comment string) *CommentTag { + return &CommentTag{ + BaseTag: NewBase(), + Comment: comment, + } +} + +func (tag *CommentTag) Render(writer printer.Writer, indent int) { + tag.BaseTag.Render(writer, indent) + fmt.Fprintf(writer, "", tag.Comment) +} diff --git a/lang/tags/tag_default.go b/lang/tags/tag_default.go new file mode 100644 index 0000000..2099ebf --- /dev/null +++ b/lang/tags/tag_default.go @@ -0,0 +1,40 @@ +package tags + +import ( + "fmt" + + "github.com/bbfh-dev/mend/lang/attrs" + "github.com/bbfh-dev/mend/lang/printer" +) + +type DefaultTag struct { + *BasePairedTag + Name string + Attrs *attrs.Attributes +} + +func NewDefault(name string, attrs *attrs.Attributes) *DefaultTag { + return &DefaultTag{ + BasePairedTag: NewPairedBase(), + Name: name, + Attrs: attrs, + } +} + +func (tag *DefaultTag) Render(writer printer.Writer, indent int) { + tag.BaseTag.Render(writer, indent) + + fmt.Fprintf(writer, "<%s", tag.Name) + tag.Attrs.Render(writer) + writer.WriteString(">\n") + + tag.BasePairedTag.Render(writer, indent) + + tag.BaseTag.Render(writer, indent) + fmt.Fprintf(writer, "", tag.Name) +} + +func (tag *DefaultTag) OverrideAttr(key, value string) bool { + tag.Attrs.OverrideAttr(key, value) + return true +} diff --git a/lang/tags/tag_default_root.go b/lang/tags/tag_default_root.go new file mode 100644 index 0000000..83aefb8 --- /dev/null +++ b/lang/tags/tag_default_root.go @@ -0,0 +1,37 @@ +package tags + +import ( + "fmt" + + "github.com/bbfh-dev/mend/lang/attrs" + "github.com/bbfh-dev/mend/lang/printer" +) + +type DefaultRootTag struct { + *DefaultTag +} + +func NewDefaultRoot(name string, attrs *attrs.Attributes) *DefaultRootTag { + return &DefaultRootTag{ + DefaultTag: NewDefault(name, attrs), + } +} + +func (tag *DefaultRootTag) Render(writer printer.Writer, indent int) { + tag.BaseTag.Render(writer, indent) + + fmt.Fprintf(writer, "<%s", tag.Name) + tag.Attrs.Render(writer) + writer.WriteString(">\n") + + writer.WriteString("\n") + for _, child := range tag.Children { + if child.Visibility() != INVISIBLE { + child.Render(writer, indent) + writer.WriteString("\n\n") + } + } + + tag.BaseTag.Render(writer, indent) + fmt.Fprintf(writer, "", tag.Name) +} diff --git a/lang/tags/tag_default_self_closing.go b/lang/tags/tag_default_self_closing.go new file mode 100644 index 0000000..24d3691 --- /dev/null +++ b/lang/tags/tag_default_self_closing.go @@ -0,0 +1,35 @@ +package tags + +import ( + "fmt" + + "github.com/bbfh-dev/mend/lang/attrs" + "github.com/bbfh-dev/mend/lang/printer" +) + +type SelfClosingTag struct { + *BaseTag + Name string + Attrs *attrs.Attributes +} + +func NewSelfClosing(name string, attrs *attrs.Attributes) *SelfClosingTag { + return &SelfClosingTag{ + BaseTag: NewBase(), + Name: name, + Attrs: attrs, + } +} + +func (tag *SelfClosingTag) Render(writer printer.Writer, indent int) { + tag.BaseTag.Render(writer, indent) + + fmt.Fprintf(writer, "<%s", tag.Name) + tag.Attrs.Render(writer) + writer.WriteString(" />") +} + +func (tag *SelfClosingTag) OverrideAttr(key, value string) bool { + tag.Attrs.OverrideAttr(key, value) + return true +} diff --git a/lang/tags/tag_doctype.go b/lang/tags/tag_doctype.go new file mode 100644 index 0000000..c09564a --- /dev/null +++ b/lang/tags/tag_doctype.go @@ -0,0 +1,24 @@ +package tags + +import ( + "fmt" + + "github.com/bbfh-dev/mend/lang/printer" +) + +type DoctypeTag struct { + *BaseTag + Doctype string +} + +func NewDoctype(doctype string) *DoctypeTag { + return &DoctypeTag{ + BaseTag: NewBase(), + Doctype: doctype, + } +} + +func (tag *DoctypeTag) Render(writer printer.Writer, indent int) { + tag.BaseTag.Render(writer, indent) + fmt.Fprintf(writer, "", tag.Doctype) +} diff --git a/lang/tags/tag_mend_extend.go b/lang/tags/tag_mend_extend.go new file mode 100644 index 0000000..14707ec --- /dev/null +++ b/lang/tags/tag_mend_extend.go @@ -0,0 +1,27 @@ +package tags + +import ( + "github.com/bbfh-dev/mend/lang/printer" +) + +type MendExtendTag struct { + *BasePairedTag + Root PairedTag + Slot *MendSlotTag +} + +func NewMendExtend(root PairedTag, slot *MendSlotTag) *MendExtendTag { + return &MendExtendTag{ + BasePairedTag: NewPairedBase(), + Root: root, + Slot: slot, + } +} + +func (tag *MendExtendTag) Render(writer printer.Writer, indent int) { + tag.Root.Render(writer, indent) +} + +func (tag *MendExtendTag) Visibility() visibility { + return INLINE +} diff --git a/lang/tags/tag_mend_slot.go b/lang/tags/tag_mend_slot.go new file mode 100644 index 0000000..d35f80f --- /dev/null +++ b/lang/tags/tag_mend_slot.go @@ -0,0 +1,15 @@ +package tags + +type MendSlotTag struct { + *BasePairedTag +} + +func NewMendSlot() *MendSlotTag { + return &MendSlotTag{ + BasePairedTag: NewPairedBase(), + } +} + +func (tag *MendSlotTag) Visibility() visibility { + return INLINE +} diff --git a/lang/tags/tag_mend_void.go b/lang/tags/tag_mend_void.go new file mode 100644 index 0000000..a70c69d --- /dev/null +++ b/lang/tags/tag_mend_void.go @@ -0,0 +1,15 @@ +package tags + +type MendVoidTag struct { + *BasePairedTag +} + +func NewMendVoid() *MendVoidTag { + return &MendVoidTag{ + BasePairedTag: NewPairedBase(), + } +} + +func (tag *MendVoidTag) Visibility() visibility { + return INVISIBLE +} diff --git a/lang/tags/tag_text.go b/lang/tags/tag_text.go new file mode 100644 index 0000000..b6785f6 --- /dev/null +++ b/lang/tags/tag_text.go @@ -0,0 +1,33 @@ +package tags + +import ( + "strings" + + "github.com/bbfh-dev/mend/lang/printer" +) + +type TextTag struct { + *BaseTag + Text string +} + +func NewText(text string) *TextTag { + return &TextTag{ + BaseTag: NewBase(), + Text: text, + } +} + +func (tag *TextTag) Render(writer printer.Writer, indent int) { + tag.BaseTag.Render(writer, indent) + + lines := strings.Split(tag.Text, "\n") + lastLine := len(lines) - 1 + for i, line := range lines { + line = strings.TrimSpace(line) + writer.WriteString(line) + if i != lastLine { + writer.WriteString(" ") + } + } +} diff --git a/lang/template.go b/lang/template.go new file mode 100644 index 0000000..b3db844 --- /dev/null +++ b/lang/template.go @@ -0,0 +1,81 @@ +package lang + +import ( + "fmt" + + "github.com/bbfh-dev/mend/lang/attrs" + "github.com/bbfh-dev/mend/lang/context" + "github.com/bbfh-dev/mend/lang/tags" + "golang.org/x/net/html" +) + +const MEND_PREFIX = "mend:" +const PKG_PREFIX = "pkg:" + +type Template struct { + Dir string + Name string + Context *context.Context + + Breadcrumbs []tags.PairedTag + Slot *tags.MendSlotTag + + thisToken html.Token + thisText string + thisAttrs *attrs.Attributes + thisLineIndex int + thisIndent int +} + +func New(indent int, ctx *context.Context, dir, name string) *Template { + return &Template{ + Dir: dir, + Name: name, + Context: ctx, + Breadcrumbs: []tags.PairedTag{tags.NewMendSlot()}, + Slot: nil, + thisToken: html.Token{}, + thisText: "", + thisAttrs: nil, + thisLineIndex: 0, + thisIndent: indent, + } +} + +func (template *Template) Cursor() string { + return fmt.Sprintf("%s:%d", template.Name, template.thisLineIndex+1) +} + +func (template *Template) Root() tags.PairedTag { + return template.Breadcrumbs[0] +} + +func (template *Template) Pivot() tags.PairedTag { + return template.Breadcrumbs[len(template.Breadcrumbs)-1] +} + +func (template *Template) EnterPivot(tag tags.PairedTag) { + template.Pivot().Append(tag) + template.Breadcrumbs = append(template.Breadcrumbs, tag) + template.thisIndent++ +} + +func (template *Template) ExitPivot() { + if len(template.Breadcrumbs) == 1 { + return + } + template.Breadcrumbs = template.Breadcrumbs[:len(template.Breadcrumbs)-1] + template.thisIndent-- +} + +func (template *Template) requireAttr(key string) (string, error) { + src, ok := template.thisAttrs.Values[key] + if !ok { + return "", fmt.Errorf( + "<%s> requires an `:%s=\"...\"` attribute", + template.thisText, + key, + ) + } + return src, nil +} diff --git a/lang/template_branch.go b/lang/template_branch.go new file mode 100644 index 0000000..03327d3 --- /dev/null +++ b/lang/template_branch.go @@ -0,0 +1,75 @@ +package lang + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/bbfh-dev/mend/lang/context" +) + +func (template *Template) BranchOut(location string) (*Template, error) { + file, err := os.OpenFile(location, os.O_RDONLY, os.ModePerm) + if err != nil { + return nil, err + } + defer file.Close() + + branch := New( + template.thisIndent, + context.ParseAttrs(template.thisToken.Attr), + filepath.Dir(location), + filepath.Base(location), + ) + if err := branch.Build(file); err != nil { + return nil, err + } + + for key, attr := range template.thisAttrs.Values { + if strings.HasPrefix(key, ":") { + continue + } + branch.Root().OverrideAttr(key, attr) + } + + return branch, nil +} + +func (template *Template) Find(name string) (string, bool) { + var result string + + filepath.WalkDir(template.Dir, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + + if entry.IsDir() { + return nil + } + + if entry.Name() == name { + result = path + return filepath.SkipAll + } + + return nil + }) + + return result, result != "" +} + +func (template *Template) locateTemplate(tag string) (string, error) { + location, found := template.Find(tag + ".html") + if !found { + abs, _ := filepath.Abs(template.Dir) + return "", fmt.Errorf( + "Can't find template <%s%s> in %s/*", + PKG_PREFIX, + tag, + abs, + ) + } + return location, nil +} diff --git a/lang/template_build.go b/lang/template_build.go new file mode 100644 index 0000000..9f935cd --- /dev/null +++ b/lang/template_build.go @@ -0,0 +1,238 @@ +package lang + +import ( + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/bbfh-dev/mend/lang/attrs" + "github.com/bbfh-dev/mend/lang/expressions" + "github.com/bbfh-dev/mend/lang/printer" + "github.com/bbfh-dev/mend/lang/tags" + "golang.org/x/net/html" +) + +func (template *Template) Build(reader io.Reader) error { + tokenizer := html.NewTokenizer(reader) + + for { + tokenType := tokenizer.Next() + if tokenType == html.ErrorToken { + err := tokenizer.Err() + if err == io.EOF { + return nil + } + return fmt.Errorf("(%s): %w", template.Cursor(), err) + } + + template.thisToken = tokenizer.Token() + template.thisText = strings.TrimSpace(template.thisToken.Data) + template.thisAttrs = attrs.New(template.thisToken.Attr) + for key, value := range template.thisAttrs.Values { + text, err := expressions.Parse(value, template.Context.Compute) + if err != nil { + return fmt.Errorf("%s (expression): %w", template.Cursor(), err) + } + template.thisAttrs.Values[key] = text + } + + if tokenType == html.TextToken { + template.thisLineIndex += strings.Count(template.thisToken.Data, "\n") + } + + if err := template.buildToken(tokenType); err != nil { + return fmt.Errorf("(%s): %w", template.Cursor(), err) + } + } +} + +func (template *Template) buildToken(tokenType html.TokenType) error { + switch tokenType { + + case html.DoctypeToken: + template.Pivot().Append(tags.NewDoctype( + template.thisText, + )) + + case html.CommentToken: + if printer.StripComments { + break + } + template.Pivot().Append(tags.NewComment( + template.thisText, + )) + + case html.TextToken: + if len(template.thisText) == 0 { + break + } + text, err := expressions.Parse(template.thisText, template.Context.Compute) + if err != nil { + return fmt.Errorf("(expression): %w", err) + } + template.Pivot().Append(tags.NewText( + text, + )) + + case html.SelfClosingTagToken: + switch { + + case strings.HasPrefix(template.thisText, MEND_PREFIX): + name := strings.TrimPrefix(template.thisText, MEND_PREFIX) + switch name { + + case "slot": + tag := tags.NewMendSlot() + template.Pivot().Append(tag) + template.Slot = tag + + case "include": + src, err := template.requireAttr(":src") + if err != nil { + return err + } + + branch, err := template.BranchOut(filepath.Join(template.Dir, src)) + if err != nil { + return err + } + + template.Pivot().Append(branch.Root()) + + default: + return fmt.Errorf("unknown tag <%s%s />", MEND_PREFIX, name) + } + + case strings.HasPrefix(template.thisText, PKG_PREFIX): + name := strings.TrimPrefix(template.thisText, PKG_PREFIX) + location, err := template.locateTemplate(name) + if err != nil { + return err + } + + branch, err := template.BranchOut(location) + if err != nil { + return err + } + + template.Pivot().Append(branch.Root()) + + default: + template.Pivot().Append(tags.NewSelfClosing( + template.thisText, + template.thisAttrs, + )) + } + + case html.StartTagToken: + switch { + + case strings.HasPrefix(template.thisText, MEND_PREFIX): + name := strings.TrimPrefix(template.thisText, MEND_PREFIX) + switch name { + + case "slot": + tag := tags.NewMendSlot() + template.EnterPivot(tag) + template.Slot = tag + + case "if": + checkTrue, okTrue := template.thisAttrs.Values[":true"] + checkFalse, okFalse := template.thisAttrs.Values[":false"] + switch { + case okTrue: + if checkTrue != "true" { + template.EnterPivot(tags.NewMendVoid()) + return nil + } + case okFalse: + if checkFalse != "false" { + template.EnterPivot(tags.NewMendVoid()) + return nil + } + default: + return fmt.Errorf( + " requires a `:true=\"...\"` or `:false=\"...\"` attribute", + ) + } + template.EnterPivot(tags.NewMendSlot()) + + case "extend": + src, err := template.requireAttr(":src") + if err != nil { + return err + } + + branch, err := template.BranchOut(filepath.Join(template.Dir, src)) + if err != nil { + return err + } + + template.EnterPivot(tags.NewMendExtend( + branch.Root(), + branch.Slot, + )) + + default: + return fmt.Errorf("unknown tag <%s%s>", MEND_PREFIX, name) + } + + case strings.HasPrefix(template.thisText, PKG_PREFIX): + name := strings.TrimPrefix(template.thisText, PKG_PREFIX) + location, err := template.locateTemplate(name) + if err != nil { + return err + } + + branch, err := template.BranchOut(location) + if err != nil { + return err + } + + template.EnterPivot(tags.NewMendExtend( + branch.Root(), + branch.Slot, + )) + + case template.thisText == "html": + template.EnterPivot( + tags.NewDefaultRoot( + template.thisText, + template.thisAttrs, + ), + ) + + default: + // Is it actually a self-closing tag with wrong syntax? + if slices.Contains(attrs.SelfClosingTags, template.thisText) { + return template.buildToken(html.SelfClosingTagToken) + } + template.EnterPivot( + tags.NewDefault( + template.thisText, + template.thisAttrs, + ), + ) + } + + case html.EndTagToken: + switch tag := template.Pivot().(type) { + case *tags.MendExtendTag: + if tag.Slot == nil { + fmt.Fprintf( + os.Stderr, + "WARN: (%s) couldn't find block inside of extended file. Skipping body\n", + template.Cursor(), + ) + } else { + tag.Slot.SetChildren(tag.Children) + } + } + template.ExitPivot() + } + + return nil +} diff --git a/main.go b/main.go index c4eb432..ea67527 100644 --- a/main.go +++ b/main.go @@ -1,24 +1,23 @@ package main import ( + "fmt" "os" "github.com/bbfh-dev/mend/cli" "github.com/bbfh-dev/parsex/v2" ) -var CLI = parsex.Program{ +var Runtime = parsex.Program{ Data: &cli.Options, Name: "mend", - Desc: "Mend is a simple HTML template processor designed to, but not limited to be used to generate static websites", + Desc: "HTML template processor designed to, but not limited to be used to generate static websites", Exec: cli.Main, -}.Runtime(). - SetVersion("0.2.0-alpha"). - SetPosArgs("html files...") +}.Runtime().SetVersion("1.0.0-alpha").SetPosArgs("html files...") func main() { - err := CLI.Run(os.Args[1:]) + err := Runtime.Run(os.Args[1:]) if err != nil { - os.Stderr.WriteString(err.Error() + "\n") + fmt.Fprintln(os.Stderr, err.Error()) } } diff --git a/mend/assert/assert.go b/mend/assert/assert.go deleted file mode 100644 index 3bda0d9..0000000 --- a/mend/assert/assert.go +++ /dev/null @@ -1,34 +0,0 @@ -package assert - -import ( - "fmt" - "reflect" -) - -const prefix = "(assertion failed)" - -func NotNil[T any](value any, message string, args ...any) { - if value == nil { - panic( - fmt.Sprintf( - "%s: %q is nil: %s", - prefix, - reflect.TypeOf(new(T)).String(), - fmt.Sprintf(message, args...), - ), - ) - } -} - -func NotEmpty[T any](value []T, message string, args ...any) { - if len(value) == 0 { - panic( - fmt.Sprintf( - "%s: %q is empty: %s", - prefix, - "[]"+reflect.TypeOf(new(T)).String(), - fmt.Sprintf(message, args...), - ), - ) - } -} diff --git a/mend/attrs/attributes.go b/mend/attrs/attributes.go deleted file mode 100644 index 84bba0b..0000000 --- a/mend/attrs/attributes.go +++ /dev/null @@ -1,105 +0,0 @@ -package attrs - -import ( - "strings" - - "golang.org/x/net/html" -) - -// Sorted HTML tag attributes key="value" -type Attributes struct { - order []string - values map[string]string -} - -func New(fromAttrs []html.Attribute) Attributes { - out := Attributes{ - order: []string{}, - values: map[string]string{}, - } - - for _, attr := range fromAttrs { - out.order = append(out.order, attr.Key) - out.values[attr.Key] = attr.Val - } - - return out.sort() -} - -func (attrs Attributes) IsEmpty() bool { - return len(attrs.order) == 0 -} - -func (attrs Attributes) ParamKeys() map[string]string { - out := map[string]string{} - for _, key := range attrs.order { - if strings.HasPrefix(key, ":") { - out[key[1:]] = attrs.values[key] - } - } - return out -} - -func (attrs Attributes) InheritAttributes() Attributes { - out := New([]html.Attribute{}) - for _, key := range attrs.order { - if strings.HasPrefix(key, "@") { - out.order = append(out.order, key[1:]) - out.values[key[1:]] = attrs.values[key] - } - } - return out -} - -func (attrs Attributes) Merge(overwrite Attributes) Attributes { - for _, key := range overwrite.order { - _, ok := attrs.values[key] - if !ok { - attrs.order = append(attrs.order, key) - } - attrs.values[key] = overwrite.values[key] - } - return attrs -} - -func (attrs Attributes) ParseExpressions( - source string, - fn func(string, string) (string, error), -) (Attributes, error) { - for key, value := range attrs.values { - newValue, err := fn(source, value) - if err != nil { - return attrs, err - } - attrs.values[key] = newValue - } - return attrs, nil -} - -func (attrs Attributes) ReplaceText(text string, with string) Attributes { - clone := Attributes{ - order: attrs.order, - values: map[string]string{}, - } - for key, value := range attrs.values { - clone.values[key] = strings.ReplaceAll(value, text, with) - } - return clone -} - -func (attrs Attributes) Get(key string) string { - return attrs.values[key] -} - -func (attrs Attributes) Contains(key string) bool { - _, ok := attrs.values[key] - return ok -} - -func (attrs Attributes) GetOrFallback(key string, fallback string) string { - value, ok := attrs.values[key] - if ok { - return value - } - return fallback -} diff --git a/mend/attrs/attributes_render.go b/mend/attrs/attributes_render.go deleted file mode 100644 index 33770e5..0000000 --- a/mend/attrs/attributes_render.go +++ /dev/null @@ -1,23 +0,0 @@ -package attrs - -import "fmt" - -func (attrs Attributes) Render(output writer) { - last := len(attrs.order) - 1 - - for i, key := range attrs.order { - attrs.renderKey(output, key) - if i != last { - output.WriteString(" ") - } - } -} - -func (attrs Attributes) renderKey(output writer, key string) { - if len(attrs.values[key]) == 0 { - output.WriteString(key) - return - } - - fmt.Fprintf(output, "%s=%q", key, attrs.values[key]) -} diff --git a/mend/attrs/writer.go b/mend/attrs/writer.go deleted file mode 100644 index 2427b07..0000000 --- a/mend/attrs/writer.go +++ /dev/null @@ -1,8 +0,0 @@ -package attrs - -import "io" - -type writer interface { - io.Writer - io.StringWriter -} diff --git a/mend/expression.go b/mend/expression.go deleted file mode 100644 index 995441b..0000000 --- a/mend/expression.go +++ /dev/null @@ -1,80 +0,0 @@ -package mend - -import ( - "fmt" - "strings" - - "github.com/tidwall/gjson" -) - -const bracketOpen = "[[" -const bracketClose = "]]" - -type modifier func(original string) string - -type Expression struct { - Variable string - DataSource string - Modifiers []modifier - Fallback string - IsNumber bool - notFound bool -} - -func (expression *Expression) String() string { - result := gjson.Get(expression.DataSource, expression.Variable) - if !result.Exists() { - expression.notFound = true - return expression.Fallback - } - value := result.String() - - for _, modify := range expression.Modifiers { - value = modify(value) - } - - return value -} - -func (expression *Expression) Err() error { - if expression.notFound && expression.Fallback == "" { - return fmt.Errorf("undefined parameter %q", expression.Variable) - } - return nil -} - -func ParseForExpressions(source string, text string) (string, error) { - var builder strings.Builder - i := 0 - - for { - start := strings.Index(text[i:], bracketOpen) - if start == -1 { - // No more expressions; write the remaining text. - builder.WriteString(text[i:]) - break - } - - builder.WriteString(text[i : i+start]) - i += start + len(bracketOpen) - - // Find the closing bracket. - end := strings.Index(text[i:], bracketClose) - if end == -1 { - // Unmatched bracket; treat the remainder as plain text. - builder.WriteString(bracketOpen) - builder.WriteString(text[i:]) - break - } - - // Evaluate the expression inside the brackets. - expr, err := ComputeExpression(source, strings.TrimSpace(text[i:i+end])) - if err != nil { - return builder.String(), err - } - builder.WriteString(expr) - i += end + len(bracketClose) - } - - return builder.String(), nil -} diff --git a/mend/expression_compute.go b/mend/expression_compute.go deleted file mode 100644 index bb5f54a..0000000 --- a/mend/expression_compute.go +++ /dev/null @@ -1,174 +0,0 @@ -package mend - -import ( - "fmt" - "strconv" - "strings" - "unicode" - - "github.com/bbfh-dev/mend/mend/settings" - "github.com/iancoleman/strcase" -) - -const tokenGlobal = "^" -const tokenVar = "." -const tokenModifier = "-" - -func ComputeExpression(source string, str string) (string, error) { - expression := &Expression{ - Variable: "", - DataSource: source, - Modifiers: []modifier{}, - Fallback: "", - IsNumber: false, - notFound: false, - } - - tokens := strings.Fields(str) - for i, token := range tokens { - if strings.HasPrefix(token, "@") { - // Why are we even computing an unparsed expression? - return "", nil - } - - if _, err := strconv.Atoi(token); err == nil { - expression.Variable = token - expression.IsNumber = true - continue - } - - if strings.HasPrefix(token, tokenGlobal) { - expression.DataSource = settings.GlobalParams - token = token[1:] - } - - if strings.HasPrefix(token, tokenVar) { - if expression.Variable != "" { - return "", fmt.Errorf( - "expression variable is set to %q but attempting to change it to %q", - expression.Variable, - token[1:], - ) - } - expression.Variable = token[1:] - continue - } - - if strings.HasPrefix(token, tokenModifier) { - switch token[1:] { - - case "capitalize": - expression.Modifiers = append(expression.Modifiers, func(original string) string { - if len(original) == 0 { - return "" - } - return strings.ToUpper(original[:1]) + original[1:] - }) - - case "invert": - expression.Modifiers = append(expression.Modifiers, func(original string) string { - return strings.Map(func(char rune) rune { - switch { - case unicode.IsLower(char): - return unicode.ToUpper(char) - case unicode.IsUpper(char): - return unicode.ToLower(char) - } - return char - }, original) - }) - - case "quote": - expression.Modifiers = append(expression.Modifiers, func(original string) string { - return fmt.Sprintf("%q", original) - }) - - case "get-length": - expression.Modifiers = append(expression.Modifiers, func(original string) string { - return fmt.Sprintf("%d", len(original)) - }) - - case "get-lines": - expression.Modifiers = append(expression.Modifiers, func(original string) string { - return fmt.Sprintf("%d", strings.Count(original, "\n")) - }) - - case "get-fields": - expression.Modifiers = append(expression.Modifiers, func(original string) string { - return fmt.Sprintf("%d", len(strings.Fields(original))) - }) - - case "to-upper": - expression.Modifiers = append(expression.Modifiers, func(original string) string { - return strings.ToUpper(original) - }) - - case "to-lower": - expression.Modifiers = append(expression.Modifiers, func(original string) string { - return strings.ToLower(original) - }) - - case "to-snake-case": - expression.Modifiers = append(expression.Modifiers, func(original string) string { - return strcase.ToSnake(original) - }) - - case "to-camel-case": - expression.Modifiers = append(expression.Modifiers, func(original string) string { - return strcase.ToLowerCamel(original) - }) - - case "to-pascal-case": - expression.Modifiers = append(expression.Modifiers, func(original string) string { - return strcase.ToCamel(original) - }) - - case "to-kebab-case": - expression.Modifiers = append(expression.Modifiers, func(original string) string { - return strcase.ToKebab(original) - }) - - default: - return "", fmt.Errorf("unknown modifier %q", token) - - } - continue - } - - if token == "==" || token == "!=" { - if len(tokens[i+1:]) == 0 { - return "", fmt.Errorf( - "expecting a value to the right of the equation of %q", - expression.Variable, - ) - } - compare := expression.Variable - if !expression.IsNumber { - compare = expression.String() - } - - var result = compare == tokens[i+1] - if token == "!=" { - result = !result - } - return fmt.Sprintf("%v", result), nil - } - - if token == "||" { - if len(tokens[i+1:]) == 0 { - expression.Fallback = " " - } else { - expression.Fallback = strings.Join(tokens[i+1:], " ") - } - break - } - - return "", fmt.Errorf("unexpected token %q", token) - } - - if expression.Variable == "" { - return "", fmt.Errorf("expression {{ %s }} does not contain any variables!", str) - } - - return expression.String(), expression.Err() -} diff --git a/mend/settings/settings.go b/mend/settings/settings.go deleted file mode 100644 index 1cd54b8..0000000 --- a/mend/settings/settings.go +++ /dev/null @@ -1,16 +0,0 @@ -package settings - -import ( - "io" - "strings" -) - -var GlobalParams string - -var KeepComments bool - -var IndentWith string = strings.Repeat(" ", 4) - -func WriteIndent(output io.StringWriter, indent int) { - output.WriteString(strings.Repeat(IndentWith, indent)) -} diff --git a/mend/tags/custom_extend.go b/mend/tags/custom_extend.go deleted file mode 100644 index e0348e7..0000000 --- a/mend/tags/custom_extend.go +++ /dev/null @@ -1,46 +0,0 @@ -package tags - -import ( - "errors" - "fmt" -) - -type CustomExtendNode struct { - *pairedNode - // Store all the nodes from the file it extends - Inner *pairedNode - // The node where contents will be placed - Slot NodeWithChildren -} - -func NewCustomExtendNode() *CustomExtendNode { - return &CustomExtendNode{ - pairedNode: newPairedNode(), - Inner: newPairedNode(), - } -} - -func (node *CustomExtendNode) Render(out writer, indent int) { - node.Inner.renderMinimal(out, indent) -} - -func (node *CustomExtendNode) Visible() bool { - return true -} - -func (node *CustomExtendNode) Clone() Node { - clone := *node - clone.pairedNode = clone.pairedNode.Clone() - return &clone -} - -func (node *CustomExtendNode) ParseExpressions(source string, fn expressionFunc) (err error) { - errs := make([]error, 0, len(node.Inner.Children)) - for _, child := range node.Inner.Children { - err = child.ParseExpressions(source, fn) - if err != nil { - errs = append(errs, fmt.Errorf(": %w", err)) - } - } - return errors.Join(errs...) -} diff --git a/mend/tags/custom_if.go b/mend/tags/custom_if.go deleted file mode 100644 index 8b613fc..0000000 --- a/mend/tags/custom_if.go +++ /dev/null @@ -1,50 +0,0 @@ -package tags - -import ( - "errors" - "strings" -) - -type CustomIfNode struct { - *pairedNode - Value string - Expect bool -} - -func NewCustomIfNode(value string, expect bool) *CustomIfNode { - return &CustomIfNode{ - pairedNode: newPairedNode(), - Value: value, - Expect: expect, - } -} - -func (node *CustomIfNode) check() bool { - return (node.Value == "true") == node.Expect -} - -func (node *CustomIfNode) Render(out writer, indent int) { - if node.check() { - node.renderMinimal(out, indent) - } -} - -func (node *CustomIfNode) Visible() bool { - return node.check() -} - -func (node *CustomIfNode) Clone() Node { - clone := *node - clone.pairedNode = clone.pairedNode.Clone() - return &clone -} - -func (node *CustomIfNode) ParseExpressions(source string, fn expressionFunc) (err error) { - node.Value, err = fn(source, node.Value) - return errors.Join(err, node.pairedNode.ParseExpressions(source, fn)) -} - -func (node *CustomIfNode) ReplaceText(text string, with string) { - node.Value = strings.ReplaceAll(node.Value, text, with) - node.pairedNode.ReplaceText(text, with) -} diff --git a/mend/tags/custom_range.go b/mend/tags/custom_range.go deleted file mode 100644 index 22d01fb..0000000 --- a/mend/tags/custom_range.go +++ /dev/null @@ -1,31 +0,0 @@ -package tags - -import ( - "github.com/tidwall/gjson" -) - -type CustomRangeNode struct { - *pairedNode - Name string - Values gjson.Result -} - -func NewCustomRangeNode(name string, values gjson.Result) *CustomRangeNode { - return &CustomRangeNode{ - pairedNode: newPairedNode(), - Name: name, - Values: values, - } -} - -func (node *CustomRangeNode) Render(out writer, indent int) {} - -func (node *CustomRangeNode) Visible() bool { - return false -} - -func (node *CustomRangeNode) Clone() Node { - clone := *node - clone.pairedNode = clone.pairedNode.Clone() - return &clone -} diff --git a/mend/tags/node.go b/mend/tags/node.go deleted file mode 100644 index d7c9e1e..0000000 --- a/mend/tags/node.go +++ /dev/null @@ -1,126 +0,0 @@ -package tags - -import ( - "errors" - "fmt" - - "github.com/bbfh-dev/mend/mend/attrs" -) - -type expressionFunc func(source, text string) (string, error) - -type Node interface { - Render(out writer, indent int) - Visible() bool - ParseExpressions(string, expressionFunc) error - ReplaceText(text string, with string) - Clone() Node - MergeAttributes(attrs attrs.Attributes) (ok bool) -} - -type NodeWithChildren interface { - Node - Add(...Node) -} - -// NOTE: Doesn't print the '>' in the end to allow for it to be used for both block and void tags -func renderOpeningTag(out writer, tag string, attributes attrs.Attributes) { - if attributes.IsEmpty() { - fmt.Fprintf(out, "<%s", tag) - return - } - - fmt.Fprintf(out, "<%s ", tag) - attributes.Render(out) -} - -func renderClosingTag(out writer, tag string) { - fmt.Fprintf(out, "", tag) -} - -// Shared fields/methods for paired nodes -// -// A paired node is: -type pairedNode struct { - Children []Node -} - -func newPairedNode() *pairedNode { - return &pairedNode{ - Children: []Node{}, - } -} - -func (node *pairedNode) renderMinimal(out writer, indent int) { - last := len(node.Children) - 1 - for i, child := range node.Children { - if !child.Visible() { - continue - } - child.Render(out, indent) - if i != last { - out.WriteString("\n") - } - } -} - -func (node *pairedNode) renderList(out writer, indent int) { - for _, child := range node.Children { - if !child.Visible() { - continue - } - child.Render(out, indent+1) - out.WriteString("\n") - } -} - -func (node *pairedNode) renderPadded(out writer, indent int) { - out.WriteString("\n") - for _, child := range node.Children { - if !child.Visible() { - continue - } - child.Render(out, indent) - out.WriteString("\n\n") - } -} - -func (node *pairedNode) Add(nodes ...Node) { - node.Children = append(node.Children, nodes...) -} - -func (node *pairedNode) ParseExpressions(source string, fn expressionFunc) (err error) { - errs := make([]error, 0, len(node.Children)) - for _, child := range node.Children { - err = child.ParseExpressions(source, fn) - if err != nil { - errs = append(errs, err) - } - } - return errors.Join(errs...) -} - -func (node *pairedNode) ReplaceText(text string, with string) { - for _, child := range node.Children { - child.ReplaceText(text, with) - } -} - -func (node *pairedNode) Clone() *pairedNode { - clone := *node - children := make([]Node, len(clone.Children)) - for i, child := range clone.Children { - children[i] = child.Clone() - } - clone.Children = children - return &clone -} - -func (node *pairedNode) MergeAttributes(attrs attrs.Attributes) bool { - for _, child := range node.Children { - if child.MergeAttributes(attrs) { - return true - } - } - return false -} diff --git a/mend/tags/node_comment.go b/mend/tags/node_comment.go deleted file mode 100644 index 4ed87b9..0000000 --- a/mend/tags/node_comment.go +++ /dev/null @@ -1,47 +0,0 @@ -package tags - -import ( - "fmt" - "strings" - - "github.com/bbfh-dev/mend/mend/attrs" - "github.com/bbfh-dev/mend/mend/settings" -) - -// Represents a block of text -type CommentNode struct { - Comment string -} - -func NewCommentNode(comment string) *CommentNode { - return &CommentNode{ - Comment: comment, - } -} - -func (node *CommentNode) Render(out writer, indent int) { - settings.WriteIndent(out, indent) - fmt.Fprintf(out, "", node.Comment) -} - -func (node *CommentNode) Visible() bool { - return true -} - -func (node *CommentNode) ParseExpressions(source string, fn expressionFunc) (err error) { - node.Comment, err = fn(source, node.Comment) - return err -} - -func (node *CommentNode) ReplaceText(text string, with string) { - node.Comment = strings.ReplaceAll(node.Comment, text, with) -} - -func (node *CommentNode) Clone() Node { - clone := *node - return &clone -} - -func (node *CommentNode) MergeAttributes(attrs attrs.Attributes) bool { - return false -} diff --git a/mend/tags/node_doctype.go b/mend/tags/node_doctype.go deleted file mode 100644 index 3c63558..0000000 --- a/mend/tags/node_doctype.go +++ /dev/null @@ -1,43 +0,0 @@ -package tags - -import ( - "fmt" - - "github.com/bbfh-dev/mend/mend/attrs" - "github.com/bbfh-dev/mend/mend/settings" -) - -type DoctypeNode struct { - Doctype string -} - -func NewDoctypeNode(doctype string) *DoctypeNode { - return &DoctypeNode{ - Doctype: doctype, - } -} - -func (node *DoctypeNode) Render(out writer, indent int) { - settings.WriteIndent(out, indent) - fmt.Fprintf(out, "", node.Doctype) -} - -func (node *DoctypeNode) Visible() bool { - return true -} - -func (node *DoctypeNode) ParseExpressions(source string, fn expressionFunc) (err error) { - return nil -} - -func (node *DoctypeNode) ReplaceText(text string, with string) { -} - -func (node *DoctypeNode) Clone() Node { - clone := *node - return &clone -} - -func (node *DoctypeNode) MergeAttributes(attrs attrs.Attributes) bool { - return false -} diff --git a/mend/tags/node_root.go b/mend/tags/node_root.go deleted file mode 100644 index 122bb40..0000000 --- a/mend/tags/node_root.go +++ /dev/null @@ -1,26 +0,0 @@ -package tags - -// Represents a simple paired node -type RootNode struct { - *pairedNode -} - -func NewRootNode() *RootNode { - return &RootNode{ - pairedNode: newPairedNode(), - } -} - -func (node *RootNode) Render(out writer, indent int) { - node.renderMinimal(out, indent) -} - -func (node *RootNode) Visible() bool { - return true -} - -func (node *RootNode) Clone() Node { - clone := *node - clone.pairedNode = clone.pairedNode.Clone() - return &clone -} diff --git a/mend/tags/node_tag.go b/mend/tags/node_tag.go deleted file mode 100644 index 6bd0a99..0000000 --- a/mend/tags/node_tag.go +++ /dev/null @@ -1,63 +0,0 @@ -package tags - -import ( - "errors" - - "github.com/bbfh-dev/mend/mend/attrs" - "github.com/bbfh-dev/mend/mend/settings" -) - -// Represents a regular HTML paired node -type TagNode struct { - *pairedNode - Tag string - Attributes attrs.Attributes -} - -func NewTagNode(tag string, attributes attrs.Attributes) *TagNode { - return &TagNode{ - Tag: tag, - Attributes: attributes, - pairedNode: newPairedNode(), - } -} - -func (node *TagNode) Render(out writer, indent int) { - settings.WriteIndent(out, indent) - renderOpeningTag(out, node.Tag, node.Attributes) - out.WriteString(">\n") - - if node.Tag == "html" { - node.renderPadded(out, indent) - } else { - node.renderList(out, indent) - } - - settings.WriteIndent(out, indent) - renderClosingTag(out, node.Tag) -} - -func (node *TagNode) Visible() bool { - return true -} - -func (node *TagNode) ParseExpressions(source string, fn expressionFunc) (err error) { - node.Attributes, err = node.Attributes.ParseExpressions(source, fn) - return errors.Join(err, node.pairedNode.ParseExpressions(source, fn)) -} - -func (node *TagNode) ReplaceText(text string, with string) { - node.Attributes = node.Attributes.ReplaceText(text, with) - node.pairedNode.ReplaceText(text, with) -} - -func (node *TagNode) Clone() Node { - clone := *node - clone.pairedNode = clone.pairedNode.Clone() - return &clone -} - -func (node *TagNode) MergeAttributes(attrs attrs.Attributes) bool { - node.Attributes = node.Attributes.Merge(attrs) - return true -} diff --git a/mend/tags/node_text.go b/mend/tags/node_text.go deleted file mode 100644 index 38aa8fc..0000000 --- a/mend/tags/node_text.go +++ /dev/null @@ -1,46 +0,0 @@ -package tags - -import ( - "strings" - - "github.com/bbfh-dev/mend/mend/attrs" - "github.com/bbfh-dev/mend/mend/settings" -) - -// Represents a block of text -type TextNode struct { - Text string -} - -func NewTextNode(text string) *TextNode { - return &TextNode{ - Text: text, - } -} - -func (node *TextNode) Render(out writer, indent int) { - settings.WriteIndent(out, indent) - out.WriteString(node.Text) -} - -func (node *TextNode) Visible() bool { - return true -} - -func (node *TextNode) ParseExpressions(source string, fn expressionFunc) (err error) { - node.Text, err = fn(source, node.Text) - return err -} - -func (node *TextNode) ReplaceText(text string, with string) { - node.Text = strings.ReplaceAll(node.Text, text, with) -} - -func (node *TextNode) Clone() Node { - clone := *node - return &clone -} - -func (node *TextNode) MergeAttributes(attrs attrs.Attributes) bool { - return false -} diff --git a/mend/tags/node_void.go b/mend/tags/node_void.go deleted file mode 100644 index 595cba8..0000000 --- a/mend/tags/node_void.go +++ /dev/null @@ -1,48 +0,0 @@ -package tags - -import ( - "github.com/bbfh-dev/mend/mend/attrs" - "github.com/bbfh-dev/mend/mend/settings" -) - -// Represents a self-closing HTML tag -type VoidNode struct { - Void string - Attributes attrs.Attributes -} - -func NewVoidNode(tag string, attrs attrs.Attributes) *VoidNode { - return &VoidNode{ - Void: tag, - Attributes: attrs, - } -} - -func (node *VoidNode) Render(out writer, indent int) { - settings.WriteIndent(out, indent) - renderOpeningTag(out, node.Void, node.Attributes) - out.WriteString(" />") -} - -func (node *VoidNode) Visible() bool { - return true -} - -func (node *VoidNode) ParseExpressions(source string, fn expressionFunc) (err error) { - node.Attributes, err = node.Attributes.ParseExpressions(source, fn) - return err -} - -func (node *VoidNode) ReplaceText(text string, with string) { - node.Attributes = node.Attributes.ReplaceText(text, with) -} - -func (node *VoidNode) Clone() Node { - clone := *node - return &clone -} - -func (node *VoidNode) MergeAttributes(attrs attrs.Attributes) bool { - node.Attributes = node.Attributes.Merge(attrs) - return true -} diff --git a/mend/tags/tags.go b/mend/tags/tags.go deleted file mode 100644 index 6414f37..0000000 --- a/mend/tags/tags.go +++ /dev/null @@ -1,17 +0,0 @@ -package tags - -const TAG_INCLUDE = "include" -const TAG_SLOT = "slot" -const TAG_EXTEND = "extend" -const TAG_RANGE = "range" -const TAG_IF = "if" -const TAG_UNLESS = "unless" - -var AllTags = []string{ - TAG_INCLUDE, - TAG_SLOT, - TAG_EXTEND, - TAG_RANGE, - TAG_IF, - TAG_UNLESS, -} diff --git a/mend/tags/writer.go b/mend/tags/writer.go deleted file mode 100644 index 5550568..0000000 --- a/mend/tags/writer.go +++ /dev/null @@ -1,8 +0,0 @@ -package tags - -import "io" - -type writer interface { - io.Writer - io.StringWriter -} diff --git a/mend/template.go b/mend/template.go deleted file mode 100644 index ea72c10..0000000 --- a/mend/template.go +++ /dev/null @@ -1,63 +0,0 @@ -package mend - -import ( - "github.com/bbfh-dev/mend/mend/assert" - "github.com/bbfh-dev/mend/mend/attrs" - "github.com/bbfh-dev/mend/mend/tags" - "golang.org/x/net/html" -) - -const MEND_PREFIX = "mend:" -const PKG_PREFIX = "pkg:" - -type Template struct { - Name string - Params string - - Root tags.NodeWithChildren - Slot tags.NodeWithChildren - - currentLine int - currentToken html.Token - currentText string - currentAttrs attrs.Attributes - // A list of current parents from greatest to closest - breadcrumbs []tags.NodeWithChildren -} - -func NewTemplate(name string, params string) *Template { - root := tags.NewRootNode() - return &Template{ - Name: name, - Params: params, - Root: root, - Slot: nil, - currentLine: 1, - currentToken: html.Token{}, - currentAttrs: attrs.Attributes{}, - currentText: "", - breadcrumbs: []tags.NodeWithChildren{root}, - } -} - -func (template *Template) lastBreadcrumb() tags.NodeWithChildren { - assert.NotEmpty(template.breadcrumbs, "template must never have no breadcrumbs left") - return template.breadcrumbs[len(template.breadcrumbs)-1] -} - -func (template *Template) grandParent() tags.NodeWithChildren { - switch len(template.breadcrumbs) { - case 0, 1: - return template.Root - } - return template.breadcrumbs[len(template.breadcrumbs)-2] -} - -func (template *Template) append(nodes ...tags.Node) { - template.lastBreadcrumb().Add(nodes...) -} - -func (template *Template) appendLevel(node tags.NodeWithChildren) { - template.append(node) - template.breadcrumbs = append(template.breadcrumbs, node) -} diff --git a/mend/template_branch.go b/mend/template_branch.go deleted file mode 100644 index ee56713..0000000 --- a/mend/template_branch.go +++ /dev/null @@ -1,34 +0,0 @@ -package mend - -import ( - "encoding/json" - "io/fs" - "os" -) - -func (template *Template) branchOut(src string) (*Template, error) { - var file fs.File - var err error - - file, err = os.OpenFile(src, os.O_RDONLY, os.ModePerm) - - if err != nil { - return nil, err - } - defer file.Close() - - data, err := json.Marshal(template.currentAttrs.ParamKeys()) - if err != nil { - return nil, template.errInternal(err) - } - - branch := NewTemplate(src, string(data)) - err = branch.Parse(file) - if err != nil { - return nil, template.errBranch(err) - } - - branch.Root.MergeAttributes(template.currentAttrs.InheritAttributes()) - - return branch, nil -} diff --git a/mend/template_errors.go b/mend/template_errors.go deleted file mode 100644 index 1f829a9..0000000 --- a/mend/template_errors.go +++ /dev/null @@ -1,41 +0,0 @@ -package mend - -import ( - "fmt" - "strings" - - "github.com/bbfh-dev/mend/mend/tags" -) - -func (template *Template) errUnknownTag() error { - var help strings.Builder - for _, tag := range tags.AllTags { - fmt.Fprintf(&help, "<%s> ", tag) - } - - return fmt.Errorf( - "unknown custom tag <%s>.\nPossible tags are: %s", - template.currentText, - help.String(), - ) -} - -func (template *Template) errInternal(err error) error { - return fmt.Errorf("(internal) %w", err) -} - -func (template *Template) errBranch(err error) error { - return fmt.Errorf("-> %w", err) -} - -func (template *Template) errMissingAttribute(attr string) error { - return fmt.Errorf( - "tag <%s> is missing attribute %q", - template.currentText, - attr, - ) -} - -func (template *Template) errUndefinedParam(param string) error { - return fmt.Errorf("undefined parameter %q", param) -} diff --git a/mend/template_find.go b/mend/template_find.go deleted file mode 100644 index b53f0bb..0000000 --- a/mend/template_find.go +++ /dev/null @@ -1,32 +0,0 @@ -package mend - -import ( - "io/fs" - "path/filepath" -) - -func (template *Template) Find(name string) (string, bool) { - var result string - - filepath.WalkDir( - filepath.Dir(template.Name), - func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - - if entry.IsDir() { - return nil - } - - if entry.Name() == name { - result = path - return filepath.SkipAll - } - - return nil - }, - ) - - return result, result != "" -} diff --git a/mend/template_parse.go b/mend/template_parse.go deleted file mode 100644 index 28d55a5..0000000 --- a/mend/template_parse.go +++ /dev/null @@ -1,258 +0,0 @@ -package mend - -import ( - "fmt" - "io" - "path/filepath" - "slices" - "strings" - - "github.com/bbfh-dev/mend/mend/attrs" - "github.com/bbfh-dev/mend/mend/settings" - "github.com/bbfh-dev/mend/mend/tags" - "github.com/tidwall/gjson" - "golang.org/x/net/html" -) - -func (template *Template) Parse(reader io.Reader) error { - tokenizer := html.NewTokenizer(reader) - -loop: - for { - tokenType := tokenizer.Next() - switch tokenType { - - case html.ErrorToken: - if tokenizer.Err() == io.EOF { - break loop - } - return fmt.Errorf( - "(%s) %w", - filepath.Base(template.Name), - tokenizer.Err(), - ) - - case html.TextToken: - template.currentLine += strings.Count(template.currentToken.Data, "\n") - - } - - template.currentToken = tokenizer.Token() - template.currentAttrs = attrs.New(template.currentToken.Attr) - template.currentText = strings.TrimSpace(template.currentToken.Data) - - err := template.process(tokenType) - if err != nil { - return fmt.Errorf( - "(%s:%d) %w", - filepath.Base(template.Name), - template.currentLine, - err, - ) - } - } - - err := template.Root.ParseExpressions(template.Params, ParseForExpressions) - if err != nil { - return fmt.Errorf("(%s | Expression) %w", filepath.Base(template.Name), err) - } - - return nil -} - -func (template *Template) process(tokenType html.TokenType) error { - switch tokenType { - - case html.DoctypeToken: - template.append(tags.NewDoctypeNode(template.currentText)) - - case html.CommentToken: - if settings.KeepComments { - template.append(tags.NewCommentNode(template.currentText)) - } - - case html.TextToken: - if len(template.currentText) == 0 { - break - } - - var builder strings.Builder - lines := strings.Split(template.currentText, "\n") - lastLine := len(lines) - 1 - for i, line := range lines { - builder.WriteString(strings.TrimSpace(line)) - if i != lastLine { - builder.WriteString(" ") - } - } - template.append(tags.NewTextNode(builder.String())) - - case html.SelfClosingTagToken: - switch { - - case strings.HasPrefix(template.currentText, MEND_PREFIX): - switch strings.TrimPrefix(template.currentText, MEND_PREFIX) { - - case tags.TAG_INCLUDE: - if !template.currentAttrs.Contains("src") { - return template.errMissingAttribute("src") - } - src := template.currentAttrs.Get("src") - src = filepath.Join(filepath.Dir(template.Name), src) - branch, err := template.branchOut(src) - if err != nil { - return err - } - template.append(branch.Root) - - case tags.TAG_SLOT: - node := tags.NewRootNode() - template.append(node) - template.Slot = node - - default: - return template.errUnknownTag() - } - - case strings.HasPrefix(template.currentText, PKG_PREFIX): - tag := strings.TrimPrefix(template.currentText, PKG_PREFIX) - location, exists := template.Find(tag + ".html") - if !exists { - return fmt.Errorf( - "can't resolve <%s> inside of %s", - template.currentText, - filepath.Dir(template.Name), - ) - } - branch, err := template.branchOut(location) - if err != nil { - return err - } - template.append(branch.Root) - - default: - node := tags.NewVoidNode(template.currentText, template.currentAttrs) - template.append(node) - return nil - } - - case html.StartTagToken: - switch { - - case strings.HasPrefix(template.currentText, MEND_PREFIX): - switch strings.TrimPrefix(template.currentText, MEND_PREFIX) { - - case tags.TAG_EXTEND: - if !template.currentAttrs.Contains("src") { - return template.errMissingAttribute("src") - } - src := template.currentAttrs.Get("src") - src = filepath.Join(filepath.Dir(template.Name), src) - branch, err := template.branchOut(src) - if err != nil { - return err - } - node := tags.NewCustomExtendNode() - node.Inner.Add(branch.Root) - node.Slot = branch.Slot - template.appendLevel(node) - - case tags.TAG_RANGE: - if !template.currentAttrs.Contains("for") { - return template.errMissingAttribute("for") - } - variable := template.currentAttrs.Get("for") - - var result gjson.Result - if strings.HasPrefix(variable, "^.") { - result = gjson.Get(settings.GlobalParams, variable[2:]) - } else { - result = gjson.Get(template.Params, variable) - } - - if !result.Exists() { - return template.errUndefinedParam(variable) - } - if !result.IsArray() { - return fmt.Errorf( - "parameter %q is not an array! It's set to: `%s`", - variable, - result.String(), - ) - } - node := tags.NewCustomRangeNode(variable, result) - template.appendLevel(node) - - case tags.TAG_IF: - node := tags.NewCustomIfNode( - template.currentAttrs.GetOrFallback("value", "true"), - true, - ) - template.appendLevel(node) - - case tags.TAG_UNLESS: - node := tags.NewCustomIfNode( - template.currentAttrs.GetOrFallback("value", "true"), - false, - ) - template.appendLevel(node) - - default: - return template.errUnknownTag() - } - - case strings.HasPrefix(template.currentText, PKG_PREFIX): - tag := strings.TrimPrefix(template.currentText, PKG_PREFIX) - location, exists := template.Find(tag + ".html") - if !exists { - return fmt.Errorf( - "can't resolve <%s> inside of %s", - template.currentText, - filepath.Dir(template.Name), - ) - } - branch, err := template.branchOut(location) - if err != nil { - return err - } - node := tags.NewCustomExtendNode() - node.Inner.Add(branch.Root) - node.Slot = branch.Slot - template.appendLevel(node) - - default: - // Is it actually a self-closing tag with wrong syntax? - if slices.Contains(attrs.SelfClosingTags, template.currentText) { - return template.process(html.SelfClosingTagToken) - } - - node := tags.NewTagNode(template.currentText, template.currentAttrs) - template.appendLevel(node) - return nil - } - - case html.EndTagToken: - if len(template.breadcrumbs) == 1 { - break - } - switch node := template.lastBreadcrumb().(type) { - - case *tags.CustomExtendNode: - if node.Slot != nil { - node.Slot.Add(node.Children...) - } - - case *tags.CustomRangeNode: - for i := range node.Values.Array() { - clone := node.Clone().(*tags.CustomRangeNode) - clone.ReplaceText("@index", fmt.Sprintf("%d", i)) - clone.ReplaceText("@.", fmt.Sprintf("%s.%d.", node.Name, i)) - template.grandParent().Add(clone.Children...) - } - - } - template.breadcrumbs = template.breadcrumbs[:len(template.breadcrumbs)-1] - } - - return nil -}