summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md4
-rw-r--r--main.go68
-rw-r--r--main_test.go44
3 files changed, 91 insertions, 25 deletions
diff --git a/README.md b/README.md
index f0d82ae..3a19cb4 100644
--- a/README.md
+++ b/README.md
@@ -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:
diff --git a/main.go b/main.go
index 7f49389..ec081ba 100644
--- a/main.go
+++ b/main.go
@@ -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)
}
}