summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md88
-rw-r--r--go.mod2
-rw-r--r--main.go110
-rw-r--r--main_test.go39
4 files changed, 206 insertions, 33 deletions
diff --git a/README.md b/README.md
index f3ef773..f0d82ae 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ outputs in place.
```sh
go build -o weft .
-./weft ../tjp.lol
+./weft -gitignore ../tjp.lol https://tjp.lol
```
Markdown uses CommonMark with GFM tables, strikethrough, task lists, and
@@ -26,15 +26,83 @@ walking toward the site root before using the fallback. For example,
`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
-every ancestor directory. Helpers are `rel from target`, `sortPages pages key`,
-`filterPages pages key value`, `slicePages pages start end`, `joinSections`,
-`date value layout`, `rfc3339 value`, and `xml value`. Use `xml` around joined
-HTML when embedding it as escaped Atom `type="html"` content.
+## Template data
-Go templates' built-in `slice` can select rendered sections, for example
-`joinSections (slice .Sections 1)` after rendering a weblog lede separately.
+Every template receives this root value:
+
+```go
+struct {
+ *Page
+ Pages map[string][]*Page
+}
+```
+
+The current `Page` is embedded, so its fields can be accessed as either
+`.Title` or `.Page.Title`:
+
+| Field | Meaning |
+| --- | --- |
+| `.SourcePath` | Slash-separated source path relative to the site root. |
+| `.OutputPath` | Slash-separated generated path relative to the site root. |
+| `.URL` | Root-relative URL. Markdown `index.html` outputs use their directory URL. |
+| `.CanonicalURL` | `.URL` beneath `canonical-root` |
+| `.Title` | First H1, then frontmatter `title`, then a filename-derived fallback. The extracted H1 is removed from the body. |
+| `.Meta` | `map[string]any` containing optional YAML frontmatter. |
+| `.ModTime` | Source-file modification time as `time.Time`. |
+| `.Sections` | `[]template.HTML` containing one rendered fragment per top-level Markdown node. Raw Markdown HTML is disabled before these trusted fragments are created. |
+| `.Pages` | All discovered Markdown pages grouped by ancestor directory. This field belongs to the root template value, not `Page`. |
+
+`.Pages` uses slash-separated directory keys. The root key is `""`. A page at
+`notes/recipes/cocktails/old_fashioned.md` appears under `""`, `"notes"`,
+`"notes/recipes"`, and `"notes/recipes/cocktails"`; every collection points to
+the same `Page`. Underscore-excluded sources and explicit output templates do
+not enter the inventory.
+
+Explicit `*.html.tmpl` and `*.xml.tmpl` outputs receive their source and output
+paths, URL fields, an empty `.Meta`, and the complete `.Pages` inventory. They
+have no Markdown title, modification time, or sections.
+
+## Template functions
+
+| Function | Result |
+| --- | --- |
+| `rel from target` | Makes a root-relative site target relative to the directory containing `from`. HTTP(S), `mailto:`, and fragment targets pass through. A trailing `/` is preserved, so `rel "about/index.html" "/"` returns `../`. |
+| `sortPages pages key` | Returns a copy. `"title"` sorts ascending case-insensitively; `"mod_time"` sorts newest first; every other key sorts newest first using that frontmatter value as a date. |
+| `filterPages pages key value` | Keeps pages whose value stringifies equally. `"title"` and `"output"` select `.Title` and `.OutputPath`; other keys select `.Meta[key]`. |
+| `slicePages pages start end` | Returns the half-open range `[start:end]`, clamping both bounds instead of failing. |
+| `joinSections sections` | Concatenates rendered Markdown sections and returns trusted `template.HTML`. |
+| `date value layout` | Parses a `time.Time`, RFC 3339 string, or `YYYY-MM-DD` string and formats it with a Go time layout. An invalid value formats as the zero time. |
+| `rfc3339 value` | Parses the same date values and formats them as RFC 3339. An invalid value formats as the zero time. |
+| `xml value` | Converts a trusted value back to a plain string so `html/template` escapes it in XML text. Use this around `joinSections` for escaped Atom `content type="html"`. |
+
+Examples:
+
+```gotemplate
+<a href="{{rel .OutputPath "/notes/"}}">notes</a>
+{{range sortPages (index .Pages "weblog") "post_date"}}{{.Title}}{{end}}
+{{with index .Sections 0}}{{.}}{{end}}
+{{joinSections (slice .Sections 1)}}
+<content type="html">{{xml (joinSections .Sections)}}</content>
+```
+
+All standard `html/template` actions and functions remain available, including
+`and`, `or`, `not`, `call`, `html`, `index`, `slice`, `js`, `len`, `print`,
+`printf`, `println`, `urlquery`, `eq`, `ne`, `lt`, `le`, `gt`, and `ge`.
+`html/template` applies contextual escaping to template output.
+
+## Git ignore management
+
+`-gitignore` atomically replaces a marked block in the site-root `.gitignore`
+with exact, root-anchored paths for the current generated outputs. Content
+outside the block is preserved, and stale output entries are removed. Without
+the flag, Weft does not touch `.gitignore`.
+
+```gitignore
+# BEGIN weft generated outputs
+/feed.xml
+/index.html
+# END weft generated outputs
+```
Weft validates generated internal links and XML before changing the site. It
tracks ownership in `.weft-generated.json`, refuses to replace untracked files,
@@ -45,5 +113,5 @@ For cron, schedule the build after Syncthing's settling window and use the
host's lock and logging tools, for example:
```cron
-17 * * * * sleep 60 && flock -n /run/lock/weft.lock /usr/local/bin/weft /srv/tjp.lol >>/var/log/weft.log 2>&1 || logger -t weft 'build failed or lock unavailable'
+17 * * * * sleep 60 && flock -n /run/lock/weft.lock /usr/local/bin/weft -gitignore /srv/tjp.lol https://tjp.lol >>/var/log/weft.log 2>&1 || logger -t weft 'build failed or lock unavailable'
```
diff --git a/go.mod b/go.mod
index 80fa1f1..9eff62a 100644
--- a/go.mod
+++ b/go.mod
@@ -1,4 +1,4 @@
-module tjp.lol/weft
+module weft
go 1.24
diff --git a/main.go b/main.go
index aafbfc1..7f49389 100644
--- a/main.go
+++ b/main.go
@@ -30,6 +30,11 @@ import (
const manifestName = ".weft-generated.json"
+const (
+ gitignoreStart = "# BEGIN weft generated outputs"
+ gitignoreEnd = "# END weft generated outputs"
+)
+
type Page struct {
SourcePath string
OutputPath string
@@ -54,12 +59,13 @@ type source struct {
}
type site struct {
- root string
- pages map[string][]*Page
- sources []source
- templates *template.Template
- fallback string
- warnings []string
+ root string
+ canonicalRoot string
+ pages map[string][]*Page
+ sources []source
+ templates *template.Template
+ fallback string
+ warnings []string
}
type manifest struct {
@@ -69,25 +75,24 @@ type manifest struct {
func main() {
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>") }
+ manageGitignore := flags.Bool("gitignore", false, "manage generated outputs in the site .gitignore")
+ flags.Usage = func() {
+ fmt.Fprintln(flags.Output(), "usage: weft [-gitignore] [-fallback template] <site-root> <canonical-root>")
+ }
if err := flags.Parse(os.Args[1:]); err != nil {
os.Exit(2)
}
- if flags.NArg() != 1 {
+ if flags.NArg() != 2 {
flags.Usage()
os.Exit(2)
}
- if err := buildWithFallback(flags.Arg(0), *fallback); err != nil {
+ if err := buildWithFallback(flags.Arg(0), flags.Arg(1), *fallback, *manageGitignore); 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 {
+func buildWithFallback(root, canonicalRoot, fallback string, manageGitignore bool) error {
if strings.TrimSpace(fallback) == "" {
return errors.New("fallback template cannot be empty")
}
@@ -103,7 +108,7 @@ func buildWithFallback(root, fallback string) error {
return fmt.Errorf("site root is not a directory: %s", abs)
}
- s := &site{root: abs, fallback: fallback, pages: map[string][]*Page{}}
+ s := &site{root: abs, canonicalRoot: canonicalRoot, fallback: fallback, pages: map[string][]*Page{}}
if err := s.discover(); err != nil {
return err
}
@@ -117,7 +122,7 @@ func buildWithFallback(root, fallback string) error {
if err := s.validate(outputs); err != nil {
return err
}
- if err := s.write(outputs); err != nil {
+ if err := s.write(outputs, manageGitignore); err != nil {
return err
}
for _, warning := range s.warnings {
@@ -248,7 +253,7 @@ func (s *site) parsePage(rel, out string, info fs.FileInfo) (*Page, []string, er
}
return &Page{
SourcePath: rel, OutputPath: out, URL: urlPath,
- CanonicalURL: "https://tjp.lol" + urlPath, Title: title, Meta: meta,
+ CanonicalURL: s.canonicalRoot + urlPath, Title: title, Meta: meta,
ModTime: info.ModTime(), Sections: sections,
}, warnings, nil
}
@@ -392,7 +397,7 @@ func relativePath(from, target string) string {
if err != nil {
return target
}
- rel = filepath.ToSlash(rel)
+ rel = path.Clean(filepath.ToSlash(rel))
if directory {
return strings.TrimSuffix(rel, "/") + "/"
}
@@ -484,7 +489,7 @@ func (s *site) render() (map[string][]byte, error) {
current = src.page
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{}}
+ current = &Page{SourcePath: src.path, OutputPath: src.output, URL: "/" + src.output, CanonicalURL: s.canonicalRoot + src.output, Meta: map[string]any{}}
}
if s.templates.Lookup(name) == nil {
return nil, fmt.Errorf("%s requires missing template %q", src.path, name)
@@ -617,7 +622,7 @@ func (s *site) readManifest() (map[string]bool, error) {
return tracked, nil
}
-func (s *site) write(outputs map[string][]byte) error {
+func (s *site) write(outputs map[string][]byte, manageGitignore bool) error {
tracked, err := s.readManifest()
if err != nil {
return err
@@ -661,6 +666,15 @@ func (s *site) write(outputs map[string][]byte) error {
if err := os.WriteFile(filepath.Join(stage, manifestName), manifestBytes, 0o644); err != nil {
return err
}
+ if manageGitignore {
+ contents, err := s.gitignore(paths)
+ if err != nil {
+ return err
+ }
+ if err := os.WriteFile(filepath.Join(stage, ".gitignore"), contents, 0o644); err != nil {
+ return err
+ }
+ }
var movedOld, installed []string
rollback := func() {
@@ -699,7 +713,11 @@ func (s *site) write(outputs map[string][]byte) error {
}
}
}
- for _, rel := range append(paths, manifestName) {
+ install := append(slices.Clone(paths), manifestName)
+ if manageGitignore {
+ install = append(install, ".gitignore")
+ }
+ for _, rel := range install {
if err := moveOld(rel); err != nil {
rollback()
return err
@@ -717,3 +735,53 @@ func (s *site) write(outputs map[string][]byte) error {
}
return nil
}
+
+func (s *site) gitignore(outputs []string) ([]byte, error) {
+ raw, err := os.ReadFile(filepath.Join(s.root, ".gitignore"))
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return nil, err
+ }
+ var kept strings.Builder
+ inBlock, seen := false, false
+ for _, line := range strings.SplitAfter(string(raw), "\n") {
+ value := strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r")
+ switch value {
+ case gitignoreStart:
+ if inBlock || seen {
+ return nil, errors.New("invalid managed block in .gitignore")
+ }
+ inBlock, seen = true, true
+ case gitignoreEnd:
+ if !inBlock {
+ return nil, errors.New("invalid managed block in .gitignore")
+ }
+ inBlock = false
+ default:
+ if !inBlock {
+ kept.WriteString(line)
+ }
+ }
+ }
+ if inBlock {
+ return nil, errors.New("unterminated managed block in .gitignore")
+ }
+ if kept.Len() > 0 && !strings.HasSuffix(kept.String(), "\n") {
+ kept.WriteByte('\n')
+ }
+ kept.WriteString(gitignoreStart + "\n")
+ for _, output := range outputs {
+ if strings.ContainsAny(output, "\r\n") {
+ return nil, fmt.Errorf("cannot add output to .gitignore: %q", output)
+ }
+ kept.WriteString("/" + escapeGitignore(output) + "\n")
+ }
+ kept.WriteString(gitignoreEnd + "\n")
+ return []byte(kept.String()), nil
+}
+
+func escapeGitignore(value string) string {
+ return strings.NewReplacer(
+ `\`, `\\`, " ", `\ `, "#", `\#`, "!", `\!`,
+ "[", `\[`, "]", `\]`, "*", `\*`, "?", `\?`,
+ ).Replace(value)
+}
diff --git a/main_test.go b/main_test.go
index 91f432b..cbb102c 100644
--- a/main_test.go
+++ b/main_test.go
@@ -21,6 +21,10 @@ func writeTestFile(t *testing.T, root, name, contents string) {
}
}
+func build(root string) error {
+ return buildWithFallback(root, "https://example.com", "page", false)
+}
+
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}}
@@ -148,7 +152,7 @@ func TestMarkdownTemplateFallsThroughPathThenFallback(t *testing.T) {
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 {
+ if err := buildWithFallback(root, "https://example.com", "default", false); err != nil {
t.Fatal(err)
}
for name, want := range map[string]string{
@@ -269,6 +273,39 @@ func TestManifestRejectsNonOutputPaths(t *testing.T) {
}
}
+func TestGitignoreTracksOnlyCurrentGeneratedOutputs(t *testing.T) {
+ root := t.TempDir()
+ writeTestFile(t, root, ".gitignore", "*.log\n/pantograph/cache.html\n")
+ writeTestFile(t, root, "layouts.tmpl", testLayouts())
+ writeTestFile(t, root, "one.md", "# One\n")
+ writeTestFile(t, root, "nested/two.md", "# Two\n")
+ if err := buildWithFallback(root, "https://example.com", "page", true); err != nil {
+ t.Fatal(err)
+ }
+ want := "*.log\n/pantograph/cache.html\n" + gitignoreStart + "\n/nested/two.html\n/one.html\n" + gitignoreEnd + "\n"
+ if raw, _ := os.ReadFile(filepath.Join(root, ".gitignore")); string(raw) != want {
+ t.Fatalf(".gitignore = %q, want %q", raw, want)
+ }
+ if err := os.Remove(filepath.Join(root, "nested/two.md")); err != nil {
+ t.Fatal(err)
+ }
+ if err := buildWithFallback(root, "https://example.com", "page", true); err != nil {
+ t.Fatal(err)
+ }
+ want = "*.log\n/pantograph/cache.html\n" + gitignoreStart + "\n/one.html\n" + gitignoreEnd + "\n"
+ if raw, _ := os.ReadFile(filepath.Join(root, ".gitignore")); string(raw) != want {
+ t.Fatalf("updated .gitignore = %q, want %q", raw, want)
+ }
+ writeTestFile(t, root, ".gitignore", gitignoreStart+"\n")
+ writeTestFile(t, root, "one.md", "# Changed\n")
+ if err := buildWithFallback(root, "https://example.com", "page", true); err == nil {
+ t.Fatal("build accepted an unterminated managed block")
+ }
+ if raw, _ := os.ReadFile(filepath.Join(root, "one.html")); !strings.Contains(string(raw), "<title>One</title>") {
+ t.Fatalf("failed .gitignore update changed output: %s", raw)
+ }
+}
+
func TestSortingFilteringSlicing(t *testing.T) {
a := &Page{Title: "A", Meta: map[string]any{"post_date": "2024-01-01", "kind": "x"}, ModTime: time.Unix(1, 0)}
b := &Page{Title: "B", Meta: map[string]any{"post_date": "2025-01-01", "kind": "y"}, ModTime: time.Unix(2, 0)}