Files

408 lines
11 KiB
Go

package build
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"text/template/parse"
"gopkg.in/yaml.v3"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/site"
)
// The section library: every section a site's pages can stack, with the
// fields each takes, so the editor can offer sections as cards and their
// fields as forms. A section template declares its fields in a comment at
// the top:
//
// {{/* section
// label: Hero
// about: The big opening at the top of a page.
// fields:
// - { name: heading, type: text, required: true }
// - { name: text, type: markdown }
// - name: buttons
// type: list
// fields:
// - { name: text, type: text }
// - { name: href, type: link }
// - { name: primary, type: bool, label: Main button }
// */}}
//
// A template without that comment still works: its fields are read from what
// the template uses (.Data.heading, range .Data.buttons, markdownify ...).
// SectionInfo is one section type.
type SectionInfo struct {
Name string `json:"name"`
Label string `json:"label"`
About string `json:"about,omitempty"`
Fields []Field `json:"fields"`
Declared bool `json:"declared"` // false: fields read from the template
Problem string `json:"problem,omitempty"` // the declaration couldn't be read
Pack string `json:"pack,omitempty"` // installed by this pack
}
// Field is one field of a section, or of an item in a list field.
type Field struct {
Name string `json:"name" yaml:"name"`
Label string `json:"label,omitempty" yaml:"label"`
Type string `json:"type" yaml:"type"`
Help string `json:"help,omitempty" yaml:"help"`
Required bool `json:"required,omitempty" yaml:"required"`
Default any `json:"default,omitempty" yaml:"default"`
Options []string `json:"options,omitempty" yaml:"options"`
Fields []Field `json:"fields,omitempty" yaml:"fields"`
}
// FieldTypes are the kinds of field a form can show.
var FieldTypes = map[string]string{
"text": "one line of text",
"textarea": "several lines of plain text",
"markdown": "Markdown",
"link": "an address, on the site or elsewhere",
"image": "a picture from media/",
"bool": "yes or no",
"number": "a number",
"select": "one of options",
"list": "a list of items, each with fields (or of text, without fields)",
"group": "a set of fields",
"data": "a file in data/",
"collection": "a collection, such as articles",
}
var declRe = regexp.MustCompile(`(?s)^\s*\{\{-?\s*/\*\s*section\b(.*?)\*/\s*-?\}\}`)
// SectionLibrary reads every section in a site's sections/ folder.
func SectionLibrary(siteDir string) ([]SectionInfo, error) {
files, err := htmlFiles(filepath.Join(siteDir, "sections"))
if err != nil {
return nil, err
}
partials := map[string]*parse.Tree{}
pfiles, _ := htmlFiles(filepath.Join(siteDir, "partials"))
for _, name := range pfiles {
src, err := os.ReadFile(filepath.Join(siteDir, "partials", name+".html"))
if err != nil {
return nil, err
}
t := parse.New(name)
t.Mode = parse.SkipFuncCheck
_, _ = t.Parse(string(src), "", "", partials)
}
fromPack := map[string]string{}
if cfg, err := site.LoadConfig(siteDir); err == nil {
for _, p := range cfg.Packs {
for _, f := range p.Files {
fromPack[f] = p.Name
}
}
}
out := []SectionInfo{}
for _, name := range files {
src, err := os.ReadFile(filepath.Join(siteDir, "sections", name+".html"))
if err != nil {
return nil, err
}
info := describeSection(name, string(src), partials)
info.Pack = fromPack["sections/"+name+".html"]
out = append(out, info)
}
sort.Slice(out, func(i, j int) bool { return out[i].Label < out[j].Label })
return out, nil
}
func describeSection(name, src string, partials map[string]*parse.Tree) SectionInfo {
info := SectionInfo{Name: name, Label: labelFor(name)}
if m := declRe.FindStringSubmatch(src); m != nil {
var decl struct {
Label string `yaml:"label"`
About string `yaml:"about"`
Fields []Field `yaml:"fields"`
}
if err := yaml.Unmarshal([]byte(m[1]), &decl); err != nil {
info.Problem = "the section comment isn't valid YAML: " + err.Error()
} else if err := checkFields(decl.Fields, ""); err != nil {
info.Problem = err.Error()
} else {
if decl.Label != "" {
info.Label = decl.Label
}
info.About = strings.TrimSpace(decl.About)
info.Fields = decl.Fields
info.Declared = true
return info
}
}
info.Fields = inferFields(name, src, partials)
return info
}
func checkFields(fs []Field, in string) error {
seen := map[string]bool{}
for i := range fs {
f := &fs[i]
where := in + f.Name
if f.Name == "" {
return fmt.Errorf("a field in %q has no name", strings.TrimSuffix(in, "."))
}
if seen[f.Name] {
return fmt.Errorf("field %s is declared twice", where)
}
seen[f.Name] = true
if f.Type == "" {
f.Type = "text"
}
if _, ok := FieldTypes[f.Type]; !ok {
return fmt.Errorf("field %s: type %q isn't one of text, textarea, markdown, link, image, bool, number, select, list, group, data, collection", where, f.Type)
}
if f.Type == "select" && len(f.Options) == 0 {
return fmt.Errorf("field %s: a select needs options", where)
}
if len(f.Fields) > 0 && f.Type != "list" && f.Type != "group" {
return fmt.Errorf("field %s: only a list or a group has fields", where)
}
if err := checkFields(f.Fields, where+"."); err != nil {
return err
}
}
return nil
}
func labelFor(name string) string {
s := strings.ReplaceAll(strings.ReplaceAll(name, "-", " "), "_", " ")
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
// inferFields reads the fields a section template uses. It understands the
// usual shapes: .Data.x, $.Data.x, a variable holding .Data or .Data.x, with
// and range over a field (a group or a list), markdownify (Markdown), default
// with a number (a number), a field only ever tested (yes or no), and names
// that say what they hold (href, image).
func inferFields(name, src string, partials map[string]*parse.Tree) []Field {
t := parse.New(name)
t.Mode = parse.SkipFuncCheck // the site's functions aren't known here
trees := map[string]*parse.Tree{}
if _, err := t.Parse(src, "", "", trees); err != nil {
return []Field{}
}
root := &node{f: Field{Type: "group"}}
r := &reader{vars: map[string]*node{}, partials: partials}
for _, t := range trees {
if t.Root != nil {
r.walk(t.Root, nil, root)
}
}
return root.fields()
}
// node is a field while it's being worked out; pointers stay put as fields
// are added, so variables can hold them.
type node struct {
f Field
kids []*node
printed, cond bool
}
func (n *node) child(name string) *node {
for _, k := range n.kids {
if k.f.Name == name {
return k
}
}
k := &node{f: Field{Name: name, Type: "text", Label: labelFor(name)}}
n.kids = append(n.kids, k)
return k
}
// fields settles each field's type from how it was used and what it's
// called, and returns them as Fields.
func (n *node) fields() []Field {
out := []Field{}
for _, k := range n.kids {
f := k.f
if f.Type == "text" && len(k.kids) == 0 {
switch {
case k.cond && !k.printed:
f.Type = "bool"
case f.Name == "href" || f.Name == "url" || f.Name == "link":
f.Type = "link"
case f.Name == "image" || f.Name == "src" || f.Name == "logo" || strings.HasSuffix(f.Name, "_image"):
f.Type = "image"
}
}
if len(k.kids) > 0 {
f.Fields = k.fields()
}
out = append(out, f)
}
return out
}
func (n *node) markUsed() {
n.printed = true
for _, k := range n.kids {
k.markUsed()
}
}
type reader struct {
vars map[string]*node
partials map[string]*parse.Tree // partials/, followed into with what they're handed
depth int
}
// resolve maps an argument to the field it reads, if it reads one. dot is the
// field the template's dot currently is (nil: the section's own context).
// The root itself comes back for .Data, so a variable can hold it.
func (r *reader) resolve(a parse.Node, dot, root *node) *node {
var path []string
base := root
switch a := a.(type) {
case *parse.FieldNode:
path = a.Ident
switch {
case dot != nil:
base = dot
case len(path) > 0 && path[0] == "Data":
path = path[1:]
default:
return nil
}
case *parse.VariableNode:
switch {
case a.Ident[0] == "$":
if len(a.Ident) < 2 || a.Ident[1] != "Data" {
return nil
}
path = a.Ident[2:]
case r.vars[a.Ident[0]] != nil:
base, path = r.vars[a.Ident[0]], a.Ident[1:]
default:
return nil
}
case *parse.DotNode:
return dot
default:
return nil
}
n := base
for _, p := range path {
if n.f.Type != "list" {
n.f.Type = "group"
}
n = n.child(p)
}
return n
}
// pipe notes every field a pipeline reads. It returns the field when the
// pipeline is nothing but that field, which is what with and range need.
func (r *reader) pipe(p *parse.PipeNode, dot, root *node, cond bool) *node {
if p == nil {
return nil
}
var direct *node
for _, cmd := range p.Cmds {
fn := ""
if len(cmd.Args) > 0 {
if id, ok := cmd.Args[0].(*parse.IdentifierNode); ok {
fn = id.Ident
}
}
for i, a := range cmd.Args {
if sub, ok := a.(*parse.PipeNode); ok {
r.pipe(sub, dot, root, false)
continue
}
n := r.resolve(a, dot, root)
if n == nil {
continue
}
if len(p.Cmds) == 1 && len(cmd.Args) == 1 {
direct = n
}
if n == root {
continue
}
switch {
case fn == "markdownify":
n.f.Type = "markdown"
case fn == "default" && i == 2:
if num, ok := cmd.Args[1].(*parse.NumberNode); ok {
n.f.Type, n.f.Default = "number", num.Text
} else if str, ok := cmd.Args[1].(*parse.StringNode); ok {
n.f.Default = str.Text
}
}
if cond && fn == "" {
n.cond = true
} else {
n.printed = true
}
}
}
if len(p.Decl) == 1 && !p.IsAssign && direct != nil {
r.vars[p.Decl[0].Ident[0]] = direct
}
if direct == root {
return nil
}
return direct
}
func (r *reader) walk(n parse.Node, dot, root *node) {
switch x := n.(type) {
case *parse.ListNode:
if x == nil {
return
}
for _, c := range x.Nodes {
r.walk(c, dot, root)
}
case *parse.ActionNode:
r.pipe(x.Pipe, dot, root, false)
case *parse.TemplateNode:
// Handed to a partial: whatever it holds is used there, so nothing
// in it is a yes-or-no just because it was tested first.
if x.Pipe != nil && len(x.Pipe.Cmds) == 1 && len(x.Pipe.Cmds[0].Args) == 1 {
if n := r.resolve(x.Pipe.Cmds[0].Args[0], dot, root); n != nil {
if t := r.partials[x.Name]; t != nil && t.Root != nil && r.depth < 4 {
r.depth++
r.walk(t.Root, n, root)
r.depth--
return
}
n.markUsed()
}
}
r.pipe(x.Pipe, dot, root, false)
case *parse.IfNode:
r.pipe(x.Pipe, dot, root, true)
r.walk(x.List, dot, root)
r.walk(x.ElseList, dot, root)
case *parse.WithNode:
inner := dot
if f := r.pipe(x.Pipe, dot, root, false); f != nil {
inner = f
}
r.walk(x.List, inner, root)
r.walk(x.ElseList, dot, root)
case *parse.RangeNode:
inner := dot
if f := r.pipe(x.Pipe, dot, root, false); f != nil {
f.f.Type = "list"
inner = f
}
r.walk(x.List, inner, root)
r.walk(x.ElseList, dot, root)
}
}