diff options
| author | t <t@tjp.lol> | 2026-07-11 19:53:12 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-07-11 19:53:28 -0600 |
| commit | 10e19481611f21f8ad20986ecc1b9d8d7d89ff4d (patch) | |
| tree | be41f47a1f00463ce81d352d744c7fc0343b79d6 | |
| parent | 2cf936847f9e04e28d41bc088aa954cf37138359 (diff) | |
follow symlinks, but skip dot-prefixes
| -rw-r--r-- | README.md | 4 | ||||
| -rw-r--r-- | main.go | 68 | ||||
| -rw-r--r-- | main_test.go | 44 |
3 files changed, 91 insertions, 25 deletions
@@ -107,7 +107,9 @@ the flag, Weft does not touch `.gitignore`. Weft validates generated internal links and XML before changing the site. It tracks ownership in `.weft-generated.json`, refuses to replace untracked files, removes only tracked stale outputs, and installs a completed build with rollback -on write failure. Files and whole subtrees beginning with `_` are ignored. +on write failure. Files and whole subtrees beginning with `_` or `.` are ignored. +Symlinked files and directories are followed under their logical site paths; +cycles fail the build. For cron, schedule the build after Syncthing's settling window and use the host's lock and logging tools, for example: @@ -133,7 +133,7 @@ func buildWithFallback(root, canonicalRoot, fallback string, manageGitignore boo func excluded(rel string) bool { for _, part := range strings.Split(filepath.ToSlash(rel), "/") { - if strings.HasPrefix(part, "_") || part == ".git" || part == ".jj" || strings.HasPrefix(part, ".weft-txn-") { + if strings.HasPrefix(part, "_") || strings.HasPrefix(part, ".") { return true } } @@ -143,28 +143,7 @@ func excluded(rel string) bool { func (s *site) discover() error { outputs := map[string]string{} var templatePaths []string - err := filepath.WalkDir(s.root, func(full string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - rel, err := filepath.Rel(s.root, full) - if err != nil || rel == "." { - return err - } - if excluded(rel) { - if entry.IsDir() { - return filepath.SkipDir - } - return nil - } - if entry.IsDir() { - return nil - } - rel = filepath.ToSlash(rel) - info, err := entry.Info() - if err != nil { - return err - } + err := walkFiles(s.root, func(rel string, info fs.FileInfo) error { switch { case strings.HasSuffix(rel, ".md"): out := strings.TrimSuffix(rel, ".md") + ".html" @@ -212,6 +191,49 @@ func (s *site) discover() error { return nil } +func walkFiles(root string, visit func(string, fs.FileInfo) error) error { + active := map[string]bool{} + var walk func(string, string) error + walk = func(full, relDir string) error { + real, err := filepath.EvalSymlinks(full) + if err != nil { + return err + } + if active[real] { + return fmt.Errorf("symlink cycle at %s", filepath.ToSlash(relDir)) + } + active[real] = true + defer delete(active, real) + + entries, err := os.ReadDir(full) + if err != nil { + return err + } + for _, entry := range entries { + rel := filepath.Join(relDir, entry.Name()) + if excluded(rel) { + continue + } + name := filepath.Join(full, entry.Name()) + info, err := os.Stat(name) + if err != nil { + return err + } + if info.IsDir() { + if err := walk(name, rel); err != nil { + return err + } + continue + } + if err := visit(filepath.ToSlash(rel), info); err != nil { + return err + } + } + return nil + } + return walk(root, "") +} + func claim(outputs map[string]string, output, input string) error { if previous, ok := outputs[output]; ok { return fmt.Errorf("output collision: %s and %s both target %s", previous, input, output) diff --git a/main_test.go b/main_test.go index cbb102c..c4b0ec7 100644 --- a/main_test.go +++ b/main_test.go @@ -97,6 +97,8 @@ func TestRecursiveInventoryAndUnderscoreExclusion(t *testing.T) { writeTestFile(t, root, "notes/recipes/cocktails/old.md", "# Old\n") writeTestFile(t, root, "notes/_private/secret.md", "# Secret\n") writeTestFile(t, root, "notes/_draft.md", "# Draft\n") + writeTestFile(t, root, "notes/.private/secret.md", "# Secret\n") + writeTestFile(t, root, "notes/.draft.md", "# Draft\n") s := &site{root: root, pages: map[string][]*Page{}} if err := s.discover(); err != nil { t.Fatal(err) @@ -107,7 +109,47 @@ func TestRecursiveInventoryAndUnderscoreExclusion(t *testing.T) { } } if len(s.sources) != 1 { - t.Fatalf("underscore sources were discovered: %#v", s.sources) + t.Fatalf("private sources were discovered: %#v", s.sources) + } +} + +func TestDiscoveryFollowsSymlinksAndRejectsCycles(t *testing.T) { + root := t.TempDir() + vault := t.TempDir() + writeTestFile(t, root, "layouts.tmpl", testLayouts()) + writeTestFile(t, vault, "recipe.md", "# Recipe\n") + writeTestFile(t, vault, "_private/secret.md", "# Secret\n") + if err := os.Symlink(vault, filepath.Join(root, "notes")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + s := &site{root: root, pages: map[string][]*Page{}} + if err := s.discover(); err != nil { + t.Fatal(err) + } + found := false + for _, source := range s.sources { + if source.path == "notes/recipe.md" { + found = true + } + if strings.Contains(source.path, "_private") { + t.Fatalf("private symlink source discovered: %s", source.path) + } + } + if !found { + t.Fatalf("symlink sources = %#v", s.sources) + } + if err := build(root); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(vault, "recipe.html")); err != nil { + t.Fatalf("symlinked page was not generated: %v", err) + } + if err := os.Symlink(root, filepath.Join(root, "loop")); err != nil { + t.Fatal(err) + } + s = &site{root: root, pages: map[string][]*Page{}} + if err := s.discover(); err == nil || !strings.Contains(err.Error(), "symlink cycle") { + t.Fatalf("cycle error = %v", err) } } |
