package main import ( "bytes" "context" "flag" "fmt" "io/fs" "os" "path/filepath" "strings" "github.com/a-h/templ" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/log" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/tdewolff/minify/v2" "github.com/tdewolff/minify/v2/css" "github.com/tdewolff/minify/v2/html" ) var ( ctx = context.Background() previewPort = ":8080" logger = log.New(os.Stderr) minifier = minify.New() ) func closeFile(file *os.File) { if err := file.Close(); err != nil { logger.Fatal("failed to close file", "err", err) } } // minifyInPlace replaces the file content with the minified version func minifyInPlace(mediatype, path string) error { tempPath := path + ".bak" err := os.Rename(path, tempPath) if err != nil { return err } inputFile, err := os.Open(tempPath) if err != nil { return err } // Open the file again for writing outputFile, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) if err != nil { return err } defer func() { closeFile(inputFile) closeFile(outputFile) if err := os.Remove(tempPath); err != nil { logger.Fatal("failed to delete temporary input file", "file", path) } }() return minifier.Minify(mediatype, outputFile, inputFile) } func genIndexFile(path string, c templ.Component) error { indexFile, err := os.Create(filepath.Join(path, "index.html")) if err != nil { return err } defer closeFile(indexFile) buf := new(bytes.Buffer) if err := c.Render(ctx, buf); err != nil { return err } return minifier.Minify("text/html", indexFile, buf) } func traverse(prefix, path string, d fs.DirEntry) error { // Generate home page for the root directory. if prefix == path { logger.Info("create index.html for root directory") return genIndexFile(path, indexPage()) } // Otherwise generate a listing page if d.IsDir() { trimedPath := strings.TrimPrefix(strings.TrimPrefix(path, prefix), "/") logger.Info("create index.html", "dir", trimedPath) entries, err := os.ReadDir(path) if err != nil { return err } return genIndexFile(path, listingPage(trimedPath, entries)) } return nil } // preview Previews the website at specified root directory on localhost: func preview(root, port string) error { e := echo.New() e.Use(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{ LogURI: true, LogError: true, LogStatus: true, LogMethod: true, LogLatency: true, LogValuesFunc: func(_ echo.Context, v middleware.RequestLoggerValues) error { logger. With("Method", v.Method). With("URI", v.URI). With("Status", v.Status). With("Latency", v.Latency). Info("request") if v.Error != nil { logger.Error("request", "err", v.Error) } return nil }, })) e.Static("/", root) return e.Start(port) } func main() { genFlag := flag.Bool("gen", false, "Generate index files") previewFlag := flag.Bool("preview", false, "Preview the built website") minifyFlag := flag.Bool("minify", false, "Minify existing CSS files") flag.Usage = func() { fmt.Printf(`Usage: go run . [OPTIONS] DIR Arguments: DIR Path to the directory containing the source files Options: `) flag.PrintDefaults() } flag.Parse() // Being fancy ^-^ logger.SetStyles(&log.Styles{ Separator: lipgloss.NewStyle().Foreground(lipgloss.Color("#7b88a1")).Bold(true), Message: lipgloss.NewStyle().Foreground(lipgloss.Color("#7b88a1")), Key: lipgloss.NewStyle().Foreground(lipgloss.Color("4")).Bold(true), Levels: map[log.Level]lipgloss.Style{ log.FatalLevel: lipgloss.NewStyle(). Bold(true). Background(lipgloss.Color("5")). Foreground(lipgloss.Color("0")). Padding(0, 1, 0, 1). SetString("FATAL"), log.ErrorLevel: lipgloss.NewStyle(). Bold(true). Background(lipgloss.Color("1")). Foreground(lipgloss.Color("0")). Padding(0, 1, 0, 1). SetString("ERROR"), log.InfoLevel: lipgloss.NewStyle(). Bold(true). Background(lipgloss.Color("6")). Foreground(lipgloss.Color("0")). Padding(0, 1, 0, 1). SetString("INFO"), }, Keys: map[string]lipgloss.Style{ "err": lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("1")), }, }) // Load minifiers minifier.AddFunc("text/html", html.Minify) minifier.AddFunc("text/css", css.Minify) // The root directory argument is required if len(flag.Args()) != 1 { logger.Fatal("failed to parse arguments", "err", "exactly 1 argument DIR is required") } rootDir, err := filepath.Abs(flag.Arg(0)) if err != nil { logger.Fatal("unable to get root directory", "err", err) } // Run minification first, to ensure file size is calculated correctly later if *minifyFlag { err = filepath.WalkDir(rootDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.Type().IsRegular() && filepath.Ext(path) == ".css" { logger.Info("minify CSS", "file", strings.TrimPrefix(path, rootDir+"/")) return minifyInPlace("text/css", path) } return nil }) if err != nil { logger.Fatal("failed minifying CSS files", "err", err) } } if *genFlag { err = filepath.WalkDir(rootDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } return traverse(rootDir, path, d) }) if err != nil { logger.Fatal("failed to traverse root directory", "err", err) } } // Only preview after things are generated if *previewFlag { logger.Fatal("failed to preview website", "err", preview(rootDir, previewPort)) } }