From 768f5a9281cafba2f786955301363c83428fadee Mon Sep 17 00:00:00 2001 From: t Date: Sat, 11 Jul 2026 14:06:25 -0600 Subject: template names and directory links --- README.md | 11 ++++++----- main.go | 47 +++++++++++++++++++++++++++++++++++------------ main_test.go | 30 +++++++++++++++++++++++------- 3 files changed, 64 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 910d6f5..f3ef773 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,12 @@ All non-underscore `.tmpl` files are loaded into one named template set. Files ending in `.html.tmpl` or `.xml.tmpl` also render to the same path without the `.tmpl` suffix. Other `.tmpl` files only define reusable templates. -Markdown pages execute one of these required definitions: - -- `page` for ordinary paths -- `weblog` below `weblog/` -- `note` below `notes/` +Markdown pages select the first defined template matching their source path, +walking toward the site root before using the fallback. For example, +`notes/recipes/cocktails/old_fashioned.md` checks +`notes/recipes/cocktails/old_fashioned`, `notes/recipes/cocktails`, +`notes/recipes`, `notes`, then `page`. Set a different fallback with +`-fallback template`; it defaults to `page`. Template data exposes the current page fields (`.Title`, `.Meta`, `.Sections`, `.OutputPath`, and so on) plus `.Pages`, a map containing each Markdown page at diff --git a/main.go b/main.go index 412ba84..aafbfc1 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "encoding/json" "encoding/xml" "errors" + "flag" "fmt" "html/template" "io" @@ -57,6 +58,7 @@ type site struct { pages map[string][]*Page sources []source templates *template.Template + fallback string warnings []string } @@ -65,17 +67,30 @@ type manifest struct { } func main() { - if len(os.Args) != 2 { - fmt.Fprintln(os.Stderr, "usage: weft ") + flags := flag.NewFlagSet("weft", flag.ContinueOnError) + fallback := flags.String("fallback", "page", "fallback template for Markdown pages") + flags.Usage = func() { fmt.Fprintln(flags.Output(), "usage: weft [-fallback template] ") } + if err := flags.Parse(os.Args[1:]); err != nil { os.Exit(2) } - if err := build(os.Args[1]); err != nil { + if flags.NArg() != 1 { + flags.Usage() + os.Exit(2) + } + if err := buildWithFallback(flags.Arg(0), *fallback); err != nil { fmt.Fprintln(os.Stderr, "weft:", err) os.Exit(1) } } func build(root string) error { + return buildWithFallback(root, "page") +} + +func buildWithFallback(root, fallback string) error { + if strings.TrimSpace(fallback) == "" { + return errors.New("fallback template cannot be empty") + } abs, err := filepath.Abs(root) if err != nil { return err @@ -88,7 +103,7 @@ func build(root string) error { return fmt.Errorf("site root is not a directory: %s", abs) } - s := &site{root: abs, pages: map[string][]*Page{}} + s := &site{root: abs, fallback: fallback, pages: map[string][]*Page{}} if err := s.discover(); err != nil { return err } @@ -367,6 +382,7 @@ func relativePath(from, target string) string { if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "mailto:") || strings.HasPrefix(target, "#") { return target } + directory := strings.HasSuffix(target, "/") fromDir := path.Dir(strings.TrimPrefix(from, "/")) if fromDir == "." { fromDir = "" @@ -376,7 +392,11 @@ func relativePath(from, target string) string { if err != nil { return target } - return filepath.ToSlash(rel) + rel = filepath.ToSlash(rel) + if directory { + return strings.TrimSuffix(rel, "/") + "/" + } + return rel } func sortPages(pages []*Page, key string) []*Page { @@ -462,13 +482,7 @@ func (s *site) render() (map[string][]byte, error) { name := src.path if src.kind == "markdown" { current = src.page - name = "page" - if src.output == "weblog" || strings.HasPrefix(src.output, "weblog/") { - name = "weblog" - } - if src.output == "notes" || strings.HasPrefix(src.output, "notes/") { - name = "note" - } + name = s.markdownTemplate(src.path) } else { current = &Page{SourcePath: src.path, OutputPath: src.output, URL: "/" + src.output, CanonicalURL: "https://tjp.lol/" + src.output, Meta: map[string]any{}} } @@ -484,6 +498,15 @@ func (s *site) render() (map[string][]byte, error) { return outputs, nil } +func (s *site) markdownTemplate(sourcePath string) string { + for candidate := strings.TrimSuffix(sourcePath, ".md"); candidate != "." && candidate != ""; candidate = path.Dir(candidate) { + if s.templates.Lookup(candidate) != nil { + return candidate + } + } + return s.fallback +} + func (s *site) validate(outputs map[string][]byte) error { tracked, err := s.readManifest() if err != nil { diff --git a/main_test.go b/main_test.go index cf40a29..91f432b 100644 --- a/main_test.go +++ b/main_test.go @@ -24,7 +24,7 @@ func writeTestFile(t *testing.T, root, name, contents string) { func testLayouts() string { return `{{define "page"}}{{.Title}}{{joinSections .Sections}}{{end}} {{define "weblog"}}{{.Title}}{{joinSections .Sections}}{{end}} -{{define "note"}}{{template "page" .}}{{end}}` +{{define "notes"}}{{template "page" .}}{{end}}` } func TestMarkdownGFMFrontmatterTitleSectionsAndLinks(t *testing.T) { @@ -121,6 +121,10 @@ func TestOutputCollision(t *testing.T) { func TestRelativePath(t *testing.T) { cases := map[string]string{ relativePath("index.html", "style.css"): "style.css", + relativePath("index.html", "/"): "./", + relativePath("about/index.html", "/"): "../", + relativePath("index.html", "/notes/"): "notes/", + relativePath("about/index.html", "/notes/"): "../notes/", relativePath("notes/deep/page.html", "style.css"): "../../style.css", relativePath("notes/page.html", "/feed.xml"): "../feed.xml", relativePath("notes/page.html", "https://example.com"): "https://example.com", @@ -132,16 +136,28 @@ func TestRelativePath(t *testing.T) { } } -func TestPathSelectsMarkdownLayout(t *testing.T) { +func TestMarkdownTemplateFallsThroughPathThenFallback(t *testing.T) { root := t.TempDir() - writeTestFile(t, root, "layouts.tmpl", `{{define "page"}}page:{{.Title}}{{end}}{{define "weblog"}}post:{{.Title}}{{end}}{{define "note"}}note:{{.Title}}{{end}}`) + writeTestFile(t, root, "layouts.tmpl", `{{define "default"}}default:{{.Title}}{{end}} +{{define "notes"}}notes:{{.Title}}{{end}} +{{define "notes/recipes"}}recipes:{{.Title}}{{end}} +{{define "notes/recipes/cocktails"}}cocktails:{{.Title}}{{end}} +{{define "notes/recipes/cocktails/old_fashioned"}}exact:{{.Title}}{{end}}`) writeTestFile(t, root, "about.md", "# About\n") - writeTestFile(t, root, "weblog/post.md", "# Post\n") - writeTestFile(t, root, "notes/item.md", "# Item\n") - if err := build(root); err != nil { + writeTestFile(t, root, "notes/todo.md", "# Todo\n") + writeTestFile(t, root, "notes/recipes/bread.md", "# Bread\n") + writeTestFile(t, root, "notes/recipes/cocktails/stirred.md", "# Stirred\n") + writeTestFile(t, root, "notes/recipes/cocktails/old_fashioned.md", "# Old Fashioned\n") + if err := buildWithFallback(root, "default"); err != nil { t.Fatal(err) } - for name, want := range map[string]string{"about.html": "page:About", "weblog/post.html": "post:Post", "notes/item.html": "note:Item"} { + for name, want := range map[string]string{ + "about.html": "default:About", + "notes/todo.html": "notes:Todo", + "notes/recipes/bread.html": "recipes:Bread", + "notes/recipes/cocktails/stirred.html": "cocktails:Stirred", + "notes/recipes/cocktails/old_fashioned.html": "exact:Old Fashioned", + } { raw, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(name))) if err != nil { t.Fatal(err) -- cgit v1.3