summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-07-11 14:06:25 -0600
committert <t@tjp.lol>2026-07-11 14:26:20 -0600
commit768f5a9281cafba2f786955301363c83428fadee (patch)
tree60c6610cb2e00faff99cc547d664769e02d90bbf
parent70110768e512a2ac179c455b14cf5ed367a3afee (diff)
template names and directory links
-rw-r--r--README.md11
-rw-r--r--main.go47
-rw-r--r--main_test.go30
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 <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)