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) } }