--- /dev/null
+# 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?
+++ /dev/null
-===== 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.
<body>
<main>
- <stdp-contact-form :action="/contact" :method="POST"></stdp-contact-form>
+ <stdp-contact-form :action="/contact" id="cf-get"></stdp-contact-form>
+ <stdp-contact-form :action="/contact" :method="POST" id="cf-post"></stdp-contact-form>
</main>
</body>
package main
import (
+ "html/template"
"net/http"
"syscall"
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() {
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()
}
)
type Component struct {
- name string
- source include.Opener
+ name string
+ fileName string
+ source include.Opener
includes []compile.Source
}
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 {
return p.name
}
+func (p *Component) FileName() string {
+ return p.fileName
+}
+
func (p *Component) File() include.Opener {
return p.source
}
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 {
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
type Source interface {
Name() string
+ FileName() string
File() include.Opener
Includes() []Source
}
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 {
if err != nil {
return err
}
+ // Add compute node for subsource
c.children = append(c.children, computeSubSource)
childComputeNode = computeSubSource
}
type computeNode struct {
name string
+ htmlId string
compute render.OnLoadFunc
asDataFromParent map[string]string
raiseFromParent []string
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
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
}
"errors"
"fmt"
"regexp"
+ "slices"
"strings"
"git.earlybird.gay/today-engine/htmltree"
- "git.earlybird.gay/today-engine/render"
"golang.org/x/net/html"
)
)
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 {
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) {
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) {
}
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)
// 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.
})
// 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
+}
package page
import (
+ "context"
"html/template"
"git.earlybird.gay/today-engine/htmltree"
)
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
}
}
-func OnLoad(f func(data render.Data) error) Config {
+func OnLoad(f render.OnLoadFunc) Config {
return func(p *Page) {
p.onLoad = f
}
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)
return p.name
}
+func (p *Page) FileName() string {
+ return p.fileName
+}
+
func (p *Page) File() include.Opener {
return p.source
}
package part
import (
+ "context"
"text/template"
"git.earlybird.gay/today-engine/include"
)
type Part struct {
- name string
- source include.Opener
+ name string
+ fileName string
+ source include.Opener
includes []compile.Source
onLoad render.OnLoadFunc
}
}
-func OnLoad(f func(data render.Data) error) Config {
+func OnLoad(f render.OnLoadFunc) Config {
return func(p *Part) {
p.onLoad = f
}
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)
return p.name
}
+func (p *Part) FileName() string {
+ return p.fileName
+}
+
func (p *Part) File() include.Opener {
return p.source
}
package render
import (
+ "context"
"net/http"
"regexp"
)
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.
d[key] = value
}
-type OnLoadFunc func(Data) error
+type OnLoadFunc func(context.Context, Data) error
type Loader interface {
Compute(r *http.Request) (Data, error)
}
package stdpart
import (
+ "context"
"errors"
"fmt"
"net/http"
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
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 {
<template>
- {{ $id := .id }}
+ {{- $id := "contact-form" -}}
+ {{- if .id }}{{ $id = .id }}{{ end -}}
<form action="{{.action}}"
{{ with .method }}method="{{.}}"{{ end }}>
<input type="hidden" id="{{$id}}-callback" name="callback" value="{{.request.URL.Path}}">