From 9f905de4617739f7c0fc5927be5046c23bfa451c Mon Sep 17 00:00:00 2001 From: mAi Date: Fri, 15 May 2026 13:24:44 +0200 Subject: [PATCH] feat: Go HTTP server with tree / detail / new / classify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd/projax/main.go boots a pgxpool against PROJAX_DB_URL (falls back to SUPABASE_DATABASE_URL), auto-applies embedded migrations on start (disable with PROJAX_AUTO_MIGRATE=off), and serves on PROJAX_LISTEN_ADDR (default :8080). store package wraps the unified view + projax.items writes. Item has helper methods for templates: IsArea, Editable, SourceRefDeref. The Promote() flow runs the insert + item_links link inside a single transaction so the source row drops out of items_unified atomically. web package: per-page html/template instances parsed against a shared layout.tmpl, embedded static/style.css, HTMX from CDN. Pages: GET / tree of items_unified GET /i/{path} detail (editable for projax, read-only + promote form for mai.projects) POST /i/{path} update projax-native item POST /i/{path}/promote one-page promote (HTMX-aware fragment for inline classify) GET /new?parent={path} create form POST /new create projax-native item GET /admin/classify orphan list with inline HTMX promote GET /healthz DB ping GET /static/* embedded assets Auth is intentionally out of scope for v1 — service binds to whatever PROJAX_LISTEN_ADDR points at, deploy guidance pins it to the Tailscale interface (covered in 1d README). Tests (skip when DB env is unset): TestTreeRenders, TestHealthz, TestDetailProjaxNativeEditable, TestDetailMaiProjectsReadOnly, TestClassifyListsOrphans, TestPromoteRoundTrip. --- cmd/projax/main.go | 85 +++++++++ store/store.go | 274 +++++++++++++++++++++++++++ web/server.go | 364 ++++++++++++++++++++++++++++++++++++ web/server_test.go | 190 +++++++++++++++++++ web/static/style.css | 58 ++++++ web/templates/classify.tmpl | 37 ++++ web/templates/detail.tmpl | 71 +++++++ web/templates/error.tmpl | 5 + web/templates/layout.tmpl | 20 ++ web/templates/new.tmpl | 28 +++ web/templates/tree.tmpl | 52 ++++++ 11 files changed, 1184 insertions(+) create mode 100644 cmd/projax/main.go create mode 100644 store/store.go create mode 100644 web/server.go create mode 100644 web/server_test.go create mode 100644 web/static/style.css create mode 100644 web/templates/classify.tmpl create mode 100644 web/templates/detail.tmpl create mode 100644 web/templates/error.tmpl create mode 100644 web/templates/layout.tmpl create mode 100644 web/templates/new.tmpl create mode 100644 web/templates/tree.tmpl diff --git a/cmd/projax/main.go b/cmd/projax/main.go new file mode 100644 index 0000000..15d31dc --- /dev/null +++ b/cmd/projax/main.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/m/projax/db" + "github.com/m/projax/store" + "github.com/m/projax/web" +) + +func main() { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) + + dbURL := os.Getenv("PROJAX_DB_URL") + if dbURL == "" { + dbURL = os.Getenv("SUPABASE_DATABASE_URL") + } + if dbURL == "" { + logger.Error("startup: set PROJAX_DB_URL (or SUPABASE_DATABASE_URL)") + os.Exit(1) + } + listen := os.Getenv("PROJAX_LISTEN_ADDR") + if listen == "" { + listen = ":8080" + } + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + pool, err := pgxpool.New(ctx, dbURL) + if err != nil { + logger.Error("db pool", "err", err) + os.Exit(1) + } + defer pool.Close() + if err := pool.Ping(ctx); err != nil { + logger.Error("db ping", "err", err) + os.Exit(1) + } + + if os.Getenv("PROJAX_AUTO_MIGRATE") != "off" { + if err := db.ApplyMigrations(ctx, pool); err != nil { + logger.Error("apply migrations", "err", err) + os.Exit(1) + } + logger.Info("migrations applied") + } + + srv, err := web.New(store.New(pool), logger) + if err != nil { + logger.Error("server init", "err", err) + os.Exit(1) + } + + httpServer := &http.Server{ + Addr: listen, + Handler: srv.Routes(), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = httpServer.Shutdown(shutdownCtx) + }() + + logger.Info("listening", "addr", listen) + if err := httpServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + logger.Error("listen", "err", err) + os.Exit(1) + } + logger.Info("shutdown clean") +} diff --git a/store/store.go b/store/store.go new file mode 100644 index 0000000..6604c6a --- /dev/null +++ b/store/store.go @@ -0,0 +1,274 @@ +package store + +import ( + "context" + "errors" + "fmt" + "slices" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Item is the unified row shape: projax-native items and mai.projects rows +// both render through this struct. +type Item struct { + ID string + Kind []string + Title string + Slug string + Path string + ParentID *string + ContentMD string + Aliases []string + Metadata map[string]any + Status string + Pinned bool + Archived bool + StartTime *time.Time + EndTime *time.Time + Source string // "projax" or "mai.projects" + SourceRefID *string // mai.projects.id when Source = "mai.projects" + CreatedAt time.Time + UpdatedAt time.Time +} + +// IsArea reports whether this item should be treated as a top-level container. +func (it *Item) IsArea() bool { return slices.Contains(it.Kind, "area") } + +// Editable reports whether the UI may edit this row directly. +// mai.projects rows are read-only — they must be promoted first. +func (it *Item) Editable() bool { return it.Source == "projax" } + +// SourceRefDeref returns the source ref id (empty string if nil) for templates. +func (it *Item) SourceRefDeref() string { + if it.SourceRefID == nil { + return "" + } + return *it.SourceRefID +} + +// Store wraps a pgx pool with the queries projax needs. +type Store struct { + Pool *pgxpool.Pool +} + +func New(pool *pgxpool.Pool) *Store { return &Store{Pool: pool} } + +var ErrNotFound = errors.New("projax: item not found") + +const itemsUnifiedCols = `id, kind, title, slug, path, parent_id, content_md, aliases, +metadata, status, pinned, archived, start_time, end_time, source, source_ref_id, +created_at, updated_at` + +func scanItem(row pgx.Row) (*Item, error) { + var it Item + if err := row.Scan( + &it.ID, &it.Kind, &it.Title, &it.Slug, &it.Path, &it.ParentID, &it.ContentMD, + &it.Aliases, &it.Metadata, &it.Status, &it.Pinned, &it.Archived, + &it.StartTime, &it.EndTime, &it.Source, &it.SourceRefID, + &it.CreatedAt, &it.UpdatedAt, + ); err != nil { + return nil, err + } + return &it, nil +} + +func scanItems(rows pgx.Rows) ([]*Item, error) { + defer rows.Close() + var out []*Item + for rows.Next() { + var it Item + if err := rows.Scan( + &it.ID, &it.Kind, &it.Title, &it.Slug, &it.Path, &it.ParentID, &it.ContentMD, + &it.Aliases, &it.Metadata, &it.Status, &it.Pinned, &it.Archived, + &it.StartTime, &it.EndTime, &it.Source, &it.SourceRefID, + &it.CreatedAt, &it.UpdatedAt, + ); err != nil { + return nil, err + } + out = append(out, &it) + } + return out, rows.Err() +} + +// ListAll returns every visible row from items_unified. Caller groups by tree. +func (s *Store) ListAll(ctx context.Context) ([]*Item, error) { + rows, err := s.Pool.Query(ctx, + `select `+itemsUnifiedCols+` from projax.items_unified + order by source = 'projax' desc, path`) + if err != nil { + return nil, err + } + return scanItems(rows) +} + +// GetByPath looks up a single item by path. Projax-native wins on collision. +func (s *Store) GetByPath(ctx context.Context, path string) (*Item, error) { + row := s.Pool.QueryRow(ctx, + `select `+itemsUnifiedCols+` from projax.items_unified + where path = $1 + order by source = 'projax' desc + limit 1`, path) + it, err := scanItem(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return it, nil +} + +// GetByID looks up a single projax-native item by uuid. +func (s *Store) GetByID(ctx context.Context, id string) (*Item, error) { + row := s.Pool.QueryRow(ctx, + `select `+itemsUnifiedCols+` from projax.items_unified where id = $1`, id) + it, err := scanItem(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return it, nil +} + +// Areas lists all root-level area items, ordered by slug. +func (s *Store) Areas(ctx context.Context) ([]*Item, error) { + rows, err := s.Pool.Query(ctx, + `select `+itemsUnifiedCols+` from projax.items_unified + where source = 'projax' and parent_id is null and 'area' = any(kind) + order by slug`) + if err != nil { + return nil, err + } + return scanItems(rows) +} + +// MaiOrphans lists rows that originated in mai.projects and have not been +// promoted yet. Used by /admin/classify. +func (s *Store) MaiOrphans(ctx context.Context) ([]*Item, error) { + rows, err := s.Pool.Query(ctx, + `select `+itemsUnifiedCols+` from projax.items_unified + where source = 'mai.projects' + order by path`) + if err != nil { + return nil, err + } + return scanItems(rows) +} + +// CreateInput captures the editable surface of a projax-native item. +type CreateInput struct { + Kind []string + Title string + Slug string + ParentID *string + ContentMD string + Status string + Pinned bool + StartTime *time.Time + EndTime *time.Time +} + +func (s *Store) Create(ctx context.Context, in CreateInput) (*Item, error) { + if len(in.Kind) == 0 { + return nil, errors.New("kind required") + } + if in.Title == "" { + return nil, errors.New("title required") + } + if in.Slug == "" { + return nil, errors.New("slug required") + } + if in.Status == "" { + in.Status = "active" + } + var id string + err := s.Pool.QueryRow(ctx, ` + insert into projax.items + (kind, title, slug, parent_id, content_md, status, pinned, start_time, end_time) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9) + returning id`, + in.Kind, in.Title, in.Slug, in.ParentID, in.ContentMD, in.Status, in.Pinned, in.StartTime, in.EndTime, + ).Scan(&id) + if err != nil { + return nil, fmt.Errorf("insert: %w", err) + } + return s.GetByID(ctx, id) +} + +// UpdateInput captures the editable surface of an existing projax-native item. +type UpdateInput struct { + Title string + Slug string + ParentID *string + ContentMD string + Status string + Pinned bool + Archived bool + StartTime *time.Time + EndTime *time.Time +} + +func (s *Store) Update(ctx context.Context, id string, in UpdateInput) (*Item, error) { + _, err := s.Pool.Exec(ctx, ` + update projax.items + set title=$2, slug=$3, parent_id=$4, content_md=$5, + status=$6, pinned=$7, archived=$8, start_time=$9, end_time=$10 + where id=$1 and deleted_at is null`, + id, in.Title, in.Slug, in.ParentID, in.ContentMD, + in.Status, in.Pinned, in.Archived, in.StartTime, in.EndTime, + ) + if err != nil { + return nil, fmt.Errorf("update: %w", err) + } + return s.GetByID(ctx, id) +} + +// Promote turns a mai.projects orphan into a projax-native project under the +// chosen parent. The promotion link causes items_unified to hide the source. +func (s *Store) Promote(ctx context.Context, maiID, parentID, slug, title string) (*Item, error) { + tx, err := s.Pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + var newID string + if err := tx.QueryRow(ctx, ` + insert into projax.items (kind, title, slug, parent_id, content_md, metadata, status) + select array['project']::text[], $1, $2, $3, + coalesce(goal, ''), + coalesce(metadata, '{}'::jsonb), + case status + when 'sleeping' then 'archived' + when 'archived' then 'archived' + when 'done' then 'done' + else 'active' end + from mai.projects where id = $4 + returning id`, + title, slug, parentID, maiID, + ).Scan(&newID); err != nil { + return nil, fmt.Errorf("promote insert: %w", err) + } + if _, err := tx.Exec(ctx, ` + insert into projax.item_links (item_id, ref_type, ref_id, rel) + values ($1, 'mai-project', $2, 'derived-from')`, + newID, maiID, + ); err != nil { + return nil, fmt.Errorf("promote link: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return s.GetByID(ctx, newID) +} + +// SoftDelete marks a projax-native item deleted_at = now(). +func (s *Store) SoftDelete(ctx context.Context, id string) error { + _, err := s.Pool.Exec(ctx, `update projax.items set deleted_at = now() where id = $1`, id) + return err +} diff --git a/web/server.go b/web/server.go new file mode 100644 index 0000000..b033923 --- /dev/null +++ b/web/server.go @@ -0,0 +1,364 @@ +package web + +import ( + "context" + "embed" + "errors" + "fmt" + "html/template" + "io/fs" + "log/slog" + "net/http" + "sort" + "strings" + + "github.com/m/projax/store" +) + +//go:embed templates/*.tmpl +var templatesFS embed.FS + +//go:embed static/* +var staticFS embed.FS + +// Server bundles handlers, templates, and the store. +type Server struct { + Store *store.Store + pages map[string]*template.Template + Logger *slog.Logger +} + +// New builds a Server. Each page is parsed alongside the layout into its own +// Template so per-page `define "content"` blocks don't shadow each other. +func New(s *store.Store, logger *slog.Logger) (*Server, error) { + if logger == nil { + logger = slog.Default() + } + funcs := template.FuncMap{ + "deref": func(p *string) string { + if p == nil { + return "" + } + return *p + }, + } + pages := map[string]*template.Template{} + for _, name := range []string{"tree", "detail", "new", "classify", "error"} { + t, err := template.New(name).Funcs(funcs).ParseFS(templatesFS, + "templates/layout.tmpl", + "templates/"+name+".tmpl", + ) + if err != nil { + return nil, fmt.Errorf("parse %s: %w", name, err) + } + pages[name] = t + } + return &Server{Store: s, pages: pages, Logger: logger}, nil +} + +// Routes wires every URL to a handler and returns the mux. +func (s *Server) Routes() http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("GET /", s.handleTree) + mux.HandleFunc("GET /i/", s.handleDetail) + mux.HandleFunc("POST /i/", s.handleDetailWrite) + mux.HandleFunc("GET /new", s.handleNewForm) + mux.HandleFunc("POST /new", s.handleNewSubmit) + mux.HandleFunc("GET /admin/classify", s.handleClassify) + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + if err := s.Store.Pool.Ping(r.Context()); err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + fmt.Fprintln(w, "ok") + }) + + static, _ := fs.Sub(staticFS, "static") + mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static)))) + + return logging(s.Logger, mux) +} + +// --- handlers --- + +type treeNode struct { + Item *store.Item + Children []*treeNode +} + +func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + items, err := s.Store.ListAll(r.Context()) + if err != nil { + s.fail(w, r, err) + return + } + areas, orphans, projaxN, maiN := buildForest(items) + s.render(w, "tree", map[string]any{ + "Title": "tree", + "Areas": areas, + "Orphans": orphans, + "ProjaxCount": projaxN, + "MaiCount": maiN, + }) +} + +func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/i/") + if path == "" { + http.NotFound(w, r) + return + } + it, err := s.Store.GetByPath(r.Context(), path) + if err != nil { + s.fail(w, r, err) + return + } + parents, err := s.parentOptions(r.Context()) + if err != nil { + s.fail(w, r, err) + return + } + s.render(w, "detail", map[string]any{ + "Title": it.Title, + "Item": it, + "ParentOptions": parents, + "StatusOptions": []string{"active", "done", "archived"}, + }) +} + +func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/i/") + if base, ok := strings.CutSuffix(path, "/promote"); ok { + s.handlePromote(w, r, base) + return + } + it, err := s.Store.GetByPath(r.Context(), path) + if err != nil { + s.fail(w, r, err) + return + } + if !it.Editable() { + http.Error(w, "read-only source — use /promote", http.StatusForbidden) + return + } + if err := r.ParseForm(); err != nil { + s.fail(w, r, err) + return + } + var parentID *string + if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" { + parentID = &v + } + in := store.UpdateInput{ + Title: strings.TrimSpace(r.FormValue("title")), + Slug: strings.TrimSpace(r.FormValue("slug")), + ParentID: parentID, + ContentMD: r.FormValue("content_md"), + Status: strings.TrimSpace(r.FormValue("status")), + Pinned: r.FormValue("pinned") == "1", + Archived: r.FormValue("archived") == "1", + } + updated, err := s.Store.Update(r.Context(), it.ID, in) + if err != nil { + s.fail(w, r, err) + return + } + http.Redirect(w, r, "/i/"+updated.Path, http.StatusSeeOther) +} + +func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request, path string) { + it, err := s.Store.GetByPath(r.Context(), path) + if err != nil { + s.fail(w, r, err) + return + } + if it.Source != "mai.projects" || it.SourceRefID == nil { + http.Error(w, "promote: not a mai.projects row", http.StatusBadRequest) + return + } + if err := r.ParseForm(); err != nil { + s.fail(w, r, err) + return + } + parentID := strings.TrimSpace(r.FormValue("parent_id")) + slug := strings.TrimSpace(r.FormValue("slug")) + title := strings.TrimSpace(r.FormValue("title")) + if parentID == "" || slug == "" || title == "" { + http.Error(w, "promote: parent_id, slug, title required", http.StatusBadRequest) + return + } + newItem, err := s.Store.Promote(r.Context(), *it.SourceRefID, parentID, slug, title) + if err != nil { + s.fail(w, r, err) + return + } + // HTMX inline-promote on /admin/classify expects a fragment; full-page promote redirects. + if r.Header.Get("HX-Request") == "true" { + fmt.Fprintf(w, `Promoted to %s`, + template.HTMLEscapeString(newItem.Path), template.HTMLEscapeString(newItem.Path)) + return + } + http.Redirect(w, r, "/i/"+newItem.Path, http.StatusSeeOther) +} + +func (s *Server) handleNewForm(w http.ResponseWriter, r *http.Request) { + parentPath := r.URL.Query().Get("parent") + var parent *store.Item + if parentPath != "" { + p, err := s.Store.GetByPath(r.Context(), parentPath) + if err != nil { + s.fail(w, r, err) + return + } + if !p.Editable() { + http.Error(w, "parent must be a projax-native item", http.StatusBadRequest) + return + } + parent = p + } + s.render(w, "new", map[string]any{ + "Title": "new", + "Parent": parent, + "StatusOptions": []string{"active", "done", "archived"}, + }) +} + +func (s *Server) handleNewSubmit(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.fail(w, r, err) + return + } + kind := strings.TrimSpace(r.FormValue("kind")) + if kind == "" { + kind = "project" + } + var parentID *string + if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" { + parentID = &v + } + if kind == "project" && parentID == nil { + http.Error(w, "project requires a parent", http.StatusBadRequest) + return + } + in := store.CreateInput{ + Kind: []string{kind}, + Title: strings.TrimSpace(r.FormValue("title")), + Slug: strings.TrimSpace(r.FormValue("slug")), + ParentID: parentID, + ContentMD: r.FormValue("content_md"), + Status: strings.TrimSpace(r.FormValue("status")), + } + it, err := s.Store.Create(r.Context(), in) + if err != nil { + s.fail(w, r, err) + return + } + http.Redirect(w, r, "/i/"+it.Path, http.StatusSeeOther) +} + +func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) { + orphans, err := s.Store.MaiOrphans(r.Context()) + if err != nil { + s.fail(w, r, err) + return + } + parents, err := s.parentOptions(r.Context()) + if err != nil { + s.fail(w, r, err) + return + } + s.render(w, "classify", map[string]any{ + "Title": "classify", + "Orphans": orphans, + "ParentOptions": parents, + }) +} + +// --- helpers --- + +// ParentOption is a flat option for the parent + + {{range $.ParentOptions}}{{end}} + + + + + + + {{else}} + No orphans. Everything is classified. + {{end}} + + +{{end}} diff --git a/web/templates/detail.tmpl b/web/templates/detail.tmpl new file mode 100644 index 0000000..7112205 --- /dev/null +++ b/web/templates/detail.tmpl @@ -0,0 +1,71 @@ +{{define "content"}} +

{{.Item.Title}}

+

+ {{.Item.Source}} + {{.Item.Path}} + {{.Item.Status}} + {{if .Item.Pinned}}pinned{{end}} + {{if .Item.Archived}}archived{{end}} +

+ +{{if .Item.Editable}} +
+ + + + + + + +
+ + Cancel +
+
+{{else}} +
+

Read-only: this row is sourced from {{.Item.Source}}.

+
{{.Item.ContentMD}}
+ +

Promote to projax

+

Pick the area or project this should live under. mai.projects row stays untouched; the projax item links back to it via item_links.

+
+ + + +
+ + Cancel +
+
+
+{{end}} +{{end}} diff --git a/web/templates/error.tmpl b/web/templates/error.tmpl new file mode 100644 index 0000000..4e18e96 --- /dev/null +++ b/web/templates/error.tmpl @@ -0,0 +1,5 @@ +{{define "content"}} +

Error

+

{{.Message}}

+

Back to tree

+{{end}} diff --git a/web/templates/layout.tmpl b/web/templates/layout.tmpl new file mode 100644 index 0000000..7e26f04 --- /dev/null +++ b/web/templates/layout.tmpl @@ -0,0 +1,20 @@ +{{define "layout"}} + + + +{{.Title}} — projax + + + + +
+ +
+
+{{template "content" .}} +
+ +{{end}} diff --git a/web/templates/new.tmpl b/web/templates/new.tmpl new file mode 100644 index 0000000..b7a5bba --- /dev/null +++ b/web/templates/new.tmpl @@ -0,0 +1,28 @@ +{{define "content"}} +

New item

+

Parent: {{if .Parent}}{{.Parent.Path}}{{else}}(root area){{end}}

+ +
+ {{if .Parent}}{{end}} + + + + + +
+ + Cancel +
+
+{{end}} diff --git a/web/templates/tree.tmpl b/web/templates/tree.tmpl new file mode 100644 index 0000000..b9bb661 --- /dev/null +++ b/web/templates/tree.tmpl @@ -0,0 +1,52 @@ +{{define "content"}} +

Tree

+

+ {{.ProjaxCount}} projax · {{.MaiCount}} mai.projects orphans + {{if .MaiCount}}→ classify{{end}} +

+ +
+

Areas + projax items

+ +
+ +{{if .Orphans}} +
+

mai.projects orphans (unclassified)

+ +
+{{end}} +{{end}} + +{{define "children"}} + {{if .Children}} + + {{end}} +{{end}}