about summary refs log tree commit diff stats
path: root/internal/storage/files/writer.go
diff options
context:
space:
mode:
authorAlan Pearce2025-01-26 11:52:12 +0100
committerAlan Pearce2025-01-26 13:38:16 +0100
commit7d9e98a0996eca0372e38cee4b8826d84a9ace2b (patch)
tree6ae88100a923012f04ec316a45990b6902140136 /internal/storage/files/writer.go
parent93c01942cb379b448dafab7ceffd78c005772928 (diff)
downloadwebsite-7d9e98a0996eca0372e38cee4b8826d84a9ace2b.tar.lz
website-7d9e98a0996eca0372e38cee4b8826d84a9ace2b.tar.zst
website-7d9e98a0996eca0372e38cee4b8826d84a9ace2b.zip
refactor outputs->storage for generalisation
Diffstat (limited to 'internal/storage/files/writer.go')
-rw-r--r--internal/storage/files/writer.go212
1 files changed, 212 insertions, 0 deletions
diff --git a/internal/storage/files/writer.go b/internal/storage/files/writer.go
new file mode 100644
index 0000000..6f10fe7
--- /dev/null
+++ b/internal/storage/files/writer.go
@@ -0,0 +1,212 @@
+package files
+
+import (
+	"compress/gzip"
+	"context"
+	"io"
+	"io/fs"
+	"os"
+	"path"
+	"path/filepath"
+
+	"go.alanpearce.eu/x/log"
+
+	"github.com/a-h/templ"
+	"github.com/andybalholm/brotli"
+	"gitlab.com/tozd/go/errors"
+)
+
+const (
+	gzipLevel   = 6
+	brotliLevel = 9
+)
+
+type Files struct {
+	outputDirectory string
+	options         *Options
+	log             *log.Logger
+}
+
+type Options struct {
+	Compress bool
+}
+
+func NewWriter(outputDirectory string, logger *log.Logger, opts *Options) *Files {
+	return &Files{
+		outputDirectory: outputDirectory,
+		options:         opts,
+		log:             logger,
+	}
+}
+
+func (f *Files) CopyRecursive(src string) error {
+	return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
+		if err != nil {
+			return err
+		}
+		rel, err := filepath.Rel(src, path)
+		if err != nil {
+			return err
+		}
+		if d.IsDir() {
+			return f.mkdirp(rel)
+		}
+
+		sf, err := os.Open(path)
+		if err != nil {
+			return err
+		}
+		defer sf.Close()
+		df, err := f.openFileAndVariants(filepath.Join(f.outputDirectory, rel))
+		if err != nil {
+			return err
+		}
+		defer df.Close()
+		if _, err := io.Copy(df, sf); err != nil {
+			return err
+		}
+
+		return nil
+	})
+}
+
+func (f *Files) Open(filename string) (io.ReadCloser, error) {
+	return os.Open(filepath.Join(f.outputDirectory, filename))
+}
+
+func (f *Files) OutputToFile(output io.Reader, filename string) error {
+	fn := path.Join(f.outputDirectory, filename)
+	if err := f.mkdirp(filepath.Dir(filename)); err != nil {
+		return err
+	}
+	f.log.Debug("outputting file", "filename", fn)
+	file, err := f.openFileAndVariants(fn)
+	if err != nil {
+		return errors.WithMessage(err, "could not open output file")
+	}
+	defer file.Close()
+
+	if _, err := io.Copy(file, output); err != nil {
+		return errors.WithMessage(err, "could not write output file")
+	}
+
+	return nil
+}
+
+func (f *Files) RenderToFile(component templ.Component, filename string) error {
+	fn := path.Join(f.outputDirectory, filename)
+	if err := f.mkdirp(filepath.Dir(filename)); err != nil {
+		return err
+	}
+	f.log.Debug("outputting file", "filename", filename)
+	file, err := f.openFileAndVariants(fn)
+	if err != nil {
+		return errors.WithMessage(err, "could not open output file")
+	}
+	defer file.Close()
+
+	if err := component.Render(context.TODO(), file); err != nil {
+		return errors.WithMessage(err, "could not write output file")
+	}
+
+	return nil
+}
+
+func (f *Files) WriterToFile(writer io.WriterTo, filename string) error {
+	fn := path.Join(f.outputDirectory, filename)
+	if err := f.mkdirp(filepath.Dir(filename)); err != nil {
+		return err
+	}
+	f.log.Debug("outputting file", "filename", fn)
+	file, err := f.openFileAndVariants(fn)
+	if err != nil {
+		return errors.WithMessage(err, "could not open output file")
+	}
+	defer file.Close()
+
+	if _, err := writer.WriteTo(file); err != nil {
+		return errors.WithMessage(err, "could not write output file")
+	}
+
+	return nil
+}
+
+type MultiWriteCloser struct {
+	writers     []io.WriteCloser
+	multiWriter io.Writer
+}
+
+func (mw *MultiWriteCloser) Write(p []byte) (n int, err error) {
+	return mw.multiWriter.Write(p)
+}
+
+func (mw *MultiWriteCloser) Close() error {
+	var lastErr error
+	for _, w := range mw.writers {
+		err := w.Close()
+		if err != nil {
+			lastErr = err
+		}
+	}
+
+	return lastErr
+}
+
+func openFileWrite(filename string) (*os.File, error) {
+	return os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
+}
+
+func openFileGz(filename string) (*gzip.Writer, error) {
+	filenameGz := filename + ".gz"
+	f, err := openFileWrite(filenameGz)
+	if err != nil {
+		return nil, err
+	}
+
+	return gzip.NewWriterLevel(f, gzipLevel)
+}
+
+func openFileBrotli(filename string) (*brotli.Writer, error) {
+	filenameBrotli := filename + ".br"
+	f, err := openFileWrite(filenameBrotli)
+	if err != nil {
+		return nil, err
+	}
+
+	return brotli.NewWriterLevel(f, brotliLevel), nil
+}
+
+func multiOpenFile(filename string) (*MultiWriteCloser, error) {
+	r, err := openFileWrite(filename)
+	if err != nil {
+		return nil, err
+	}
+	gz, err := openFileGz(filename)
+	if err != nil {
+		return nil, err
+	}
+	br, err := openFileBrotli(filename)
+	if err != nil {
+		return nil, err
+	}
+
+	return &MultiWriteCloser{
+		writers:     []io.WriteCloser{r, gz, br},
+		multiWriter: io.MultiWriter(r, gz, br),
+	}, nil
+}
+
+func (f *Files) openFileAndVariants(filename string) (io.WriteCloser, error) {
+	if f.options.Compress {
+		return multiOpenFile(filename)
+	}
+
+	return openFileWrite(filename)
+}
+
+func (f *Files) mkdirp(dir string) error {
+	f.log.Debug("creating directory", "dir", dir)
+	err := os.MkdirAll(path.Join(f.outputDirectory, dir), 0755)
+
+	return errors.WithMessage(err, "could not create directory")
+}