From 72a386b179b3b8f519c82ef9e79232222ec91531 Mon Sep 17 00:00:00 2001 From: early Date: Tue, 27 Aug 2024 17:21:30 -0600 Subject: [PATCH] revise compute pattern, add context to OnLoad --- README.md | 24 ++++++++++++ README.txt | 16 -------- cmd/standard-test/index.html | 3 +- cmd/standard-test/main.go | 14 +++++++ component/component.go | 10 ++++- include/include.go | 67 --------------------------------- internal/compile/compile.go | 8 ++++ internal/compile/compute.go | 25 +++++++++--- internal/compile/template.go | 63 ++++++++++++++++++++++++------- page/page.go | 17 ++++++--- part/part.go | 15 ++++++-- render/data.go | 15 +++++++- standard/part/contact_form.go | 9 ++++- standard/part/contact_form.html | 3 +- 14 files changed, 170 insertions(+), 119 deletions(-) create mode 100644 README.md delete mode 100644 README.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..7cd3473 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# Today Engine + +The Today engine builds on Go's templating with reusable parts in the style of +the Web Components standard. It aims to be usable for anything from static HTML +websites to complex applications with a mix of server and client-side behavior. + +Author: Early N. +License: MIT + +## Installation + +go get git.earlybird.gay/today-engine@latest + +## Why? + +In short, to help guide new developers in making a website (today). The +technologies that dominate the modern web are complex in unique ways that do +not promote foundational knowledge of the web platform. Today focuses on: + +- Building websites from a basis of standard HTML/CSS +- Promoting use of the Web Components standard of JS +- Expanding on Go's robust template system with reusable chunks of HTML + +## How? diff --git a/README.txt b/README.txt deleted file mode 100644 index 9a43967..0000000 --- a/README.txt +++ /dev/null @@ -1,16 +0,0 @@ -===== Today Engine ============================================================= - -The Today engine builds on Go's templating with reusable parts in the style of -the Web Components standard. It aims to be usable for anything from static HTML -websites to complex applications with a mix of server and client-side behavior. - -Author: Early N. -License: MIT - -===== Install ================================================================== - -go get git.earlybird.gay/today-engine@latest - -===== Usage ==================================================================== - -I'm working on a by-example application to help with this. diff --git a/cmd/standard-test/index.html b/cmd/standard-test/index.html index ca3b29a..a5e890c 100644 --- a/cmd/standard-test/index.html +++ b/cmd/standard-test/index.html @@ -7,7 +7,8 @@
- + +
diff --git a/cmd/standard-test/main.go b/cmd/standard-test/main.go index d2ed56c..f90d82e 100644 --- a/cmd/standard-test/main.go +++ b/cmd/standard-test/main.go @@ -1,6 +1,7 @@ package main import ( + "html/template" "net/http" "syscall" @@ -14,6 +15,16 @@ var Page = page.New("index", "index.html", page.Includes( stdpart.ContactForm([]string{"Feedback"}), ), + page.Funcs(template.FuncMap{ + "SliceOfLen": func(i int) []int { + out := make([]int, i) + for i := range i { + out[i] = i + } + return out + }, + }), + page.Pretty(" "), ) func main() { @@ -23,7 +34,10 @@ func main() { app.Handle("GET /{$}", Page) app.Handle("GET /", http.FileServer(http.Dir("public"))) + app.Handle("GET /contact", stdpart.HandleContactForm(nil, stdpart.PrintContact)) app.Handle("POST /contact", stdpart.HandleContactForm(nil, stdpart.PrintContact)) + // fmt.Println(Page.Raw()) + app.ListenAndServe() } diff --git a/component/component.go b/component/component.go index b918c50..22370e2 100644 --- a/component/component.go +++ b/component/component.go @@ -7,8 +7,9 @@ import ( ) type Component struct { - name string - source include.Opener + name string + fileName string + source include.Opener includes []compile.Source } @@ -25,6 +26,7 @@ func New(name string, source string, optional ...func(*Component)) *Component { p := new(Component) // Assign basic parameters p.name = name + p.fileName = source p.source = include.File(source, "git.earlybird.gay/today-engine/component") // Run optional arguments for _, of := range optional { @@ -37,6 +39,10 @@ func (p *Component) Name() string { return p.name } +func (p *Component) FileName() string { + return p.fileName +} + func (p *Component) File() include.Opener { return p.source } diff --git a/include/include.go b/include/include.go index 0cb284e..874e62a 100644 --- a/include/include.go +++ b/include/include.go @@ -2,19 +2,9 @@ package include import ( - "errors" - "fmt" "io" "os" "path" - "runtime/debug" - "strings" -) - -var ( - ErrNoRuntime = errors.New("could not access runtime for caller info") - ErrNoGoPath = errors.New("GOPATH is unset") - ErrFileNotAllowedInMain = errors.New("include.File can't be called in package main") ) type Opener interface { @@ -27,63 +17,6 @@ func (opener OpenerFunc) Open() (io.ReadCloser, error) { return opener() } -// dependencies maps module names to module paths (name@version). -var modPaths map[string]string - -func initSource() (err error) { - err = readModPaths() - if err != nil { - return - } - return -} - -// readDependencies reads the name and version of all module dependencies and -// stores them as a map of module name -> module path. -func readModPaths() error { - buildInfo, ok := debug.ReadBuildInfo() - if !ok { - return ErrNoRuntime - } - goPath := os.Getenv("GOPATH") - if goPath == "" { - return ErrNoGoPath - } - workingPath, err := os.Getwd() - if err != nil { - return err - } - modPaths = make(map[string]string) - for _, dep := range buildInfo.Deps { - modPaths[dep.Path] = path.Join(goPath, fmt.Sprintf("%s@%s", dep.Path, dep.Version)) - } - thisModPath := buildInfo.Main.Path - modPaths[thisModPath] = workingPath - return nil -} - -// getPackagePath gets a path to a package on the filesystem based on its name. -func getPackagePath(packageName string) (filepath string) { - for modName, modPath := range modPaths { - if packPath, ok := strings.CutPrefix(packageName, modName); ok { - return path.Join(modPath, packPath) - } - } - return -} - -// openSource opens a file in package packageName on the filesystem. -func openSource(packageName string, filepath string) (r io.ReadCloser, err error) { - ppath := getPackagePath(packageName) - if ppath == "" { - err = errors.New("tried to get source for unknown package " + packageName) - return - } - absPath := path.Join(ppath, filepath) - r, err = os.Open(absPath) - return -} - // File returns an Opener that Opens() a file relative to the calling package. func File(relativePath string, ignorePackages ...string) Opener { var root string diff --git a/internal/compile/compile.go b/internal/compile/compile.go index 16554a0..6b84682 100644 --- a/internal/compile/compile.go +++ b/internal/compile/compile.go @@ -15,6 +15,7 @@ import ( type Source interface { Name() string + FileName() string File() include.Opener Includes() []Source } @@ -57,6 +58,12 @@ func Compile(root TemplateSource, transform ...func(root *html.Node)) (Result, e name: "root", compute: root.OnLoad(), } + // Insert an assignment to $compute so we can always raise scope, even + // in pipelines + document.InsertBefore(&html.Node{ + Type: html.TextNode, + Data: "{{ $compute := .compute }}", + }, document.FirstChild) // Insert component sources into document for name, subSource := range fullDependencies { @@ -94,6 +101,7 @@ func Compile(root TemplateSource, transform ...func(root *html.Node)) (Result, e if err != nil { return err } + // Add compute node for subsource c.children = append(c.children, computeSubSource) childComputeNode = computeSubSource } diff --git a/internal/compile/compute.go b/internal/compile/compute.go index d14e84b..79e6bcb 100644 --- a/internal/compile/compute.go +++ b/internal/compile/compute.go @@ -12,6 +12,7 @@ import ( type computeNode struct { name string + htmlId string compute render.OnLoadFunc asDataFromParent map[string]string raiseFromParent []string @@ -24,11 +25,23 @@ func (root *computeNode) Compute(r *http.Request) (render.Data, error) { cData.Set(key, pData.Get(key)) } } + ctx := r.Context() var f func(n *computeNode, data render.Data) error f = func(n *computeNode, data render.Data) error { - n.compute(data) - computedData := make(render.Data) + // Set the htmlId of the component if it exists as "id" + if n.htmlId != "" { + data.Set("id", n.htmlId) + } + n.compute(ctx, data) + computeFuncs := make(render.Data) for _, child := range n.children { + computeFuncName := child.name + if child.htmlId != "" { + computeFuncName += "_" + snakeCase(child.htmlId) + } + if computeFuncs.IsSet(computeFuncName) { + continue + } childData := render.NewData(r) // Stuff here is available to the OnLoad AND renderer: // Assign all data from parent @@ -48,13 +61,15 @@ func (root *computeNode) Compute(r *http.Request) (render.Data, error) { childParentData := make(render.Data) impose(childParentData, data, child.raiseFromParent) childData.Set("parent", childParentData) - if child.name != "" { - computedData.Set(child.name, childData) + if computeFuncName != "" { + computeFuncs.Set(computeFuncName, func() (render.Data, error) { + return childData, child.compute(ctx, childData) + }) } f(child, childData) // Stuff here is ONLY available to the renderer: } - data.Set("computed", computedData) + data.Set("compute", computeFuncs) return nil } diff --git a/internal/compile/template.go b/internal/compile/template.go index bbac337..4f67c2d 100644 --- a/internal/compile/template.go +++ b/internal/compile/template.go @@ -5,10 +5,10 @@ import ( "errors" "fmt" "regexp" + "slices" "strings" "git.earlybird.gay/today-engine/htmltree" - "git.earlybird.gay/today-engine/render" "golang.org/x/net/html" ) @@ -18,7 +18,8 @@ const ( ) var funcRegexp = regexp.MustCompile(`^[a-zA-Z]\w*$`) -var fieldRegexp = regexp.MustCompile(`^(?:.[a-zA-Z]\w*)+$`) +var fieldRegexp = regexp.MustCompile(`^(?:\.[a-zA-Z]\w*)+$`) +var templateRegexp = regexp.MustCompile(`\{\{.*\}\}`) var pipelineTokens = []string{"with", "if"} func isFunc(token string) bool { @@ -29,6 +30,10 @@ func isField(token string) bool { return fieldRegexp.MatchString(token) } +func containsTemplate(token string) bool { + return templateRegexp.MatchString(token) +} + func isPipeline(token string) bool { for _, ptok := range pipelineTokens { if strings.Contains(token, ptok) { @@ -42,6 +47,16 @@ func isEndPipeline(token string) bool { return strings.Contains(token, "end") } +func getAttr(n *html.Node, key string) string { + idx := slices.IndexFunc(n.Attr, func(attr html.Attribute) bool { + return attr.Key == key + }) + if idx == -1 { + return "" + } + return n.Attr[idx].Val +} + // replaceTemplateFuncs replaces template functions in a node with their // namespaced versions. func replaceTemplateFuncs(source TemplateSource, n *html.Node) { @@ -235,8 +250,7 @@ func removeDataAttrs(n *html.Node) map[string]string { } func insertTemplateSource(subSource TemplateSource, context *html.Node) (*computeNode, error) { - insertedId := render.ID() - computedName := fmt.Sprintf("%s_%s", snakeCase(subSource.Name()), insertedId) + computedName := snakeCase(subSource.Name()) // ===== CONTEXT PREPROCESSING ===== // Raise any fields pre-existing in the context to .parent.field. raiseFields := make([]string, 0) @@ -247,7 +261,6 @@ func insertTemplateSource(subSource TemplateSource, context *html.Node) (*comput // Remove :data attributes from the root of context, and prepare them // to be used as data for the computeNode. asData := removeDataAttrs(context) - asData["id"] = insertedId // ===== SUBSOURCE PROCESSING ===== // Parse the subSource in the context of context. // Require that subSource be contained in a template. @@ -338,22 +351,44 @@ func insertTemplateSource(subSource TemplateSource, context *html.Node) (*comput }) // Generate a computeNode for this part. + htmlId := getAttr(context, "id") + scopeName := computedName + "_" + snakeCase(htmlId) + if containsTemplate(htmlId) { + htmlId = "" + scopeName = computedName + } compute := &computeNode{ name: computedName, + htmlId: htmlId, compute: subSource.OnLoad(), asDataFromParent: asData, raiseFromParent: raiseFields, } - // Set ID and computed context, then generate a function that fulfills it - htmltree.SetAttr(context, "id", insertedId) - context.InsertBefore(&html.Node{ + // Insert scope up/down template pipeline + head := context.FirstChild + up, down := scopeNodes(scopeName) + context.InsertBefore(up, head) + // Insert an assignment to $compute so we can always raise + // scope, even in pipelines + n.InsertBefore(&html.Node{ Type: html.TextNode, - Data: fmt.Sprintf("{{- with .computed.%s }}", computedName), - }, context.FirstChild) - context.InsertBefore(&html.Node{ - Type: html.TextNode, - Data: "{{ end -}}", - }, nil) + Data: "{{ $compute := .compute }}", + }, head) + context.InsertBefore(down, nil) return compute, nil } + +func scopeNodes(scopeName string) (up, down *html.Node) { + upVal := fmt.Sprintf(`{{- $data := call $compute.%s }}{{ with $data }}`, scopeName) + up = &html.Node{ + Type: html.TextNode, + Data: upVal, + } + downVal := `{{ end -}}` + down = &html.Node{ + Type: html.TextNode, + Data: downVal, + } + return up, down +} diff --git a/page/page.go b/page/page.go index 416b10b..16b8a1f 100644 --- a/page/page.go +++ b/page/page.go @@ -2,6 +2,7 @@ package page import ( + "context" "html/template" "git.earlybird.gay/today-engine/htmltree" @@ -11,9 +12,10 @@ import ( ) type Page struct { - name string - source include.Opener - raw string + name string + fileName string + source include.Opener + raw string includes []compile.Source onLoad render.OnLoadFunc @@ -40,7 +42,7 @@ func Includes(includes ...compile.Source) Config { } } -func OnLoad(f func(data render.Data) error) Config { +func OnLoad(f render.OnLoadFunc) Config { return func(p *Page) { p.onLoad = f } @@ -56,8 +58,9 @@ func New(name string, source string, optional ...func(*Page)) *Page { p := new(Page) // Assign basic parameters p.name = name + p.fileName = source p.source = include.File(source, "git.earlybird.gay/today-engine/page") - p.onLoad = func(d render.Data) error { + p.onLoad = func(ctx context.Context, d render.Data) error { return nil } p.templateFuncs = make(template.FuncMap) @@ -96,6 +99,10 @@ func (p *Page) Name() string { return p.name } +func (p *Page) FileName() string { + return p.fileName +} + func (p *Page) File() include.Opener { return p.source } diff --git a/part/part.go b/part/part.go index cefe05c..ceb8293 100644 --- a/part/part.go +++ b/part/part.go @@ -2,6 +2,7 @@ package part import ( + "context" "text/template" "git.earlybird.gay/today-engine/include" @@ -10,8 +11,9 @@ import ( ) type Part struct { - name string - source include.Opener + name string + fileName string + source include.Opener includes []compile.Source onLoad render.OnLoadFunc @@ -34,7 +36,7 @@ func Includes(includes ...compile.Source) Config { } } -func OnLoad(f func(data render.Data) error) Config { +func OnLoad(f render.OnLoadFunc) Config { return func(p *Part) { p.onLoad = f } @@ -44,8 +46,9 @@ func New(name string, source string, optional ...func(*Part)) *Part { p := new(Part) // Assign basic parameters p.name = name + p.fileName = source p.source = include.File(source, "git.earlybird.gay/today-engine/part") - p.onLoad = func(d render.Data) error { + p.onLoad = func(ctx context.Context, data render.Data) error { return nil } p.templateFuncs = make(template.FuncMap) @@ -60,6 +63,10 @@ func (p *Part) Name() string { return p.name } +func (p *Part) FileName() string { + return p.fileName +} + func (p *Part) File() include.Opener { return p.source } diff --git a/render/data.go b/render/data.go index d76dc2f..4e38e9e 100644 --- a/render/data.go +++ b/render/data.go @@ -2,6 +2,7 @@ package render import ( + "context" "net/http" "regexp" ) @@ -20,12 +21,22 @@ func (d Data) Get(key string) any { return d[key] } +func (d Data) IsSet(key string) bool { + _, ok := d[key] + return ok +} + func (d Data) Request() *http.Request { return d.Get("request").(*http.Request) } +// ID gets the *static* HTML id attribute of the component that owns this data. +// If the ID cannot be determined at compile time, this returns an empty string. func (d Data) ID() string { - return d.Get("id").(string) + if id, ok := d.Get("id").(string); ok { + return id + } + return "" } // Set sets key to value. @@ -36,7 +47,7 @@ func (d Data) Set(key string, value any) { d[key] = value } -type OnLoadFunc func(Data) error +type OnLoadFunc func(context.Context, Data) error type Loader interface { Compute(r *http.Request) (Data, error) } diff --git a/standard/part/contact_form.go b/standard/part/contact_form.go index 45515fc..f8aae36 100644 --- a/standard/part/contact_form.go +++ b/standard/part/contact_form.go @@ -1,6 +1,7 @@ package stdpart import ( + "context" "errors" "fmt" "net/http" @@ -45,7 +46,7 @@ func parseContactFormResponse(r *http.Request) (ContactFormResponse, error) { func ContactForm(categories []string) *part.Part { return part.New("stdp-contact-form", "contact_form.html", - part.OnLoad(func(data render.Data) error { + part.OnLoad(func(ctx context.Context, data render.Data) error { data.Set("categories", categories) // Set message if one is given for this part @@ -74,7 +75,11 @@ func HandleContactForm(validate, handle func(ContactFormResponse) error) http.Ha callback := r.FormValue("callback") respond := func(ok bool, msg string) { msg = url.QueryEscape(msg) - w.Header().Set("Location", fmt.Sprintf("%s?resp_to=%s&success=%t&message=%s", callback, resp.ID, ok, msg)) + redirect := fmt.Sprintf("%s?success=%t&message=%s", callback, ok, msg) + if resp.ID != "" { + redirect += fmt.Sprintf("&resp_to=%s", resp.ID) + } + w.Header().Set("Location", redirect) w.WriteHeader(http.StatusSeeOther) } if err != nil { diff --git a/standard/part/contact_form.html b/standard/part/contact_form.html index 273ed38..9cbc6ae 100644 --- a/standard/part/contact_form.html +++ b/standard/part/contact_form.html @@ -1,5 +1,6 @@