From 2cf936847f9e04e28d41bc088aa954cf37138359 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 11 Jul 2026 18:14:04 -0600 Subject: track all generated outputs in .gitignore --- README.md | 88 +++++++++++++++++++++++++++++++++++++++++------ go.mod | 2 +- main.go | 110 +++++++++++++++++++++++++++++++++++++++++++++++------------ main_test.go | 39 ++++++++++++++++++++- 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 +notes +{{range sortPages (index .Pages "weblog") "post_date"}}{{.Title}}{{end}} +{{with index .Sections 0}}{{.}}{{end}} +{{joinSections (slice .Sections 1)}} +{{xml (joinSections .Sections)}} +``` + +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] ") } + manageGitignore := flags.Bool("gitignore", false, "manage generated outputs in the site .gitignore") + flags.Usage = func() { + fmt.Fprintln(flags.Output(), "usage: weft [-gitignore] [-fallback template] ") + } 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"}}{{.Title}}{{joinSections .Sections}}{{end}} {{define "weblog"}}{{.Title}}{{joinSections .Sections}}{{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), "One") { + 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)} -- cgit v1.3