Переглянути джерело

Refactor 1/2 + new stuff

* Refactor CLI
* Add config:print command
* Add diagnose command
* Allow including other files in config
* Watch for file changes and automatically restart server
Svilen Markov 8 місяців тому
батько
коміт
2b0dd3ab99
8 змінених файлів з 568 додано та 70 видалено
  1. 1 0
      docs/configuration.md
  2. 2 0
      go.mod
  3. 4 0
      go.sum
  4. 41 16
      internal/glance/cli.go
  5. 182 18
      internal/glance/config.go
  6. 219 0
      internal/glance/diagnose.go
  7. 26 18
      internal/glance/glance.go
  8. 93 18
      internal/glance/main.go

+ 1 - 0
docs/configuration.md

@@ -34,6 +34,7 @@
   - [HTML](#html)
   - [HTML](#html)
 
 
 ## Intro
 ## Intro
+<!-- TODO: update -->
 Configuration is done via a single YAML file and a server restart is required in order for any changes to take effect. Trying to start the server with an invalid config file will result in an error.
 Configuration is done via a single YAML file and a server restart is required in order for any changes to take effect. Trying to start the server with an invalid config file will result in an error.
 
 
 ## Preconfigured page
 ## Preconfigured page

+ 2 - 0
go.mod

@@ -3,6 +3,7 @@ module github.com/glanceapp/glance
 go 1.23.1
 go 1.23.1
 
 
 require (
 require (
+	github.com/fsnotify/fsnotify v1.8.0
 	github.com/mmcdole/gofeed v1.3.0
 	github.com/mmcdole/gofeed v1.3.0
 	github.com/tidwall/gjson v1.18.0
 	github.com/tidwall/gjson v1.18.0
 	golang.org/x/text v0.18.0
 	golang.org/x/text v0.18.0
@@ -19,4 +20,5 @@ require (
 	github.com/tidwall/match v1.1.1 // indirect
 	github.com/tidwall/match v1.1.1 // indirect
 	github.com/tidwall/pretty v1.2.1 // indirect
 	github.com/tidwall/pretty v1.2.1 // indirect
 	golang.org/x/net v0.29.0 // indirect
 	golang.org/x/net v0.29.0 // indirect
+	golang.org/x/sys v0.25.0 // indirect
 )
 )

+ 4 - 0
go.sum

@@ -5,6 +5,8 @@ github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
+github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
@@ -52,6 +54,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
+golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=

+ 41 - 16
internal/glance/cli.go

@@ -2,41 +2,66 @@ package glance
 
 
 import (
 import (
 	"flag"
 	"flag"
+	"fmt"
 	"os"
 	"os"
+	"strings"
 )
 )
 
 
-type CliIntent uint8
+type cliIntent uint8
 
 
 const (
 const (
-	CliIntentServe       CliIntent = iota
-	CliIntentCheckConfig           = iota
+	cliIntentServe          cliIntent = iota
+	cliIntentConfigValidate           = iota
+	cliIntentConfigPrint              = iota
+	cliIntentDiagnose                 = iota
 )
 )
 
 
-type CliOptions struct {
-	Intent     CliIntent
-	ConfigPath string
+type cliOptions struct {
+	intent     cliIntent
+	configPath string
 }
 }
 
 
-func ParseCliOptions() (*CliOptions, error) {
+func parseCliOptions() (*cliOptions, error) {
 	flags := flag.NewFlagSet("", flag.ExitOnError)
 	flags := flag.NewFlagSet("", flag.ExitOnError)
+	flags.Usage = func() {
+		fmt.Println("Usage: glance [options] command")
 
 
-	checkConfig := flags.Bool("check-config", false, "Check whether the config is valid")
-	configPath := flags.String("config", "glance.yml", "Set config path")
+		fmt.Println("\nOptions:")
+		flags.PrintDefaults()
 
 
+		fmt.Println("\nCommands:")
+		fmt.Println("  config:validate     Validate the config file")
+		fmt.Println("  config:print        Print the parsed config file with embedded includes")
+		fmt.Println("  diagnose            Run diagnostic checks")
+	}
+	configPath := flags.String("config", "glance.yml", "Set config path")
 	err := flags.Parse(os.Args[1:])
 	err := flags.Parse(os.Args[1:])
-
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
 
 
-	intent := CliIntentServe
+	var intent cliIntent
+	var args = flags.Args()
+	unknownCommandErr := fmt.Errorf("unknown command: %s", strings.Join(args, " "))
 
 
-	if *checkConfig {
-		intent = CliIntentCheckConfig
+	if len(args) == 0 {
+		intent = cliIntentServe
+	} else if len(args) == 1 {
+		if args[0] == "config:validate" {
+			intent = cliIntentConfigValidate
+		} else if args[0] == "config:print" {
+			intent = cliIntentConfigPrint
+		} else if args[0] == "diagnose" {
+			intent = cliIntentDiagnose
+		} else {
+			return nil, unknownCommandErr
+		}
+	} else {
+		return nil, unknownCommandErr
 	}
 	}
 
 
-	return &CliOptions{
-		Intent:     intent,
-		ConfigPath: *configPath,
+	return &cliOptions{
+		intent:     intent,
+		configPath: *configPath,
 	}, nil
 	}, nil
 }
 }

+ 182 - 18
internal/glance/config.go

@@ -1,9 +1,16 @@
 package glance
 package glance
 
 
 import (
 import (
+	"bytes"
 	"fmt"
 	"fmt"
-	"io"
+	"log"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"time"
 
 
+	"github.com/fsnotify/fsnotify"
 	"gopkg.in/yaml.v3"
 	"gopkg.in/yaml.v3"
 )
 )
 
 
@@ -14,22 +21,16 @@ type Config struct {
 	Pages    []Page   `yaml:"pages"`
 	Pages    []Page   `yaml:"pages"`
 }
 }
 
 
-func NewConfigFromYml(contents io.Reader) (*Config, error) {
-	config := NewConfig()
-
-	contentBytes, err := io.ReadAll(contents)
-
-	if err != nil {
-		return nil, err
-	}
-
-	err = yaml.Unmarshal(contentBytes, config)
+func newConfigFromYAML(contents []byte) (*Config, error) {
+	config := &Config{}
+	config.Server.Port = 8080
 
 
+	err := yaml.Unmarshal(contents, config)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
 
 
-	if err = configIsValid(config); err != nil {
+	if err = isConfigStateValid(config); err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
 
 
@@ -46,16 +47,179 @@ func NewConfigFromYml(contents io.Reader) (*Config, error) {
 	return config, nil
 	return config, nil
 }
 }
 
 
-func NewConfig() *Config {
-	config := &Config{}
+var includePattern = regexp.MustCompile(`(?m)^(\s*)!include:\s*(.+)$`)
 
 
-	config.Server.Host = ""
-	config.Server.Port = 8080
+func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) {
+	mainFileContents, err := os.ReadFile(mainFilePath)
+	if err != nil {
+		return nil, nil, fmt.Errorf("could not read main YAML file: %w", err)
+	}
+
+	mainFileAbsPath, err := filepath.Abs(mainFilePath)
+	if err != nil {
+		return nil, nil, fmt.Errorf("could not get absolute path of main YAML file: %w", err)
+	}
+	mainFileDir := filepath.Dir(mainFileAbsPath)
+
+	includes := make(map[string]struct{})
+	var includesLastErr error
+
+	mainFileContents = includePattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte {
+		if includesLastErr != nil {
+			return nil
+		}
+
+		matches := includePattern.FindSubmatch(match)
+		if len(matches) != 3 {
+			includesLastErr = fmt.Errorf("invalid include match: %v", matches)
+			return nil
+		}
+
+		indent := string(matches[1])
+		includeFilePath := strings.TrimSpace(string(matches[2]))
+		if !filepath.IsAbs(includeFilePath) {
+			includeFilePath = filepath.Join(mainFileDir, includeFilePath)
+		}
+
+		var fileContents []byte
+		var err error
+
+		fileContents, err = os.ReadFile(includeFilePath)
+		if err != nil {
+			includesLastErr = fmt.Errorf("could not read included file: %w", err)
+			return nil
+		}
+
+		includes[includeFilePath] = struct{}{}
+		return []byte(prefixStringLines(indent, string(fileContents)))
+	})
+
+	if includesLastErr != nil {
+		return nil, nil, includesLastErr
+	}
+
+	return mainFileContents, includes, nil
+}
+
+func prefixStringLines(prefix string, s string) string {
+	lines := strings.Split(s, "\n")
+
+	for i, line := range lines {
+		lines[i] = prefix + line
+	}
+
+	return strings.Join(lines, "\n")
+}
+
+func configFilesWatcher(
+	mainFilePath string,
+	lastContents []byte,
+	lastIncludes map[string]struct{},
+	onChange func(newContents []byte),
+	onErr func(error),
+) (func() error, error) {
+	watcher, err := fsnotify.NewWatcher()
+	if err != nil {
+		return nil, fmt.Errorf("could not create watcher: %w", err)
+	}
+
+	if err = watcher.Add(mainFilePath); err != nil {
+		watcher.Close()
+		return nil, fmt.Errorf("could not add main file to watcher: %w", err)
+	}
+
+	updateWatchedIncludes := func(previousIncludes map[string]struct{}, newIncludes map[string]struct{}) {
+		for includePath := range previousIncludes {
+			if _, ok := newIncludes[includePath]; !ok {
+				watcher.Remove(includePath)
+			}
+		}
+
+		for includePath := range newIncludes {
+			if _, ok := previousIncludes[includePath]; !ok {
+				if err := watcher.Add(includePath); err != nil {
+					log.Printf(
+						"Could not add included config file to watcher, changes to this file will not trigger a reload. path: %s, error: %v",
+						includePath, err,
+					)
+				}
+			}
+		}
+	}
+
+	updateWatchedIncludes(nil, lastIncludes)
+
+	checkForContentChangesBeforeCallback := func() {
+		currentContents, currentIncludes, err := parseYAMLIncludes(mainFilePath)
+		if err != nil {
+			onErr(fmt.Errorf("could not parse main file contents for comparison: %w", err))
+			return
+		}
+
+		if !bytes.Equal(lastContents, currentContents) {
+			updateWatchedIncludes(lastIncludes, currentIncludes)
+			lastContents, lastIncludes = currentContents, currentIncludes
+			onChange(currentContents)
+		}
+	}
 
 
-	return config
+	const debounceDuration = 500 * time.Millisecond
+	var debounceTimer *time.Timer
+	debouncedCallback := func() {
+		if debounceTimer != nil {
+			debounceTimer.Stop()
+			debounceTimer.Reset(debounceDuration)
+		} else {
+			debounceTimer = time.AfterFunc(debounceDuration, checkForContentChangesBeforeCallback)
+		}
+	}
+
+	go func() {
+		for {
+			select {
+			case event, isOpen := <-watcher.Events:
+				if !isOpen {
+					return
+				}
+				if event.Has(fsnotify.Write) {
+					debouncedCallback()
+				}
+				// maybe also handle .Remove event?
+				// from testing it appears that a removed file will stop triggering .Write events
+				// when it gets recreated, in which case we may need to watch the directory for the
+				// creation of that file and then re-add it to the watcher, though that's
+				// a lot of effort for a hopefully rare edge case
+			case err, isOpen := <-watcher.Errors:
+				if !isOpen {
+					return
+				}
+				onErr(fmt.Errorf("watcher error: %w", err))
+			}
+		}
+	}()
+
+	onChange(lastContents)
+
+	return func() error {
+		if debounceTimer != nil {
+			debounceTimer.Stop()
+		}
+
+		return watcher.Close()
+	}, nil
 }
 }
 
 
-func configIsValid(config *Config) error {
+func isConfigStateValid(config *Config) error {
+	if len(config.Pages) == 0 {
+		return fmt.Errorf("no pages configured")
+	}
+
+	if config.Server.AssetsPath != "" {
+		if _, err := os.Stat(config.Server.AssetsPath); os.IsNotExist(err) {
+			return fmt.Errorf("assets directory does not exist: %s", config.Server.AssetsPath)
+		}
+	}
+
 	for i := range config.Pages {
 	for i := range config.Pages {
 		if config.Pages[i].Title == "" {
 		if config.Pages[i].Title == "" {
 			return fmt.Errorf("Page %d has no title", i+1)
 			return fmt.Errorf("Page %d has no title", i+1)

+ 219 - 0
internal/glance/diagnose.go

@@ -0,0 +1,219 @@
+package glance
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"os"
+	"runtime"
+	"strings"
+	"sync"
+	"time"
+)
+
+const httpTestRequestTimeout = 10 * time.Second
+
+var diagnosticSteps = []diagnosticStep{
+	{
+		name: "resolve cloudflare.com through Cloudflare DoH",
+		fn: func() (string, error) {
+			return testHttpRequestWithHeaders("GET", "https://1.1.1.1/dns-query?name=cloudflare.com", map[string]string{
+				"accept": "application/dns-json",
+			}, 200)
+		},
+	},
+	{
+		name: "resolve cloudflare.com through Google DoH",
+		fn: func() (string, error) {
+			return testHttpRequest("GET", "https://8.8.8.8/resolve?name=cloudflare.com", 200)
+		},
+	},
+	{
+		name: "resolve github.com",
+		fn: func() (string, error) {
+			return testDNSResolution("github.com")
+		},
+	},
+	{
+		name: "resolve reddit.com",
+		fn: func() (string, error) {
+			return testDNSResolution("reddit.com")
+		},
+	},
+	{
+		name: "resolve twitch.tv",
+		fn: func() (string, error) {
+			return testDNSResolution("twitch.tv")
+		},
+	},
+	{
+		name: "fetch data from YouTube RSS feed",
+		fn: func() (string, error) {
+			return testHttpRequest("GET", "https://www.youtube.com/feeds/videos.xml?channel_id=UCZU9T1ceaOgwfLRq7OKFU4Q", 200)
+		},
+	},
+	{
+		name: "fetch data from Twitch.tv GQL",
+		fn: func() (string, error) {
+			// this should always return 0 bytes, we're mainly looking for a 200 status code
+			return testHttpRequest("OPTIONS", "https://gql.twitch.tv/gql", 200)
+		},
+	},
+	{
+		name: "fetch data from GitHub API",
+		fn: func() (string, error) {
+			return testHttpRequest("GET", "https://api.github.com", 200)
+		},
+	},
+	{
+		name: "fetch data from Open-Meteo API",
+		fn: func() (string, error) {
+			return testHttpRequest("GET", "https://geocoding-api.open-meteo.com/v1/search?name=London", 200)
+		},
+	},
+	{
+		name: "fetch data from Reddit API",
+		fn: func() (string, error) {
+			return testHttpRequest("GET", "https://www.reddit.com/search.json", 200)
+		},
+	},
+	{
+		name: "fetch data from Yahoo finance API",
+		fn: func() (string, error) {
+			return testHttpRequest("GET", "https://query1.finance.yahoo.com/v8/finance/chart/NVDA", 200)
+		},
+	},
+	{
+		name: "fetch data from Hacker News Firebase API",
+		fn: func() (string, error) {
+			return testHttpRequest("GET", "https://hacker-news.firebaseio.com/v0/topstories.json", 200)
+		},
+	},
+	{
+		name: "fetch data from Docker Hub API",
+		fn: func() (string, error) {
+			return testHttpRequest("GET", "https://hub.docker.com/v2/namespaces/library/repositories/ubuntu/tags/latest", 200)
+		},
+	},
+}
+
+func runDiagnostic() {
+	fmt.Println("```")
+	fmt.Println("Glance version: " + buildVersion)
+	fmt.Println("Go version: " + runtime.Version())
+	fmt.Printf("Platform: %s / %s / %d CPUs\n", runtime.GOOS, runtime.GOARCH, runtime.NumCPU())
+	fmt.Println("In Docker container: " + boolToString(isRunningInsideDockerContainer(), "yes", "no"))
+
+	fmt.Printf("\nChecking network connectivity, this may take up to %d seconds...\n\n", int(httpTestRequestTimeout.Seconds()))
+
+	var wg sync.WaitGroup
+	for i := range diagnosticSteps {
+		step := &diagnosticSteps[i]
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			start := time.Now()
+			step.extraInfo, step.err = step.fn()
+			step.elapsed = time.Since(start)
+		}()
+	}
+	wg.Wait()
+
+	for _, step := range diagnosticSteps {
+		var extraInfo string
+
+		if step.extraInfo != "" {
+			extraInfo = "| " + step.extraInfo + " "
+		}
+
+		fmt.Printf(
+			"%s %s %s| %dms\n",
+			boolToString(step.err == nil, "✓ Can", "✗ Can't"),
+			step.name,
+			extraInfo,
+			step.elapsed.Milliseconds(),
+		)
+
+		if step.err != nil {
+			fmt.Printf("└╴ error: %v\n", step.err)
+		}
+	}
+	fmt.Println("```")
+}
+
+type diagnosticStep struct {
+	name      string
+	fn        func() (string, error)
+	extraInfo string
+	err       error
+	elapsed   time.Duration
+}
+
+func boolToString(b bool, trueValue, falseValue string) string {
+	if b {
+		return trueValue
+	}
+
+	return falseValue
+}
+
+func isRunningInsideDockerContainer() bool {
+	_, err := os.Stat("/.dockerenv")
+	return err == nil
+}
+
+func testHttpRequest(method, url string, expectedStatusCode int) (string, error) {
+	return testHttpRequestWithHeaders(method, url, nil, expectedStatusCode)
+}
+
+func testHttpRequestWithHeaders(method, url string, headers map[string]string, expectedStatusCode int) (string, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), httpTestRequestTimeout)
+	defer cancel()
+
+	request, _ := http.NewRequestWithContext(ctx, method, url, nil)
+	for key, value := range headers {
+		request.Header.Add(key, value)
+	}
+
+	response, err := http.DefaultClient.Do(request)
+	if err != nil {
+		return "", err
+	}
+	defer response.Body.Close()
+
+	body, err := io.ReadAll(response.Body)
+	if err != nil {
+		return "", err
+	}
+
+	printableBody := strings.ReplaceAll(string(body), "\n", "")
+	if len(printableBody) > 50 {
+		printableBody = printableBody[:50] + "..."
+	}
+	if len(printableBody) > 0 {
+		printableBody = ", " + printableBody
+	}
+
+	extraInfo := fmt.Sprintf("%d bytes%s", len(body), printableBody)
+
+	if response.StatusCode != expectedStatusCode {
+		return extraInfo, fmt.Errorf("expected status code %d, got %d", expectedStatusCode, response.StatusCode)
+	}
+
+	return extraInfo, nil
+}
+
+func testDNSResolution(domain string) (string, error) {
+	ips, err := net.LookupIP(domain)
+
+	var ipStrings []string
+	if err == nil {
+		for i := range ips {
+			ipStrings = append(ipStrings, ips[i].String())
+		}
+	}
+
+	return strings.Join(ipStrings, ", "), err
+}

+ 26 - 18
internal/glance/glance.go

@@ -5,7 +5,7 @@ import (
 	"context"
 	"context"
 	"fmt"
 	"fmt"
 	"html/template"
 	"html/template"
-	"log/slog"
+	"log"
 	"net/http"
 	"net/http"
 	"path/filepath"
 	"path/filepath"
 	"regexp"
 	"regexp"
@@ -122,11 +122,7 @@ func (a *Application) TransformUserDefinedAssetPath(path string) string {
 	return path
 	return path
 }
 }
 
 
-func NewApplication(config *Config) (*Application, error) {
-	if len(config.Pages) == 0 {
-		return nil, fmt.Errorf("no pages configured")
-	}
-
+func newApplication(config *Config) *Application {
 	app := &Application{
 	app := &Application{
 		Version:    buildVersion,
 		Version:    buildVersion,
 		Config:     *config,
 		Config:     *config,
@@ -180,7 +176,7 @@ func NewApplication(config *Config) (*Application, error) {
 
 
 	config.Branding.LogoURL = app.TransformUserDefinedAssetPath(config.Branding.LogoURL)
 	config.Branding.LogoURL = app.TransformUserDefinedAssetPath(config.Branding.LogoURL)
 
 
-	return app, nil
+	return app
 }
 }
 
 
 func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request) {
 func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request) {
@@ -276,7 +272,7 @@ func (a *Application) AssetPath(asset string) string {
 	return a.Config.Server.BaseURL + "/static/" + a.Config.Server.AssetsHash + "/" + asset
 	return a.Config.Server.BaseURL + "/static/" + a.Config.Server.AssetsHash + "/" + asset
 }
 }
 
 
-func (a *Application) Serve() error {
+func (a *Application) Server() (func() error, func() error) {
 	// TODO: add gzip support, static files must have their gzipped contents cached
 	// TODO: add gzip support, static files must have their gzipped contents cached
 	// TODO: add HTTPS support
 	// TODO: add HTTPS support
 	mux := http.NewServeMux()
 	mux := http.NewServeMux()
@@ -295,14 +291,9 @@ func (a *Application) Serve() error {
 		http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 24*time.Hour)),
 		http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 24*time.Hour)),
 	)
 	)
 
 
+	var absAssetsPath string
 	if a.Config.Server.AssetsPath != "" {
 	if a.Config.Server.AssetsPath != "" {
-		absAssetsPath, err := filepath.Abs(a.Config.Server.AssetsPath)
-
-		if err != nil {
-			return fmt.Errorf("invalid assets path: %s", a.Config.Server.AssetsPath)
-		}
-
-		slog.Info("Serving assets", "path", absAssetsPath)
+		absAssetsPath, _ = filepath.Abs(a.Config.Server.AssetsPath)
 		assetsFS := FileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour)
 		assetsFS := FileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour)
 		mux.Handle("/assets/{path...}", http.StripPrefix("/assets/", assetsFS))
 		mux.Handle("/assets/{path...}", http.StripPrefix("/assets/", assetsFS))
 	}
 	}
@@ -312,8 +303,25 @@ func (a *Application) Serve() error {
 		Handler: mux,
 		Handler: mux,
 	}
 	}
 
 
-	a.Config.Server.StartedAt = time.Now()
-	slog.Info("Starting server", "host", a.Config.Server.Host, "port", a.Config.Server.Port, "base-url", a.Config.Server.BaseURL)
+	start := func() error {
+		a.Config.Server.StartedAt = time.Now()
+		log.Printf("Starting server on %s:%d (base-url: \"%s\", assets-path: \"%s\")\n",
+			a.Config.Server.Host,
+			a.Config.Server.Port,
+			a.Config.Server.BaseURL,
+			absAssetsPath,
+		)
+
+		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+			return err
+		}
+
+		return nil
+	}
+
+	stop := func() error {
+		return server.Close()
+	}
 
 
-	return server.ListenAndServe()
+	return start, stop
 }
 }

+ 93 - 18
internal/glance/main.go

@@ -2,45 +2,120 @@ package glance
 
 
 import (
 import (
 	"fmt"
 	"fmt"
-	"os"
+	"log"
 )
 )
 
 
 func Main() int {
 func Main() int {
-	options, err := ParseCliOptions()
+	options, err := parseCliOptions()
 
 
 	if err != nil {
 	if err != nil {
 		fmt.Println(err)
 		fmt.Println(err)
 		return 1
 		return 1
 	}
 	}
 
 
-	configFile, err := os.Open(options.ConfigPath)
+	switch options.intent {
+	case cliIntentServe:
+		if err := serveApp(options.configPath); err != nil {
+			fmt.Println(err)
+			return 1
+		}
+	case cliIntentConfigValidate:
+		contents, _, err := parseYAMLIncludes(options.configPath)
+		if err != nil {
+			fmt.Printf("failed to parse config file: %v\n", err)
+			return 1
+		}
 
 
-	if err != nil {
-		fmt.Printf("failed opening config file: %v\n", err)
-		return 1
+		if _, err := newConfigFromYAML(contents); err != nil {
+			fmt.Printf("config file is invalid: %v\n", err)
+			return 1
+		}
+	case cliIntentConfigPrint:
+		contents, _, err := parseYAMLIncludes(options.configPath)
+		if err != nil {
+			fmt.Printf("failed to parse config file: %v\n", err)
+			return 1
+		}
+
+		fmt.Println(string(contents))
+	case cliIntentDiagnose:
+		runDiagnostic()
+	}
+
+	return 0
+}
+
+func serveApp(configPath string) error {
+	exitChannel := make(chan struct{})
+	// the onChange method gets called at most once per 500ms due to debouncing so we shouldn't
+	// need to use atomic.Bool here unless newConfigFromYAML is very slow for some reason
+	hadValidConfigOnStartup := false
+	var stopServer func() error
+
+	onChange := func(newContents []byte) {
+		if stopServer != nil {
+			log.Println("Config file changed, attempting to restart server")
+		}
+
+		config, err := newConfigFromYAML(newContents)
+		if err != nil {
+			log.Printf("Config file is invalid: %v", err)
+
+			if !hadValidConfigOnStartup {
+				close(exitChannel)
+			}
+
+			return
+		} else if !hadValidConfigOnStartup {
+			hadValidConfigOnStartup = true
+		}
+
+		app := newApplication(config)
+
+		if stopServer != nil {
+			if err := stopServer(); err != nil {
+				log.Printf("Error while trying to stop server: %v", err)
+			}
+		}
+
+		go func() {
+			var startServer func() error
+			startServer, stopServer = app.Server()
+
+			if err := startServer(); err != nil {
+				log.Printf("Failed to start server: %v", err)
+			}
+		}()
 	}
 	}
 
 
-	config, err := NewConfigFromYml(configFile)
-	configFile.Close()
+	onErr := func(err error) {
+		log.Printf("Error watching config files: %v", err)
+	}
 
 
+	configContents, configIncludes, err := parseYAMLIncludes(configPath)
 	if err != nil {
 	if err != nil {
-		fmt.Printf("failed parsing config file: %v\n", err)
-		return 1
+		return fmt.Errorf("failed to parse config file: %w", err)
 	}
 	}
 
 
-	if options.Intent == CliIntentServe {
-		app, err := NewApplication(config)
+	stopWatching, err := configFilesWatcher(configPath, configContents, configIncludes, onChange, onErr)
+	if err == nil {
+		defer stopWatching()
+	} else {
+		log.Printf("Error starting file watcher, config file changes will require a manual restart. (%v)", err)
 
 
+		config, err := newConfigFromYAML(configContents)
 		if err != nil {
 		if err != nil {
-			fmt.Printf("failed creating application: %v\n", err)
-			return 1
+			return fmt.Errorf("could not parse config file: %w", err)
 		}
 		}
 
 
-		if err := app.Serve(); err != nil {
-			fmt.Printf("http server error: %v\n", err)
-			return 1
+		app := newApplication(config)
+
+		startServer, _ := app.Server()
+		if err := startServer(); err != nil {
+			return fmt.Errorf("failed to start server: %w", err)
 		}
 		}
 	}
 	}
 
 
-	return 0
+	<-exitChannel
+	return nil
 }
 }