diff options
| author | t <t@tjp.lol> | 2026-07-11 14:06:25 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-07-11 14:26:20 -0600 |
| commit | 768f5a9281cafba2f786955301363c83428fadee (patch) | |
| tree | 60c6610cb2e00faff99cc547d664769e02d90bbf | |
| parent | 70110768e512a2ac179c455b14cf5ed367a3afee (diff) | |
template names and directory links
| -rw-r--r-- | README.md | 11 | ||||
| -rw-r--r-- | main.go | 47 | ||||
| -rw-r--r-- | main_test.go | 30 |
3 files changed, 64 insertions, 24 deletions
@@ -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 @@ -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 <site-root>") + 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] <site-root>") } + 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"}}<html><head><title>{{.Title}}</title></head><body>{{joinSections .Sections}}</body></html>{{end}} {{define "weblog"}}<html><head><title>{{.Title}}</title></head><body>{{joinSections .Sections}}</body></html>{{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) |
