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.
This commit is contained in:
		
							
								
								
									
										244
									
								
								hswg/main.go
									
									
									
									
									
								
							
							
						
						
									
										244
									
								
								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("<a href='%s'>%s</a>", e.PathDestination, name)
 | 
			
		||||
	return fmt.Sprintf("<a href='%s'>%s</a>",
 | 
			
		||||
		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 <tags>.
 | 
			
		||||
		// 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 <tags>.
 | 
			
		||||
		// 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)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			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")
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Write an index.
 | 
			
		||||
	if err := writeIndex(argIndex, tmplIndex, &entries); err != nil {
 | 
			
		||||
		log.Fatalln(err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user