From 04e19f51862c7b0395586131a22146c63932d351 Mon Sep 17 00:00:00 2001
From: Přemysl Eric Janouch
Date: Tue, 22 Jun 2021 03:45:20 +0200
Subject: hswg: use inotify to watch for changed documents
Now we force the glob to be *.adoc, as well as *.asciidoc,
and there can only be one document directory.
The previous single-run mode is no longer supported.
---
hswg/main.go | 244 +++++++++++++++++++++++++++++++++++++++++++++++------------
1 file changed, 195 insertions(+), 49 deletions(-)
(limited to 'hswg')
diff --git a/hswg/main.go b/hswg/main.go
index 4626472..7854bf5 100644
--- a/hswg/main.go
+++ b/hswg/main.go
@@ -4,6 +4,7 @@ package main
import (
"bytes"
+ "encoding/binary"
"encoding/xml"
"fmt"
"html/template"
@@ -11,10 +12,12 @@ import (
"io/ioutil"
"log"
"os"
+ "os/signal"
"path/filepath"
"regexp"
"sort"
"strings"
+ "syscall"
"time"
"unicode"
"unicode/utf8"
@@ -157,7 +160,14 @@ func (e *Entry) Published() *time.Time {
}
}
-var extRE = regexp.MustCompile(`\.[^/.]*$`)
+var (
+ globs = []string{"*.adoc", "*.asciidoc"}
+ extRE = regexp.MustCompile(`\.[^/.]*$`)
+)
+
+func pathToName(path string) string {
+ return stripExtension(filepath.Base(path))
+}
func stripExtension(path string) string {
return extRE.ReplaceAllString(path, "")
@@ -172,7 +182,8 @@ func resultPath(path string) string {
func makeLink(m *map[string]*Entry, name string) string {
e := (*m)[name]
- return fmt.Sprintf("%s", e.PathDestination, name)
+ return fmt.Sprintf("%s",
+ filepath.Clean(e.PathDestination), name)
}
var linkWordRE = regexp.MustCompile(`\b\p{Lu}\p{L}*\b`)
@@ -219,31 +230,28 @@ func renderEntry(name string, e *Entry) error {
return nil
}
-func loadEntries(globs []string) (map[string]*Entry, error) {
- // Create a map from document names to their page entries.
+func makeEntry(path string) *Entry {
+ return &Entry{
+ PathSource: path,
+ PathDestination: resultPath(path),
+ }
+}
+
+// loadEntries creates a map from document names to their page entries.
+func loadEntries(dirname string) (map[string]*Entry, error) {
entries := map[string]*Entry{}
for _, glob := range globs {
- matches, err := filepath.Glob(glob)
+ matches, err := filepath.Glob(filepath.Join(dirname, glob))
if err != nil {
- return nil, fmt.Errorf("%s: %s\n", glob, err)
+ return nil, fmt.Errorf("%s: %s", dirname, err)
}
for _, path := range matches {
- name := stripExtension(filepath.Base(path))
+ name := pathToName(path)
if conflict, ok := entries[name]; ok {
- return nil, fmt.Errorf("%s: conflicts with %s\n",
+ return nil, fmt.Errorf("%s: conflicts with %s",
name, conflict.PathSource)
}
- entries[name] = &Entry{
- PathSource: path,
- PathDestination: resultPath(path),
- backlinks: map[string]bool{},
- }
- }
- }
-
- for name, e := range entries {
- if err := renderEntry(name, e); err != nil {
- return nil, err
+ entries[name] = makeEntry(path)
}
}
return entries, nil
@@ -269,21 +277,6 @@ func writeEntry(e *Entry, t *template.Template,
return t.Execute(f, e)
}
-func finalizeEntries(entries *map[string]*Entry) {
- for name, e := range *entries {
- // Expand LinkWords anywhere between .
- // We want something like the inverse of Regexp.ReplaceAllStringFunc.
- raw, last, expanded := e.raw, 0, bytes.NewBuffer(nil)
- for _, where := range tagRE.FindAllIndex(raw, -1) {
- _, _ = expanded.Write(expand(entries, name, raw[last:where[0]]))
- _, _ = expanded.Write(raw[where[0]:where[1]])
- last = where[1]
- }
- _, _ = expanded.Write(expand(entries, name, raw[last:]))
- e.Content = template.HTML(expanded.String())
- }
-}
-
func writeIndex(path string, t *template.Template,
entries *map[string]*Entry) error {
// Reorder entries reversely, primarily by date, secondarily by filename.
@@ -315,10 +308,144 @@ func writeIndex(path string, t *template.Template,
return err
}
- // TODO(p): Splitting content to categories would be nice.
+ // TODO(p): Splitting content to categories would be nice. Or tags.
return t.Execute(f, ordered)
}
+func finalizeEntries(entries *map[string]*Entry, t *template.Template,
+ indexPath string, indexT *template.Template) {
+ for name, e := range *entries {
+ e.backlinks = map[string]bool{}
+ if e.raw == nil {
+ if err := renderEntry(name, e); err != nil {
+ log.Printf("%s: %s\n", name, err)
+ }
+ }
+ }
+ for name, e := range *entries {
+ // Expand LinkWords anywhere between .
+ // We want something like the inverse of Regexp.ReplaceAllStringFunc.
+ raw, last, expanded := e.raw, 0, bytes.NewBuffer(nil)
+ for _, where := range tagRE.FindAllIndex(raw, -1) {
+ _, _ = expanded.Write(expand(entries, name, raw[last:where[0]]))
+ _, _ = expanded.Write(raw[where[0]:where[1]])
+ last = where[1]
+ }
+ _, _ = expanded.Write(expand(entries, name, raw[last:]))
+ e.Content = template.HTML(expanded.String())
+ }
+ for name, e := range *entries {
+ // Don't overwrite failed renders.
+ if e.raw == nil {
+ continue
+ }
+ if err := writeEntry(e, t, entries); err != nil {
+ log.Printf("%s: %s\n", name, err)
+ }
+ }
+ if err := writeIndex(indexPath, indexT, entries); err != nil {
+ log.Printf("%s: %s\n", indexPath, err)
+ }
+}
+
+type watchEvent struct {
+ path string // the path of the target
+ present bool // if not, the file has been removed
+}
+
+func dispatchEvents(dirname string, r io.Reader, ch chan<- *watchEvent) error {
+ var e syscall.InotifyEvent
+ for {
+ // FIXME(p): This has to respect the machine's endianness.
+ // Perhaps use the unsafe package.
+ err := binary.Read(r, binary.LittleEndian, &e)
+ if err == io.EOF {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+
+ switch {
+ case e.Mask&syscall.IN_IGNORED != 0:
+ return fmt.Errorf("watch removed by kernel")
+ case e.Mask&syscall.IN_Q_OVERFLOW != 0:
+ log.Println("inotify: queue overflowed")
+ ch <- nil
+ continue
+ case e.Len == 0:
+ continue
+ }
+
+ base := make([]byte, e.Len)
+ if n, err := r.Read(base); err != nil {
+ return err
+ } else if n < int(e.Len) {
+ return fmt.Errorf("short read")
+ }
+
+ basename, interesting := string(base[:bytes.IndexByte(base, 0)]), false
+ for _, glob := range globs {
+ if matches, _ := filepath.Match(glob, basename); matches {
+ interesting = true
+ }
+ }
+ if !interesting {
+ continue
+ }
+
+ event := &watchEvent{path: filepath.Join(dirname, basename)}
+ if e.Mask&syscall.IN_MODIFY != 0 || e.Mask&syscall.IN_MOVED_TO != 0 ||
+ e.Mask&syscall.IN_CLOSE_WRITE != 0 {
+ event.present = true
+ ch <- event
+ }
+ if e.Mask&syscall.IN_DELETE != 0 || e.Mask&syscall.IN_MOVED_FROM != 0 {
+ event.present = false
+ ch <- event
+ }
+ }
+}
+
+func watchDirectory(dirname string) (<-chan *watchEvent, error) {
+ inotifyFD, err := syscall.InotifyInit1(0)
+ if err != nil {
+ return nil, err
+ }
+
+ // We're ignoring IN_CREATE, as it doesn't seem to be useful,
+ // and we're leaving out IN_MODIFY since VIM always triggers IN_CLOSE_WRITE,
+ // saving us from having to coalesce plentiful similar events.
+ _, err = syscall.InotifyAddWatch(inotifyFD, dirname, syscall.IN_ONLYDIR|
+ syscall.IN_MOVE|syscall.IN_DELETE|syscall.IN_CLOSE_WRITE)
+ if err != nil {
+ return nil, err
+ }
+
+ inotifyFile := os.NewFile(uintptr(inotifyFD), "inotify")
+ buf := make([]byte, syscall.SizeofInotifyEvent+syscall.PathMax+1)
+ ch := make(chan *watchEvent)
+ go func() {
+ // Trigger an initial rendering run.
+ ch <- nil
+
+ defer close(ch)
+ for {
+ n, err := inotifyFile.Read(buf)
+ if err != nil {
+ log.Println(err)
+ return
+ }
+ err = dispatchEvents(dirname, bytes.NewReader(buf[:n]), ch)
+ if err != nil {
+ log.Printf("inotify: %s\n", err)
+ return
+ }
+ }
+ }()
+ return ch, nil
+}
+
func singleFile() {
html, meta, err := Render(os.Stdin, configuration.NewConfiguration())
if err != nil {
@@ -336,11 +463,11 @@ func main() {
singleFile()
return
}
- if len(os.Args) < 4 {
- log.Fatalf("usage: %s TEMPLATE INDEX GLOB...\n", os.Args[0])
+ if len(os.Args) != 4 {
+ log.Fatalf("usage: %s TEMPLATE INDEX DIRECTORY\n", os.Args[0])
}
- argTemplate, argIndex, argGlobs := os.Args[1], os.Args[2], os.Args[3:]
+ argTemplate, argIndex, argDirectory := os.Args[1], os.Args[2], os.Args[3]
// Read a template for entries.
header, err := ioutil.ReadFile(argTemplate)
@@ -362,21 +489,40 @@ func main() {
log.Fatalln(err)
}
- // Process all entries.
- entries, err := loadEntries(argGlobs)
+ // Re-render as needed, avoid having to trigger anything manually.
+ var entries map[string]*Entry
+ directoryWatch, err := watchDirectory(argDirectory)
if err != nil {
log.Fatalln(err)
}
- finalizeEntries(&entries)
- for _, e := range entries {
- if err := writeEntry(e, tmplEntry, &entries); err != nil {
- log.Fatalln(err)
- }
- }
+ signals := make(chan os.Signal)
+ signal.Notify(signals, syscall.SIGINT, syscall.SIGHUP, syscall.SIGTERM)
+ for {
+ select {
+ case <-signals:
+ os.Exit(0)
+ case event, ok := <-directoryWatch:
+ if !ok {
+ os.Exit(1)
+ }
- // Write an index.
- if err := writeIndex(argIndex, tmplIndex, &entries); err != nil {
- log.Fatalln(err)
+ if event == nil {
+ log.Println("reloading all files")
+ if entries, err = loadEntries(argDirectory); err != nil {
+ log.Println(err)
+ }
+ } else if event.present {
+ log.Printf("updating %s\n", event.path)
+ entries[pathToName(event.path)] = makeEntry(event.path)
+ } else {
+ log.Printf("removing %s\n", event.path)
+ delete(entries, pathToName(event.path))
+ os.Remove(resultPath(event.path))
+ }
+
+ finalizeEntries(&entries, tmplEntry, argIndex, tmplIndex)
+ log.Println("done")
+ }
}
}
--
cgit v1.2.3-70-g09d2