feat: add configurable html minification #23

Merged
eleith merged 5 commits from gemini/feat/minify-html into main 2025-07-26 15:37:57 +00:00
14 changed files with 128 additions and 81 deletions

No files matched your search

+4
View File
@@ -2,7 +2,11 @@ build:
go build -mod=vendor -o miniflux-digest ./cmd/miniflux-digest
preview-html:
ifdef minify
go run -mod=vendor ./scripts/preview-html/main.go -minify=${minify}
else
go run -mod=vendor ./scripts/preview-html/main.go
endif
./scripts/open-preview.sh
preview-miniflux-email:
+1
View File
@@ -14,3 +14,4 @@ digest:
from: "SENDER_EMAIL@example.com"
schedule: "@every 24h" # Cron schedule for digest generation
host: "https://your-digest-host.com" # URL where HTML archives will be served
compress: true # Compress HTML before sending
+3
View File
@@ -7,6 +7,7 @@ require (
github.com/knadh/koanf/parsers/yaml v1.1.0
github.com/knadh/koanf/providers/file v1.2.0
github.com/knadh/koanf/v2 v2.2.2
github.com/tdewolff/minify/v2 v2.23.8
github.com/wneessen/go-mail v0.6.2
miniflux.app/v2 v2.2.10
)
@@ -17,11 +18,13 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/knadh/koanf/providers/confmap v1.0.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/tdewolff/parse/v2 v2.8.1 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/sys v0.34.0 // indirect
+8
View File
@@ -16,6 +16,8 @@ github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpb
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4=
github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg=
github.com/knadh/koanf/providers/confmap v1.0.0 h1:mHKLJTE7iXEys6deO5p6olAiZdG5zwp8Aebir+/EaRE=
github.com/knadh/koanf/providers/confmap v1.0.0/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A=
github.com/knadh/koanf/providers/file v1.2.0 h1:hrUJ6Y9YOA49aNu/RSYzOTFlqzXSCpmYIDXI7OJU6+U=
github.com/knadh/koanf/providers/file v1.2.0/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
github.com/knadh/koanf/v2 v2.2.2 h1:ghbduIkpFui3L587wavneC9e3WIliCgiCgdxYO/wd7A=
@@ -38,6 +40,12 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tdewolff/minify/v2 v2.23.8 h1:tvjHzRer46kwOfpdCBCWsDblCw3QtnLJRd61pTVkyZ8=
github.com/tdewolff/minify/v2 v2.23.8/go.mod h1:VW3ISUd3gDOZuQ/jwZr4sCzsuX+Qvsx87FDMjk6Rvno=
github.com/tdewolff/parse/v2 v2.8.1 h1:J5GSHru6o3jF1uLlEKVXkDxxcVx6yzOlIVIotK4w2po=
github.com/tdewolff/parse/v2 v2.8.1/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/wneessen/go-mail v0.6.2 h1:c6V7c8D2mz868z9WJ+8zDKtUyLfZ1++uAZmo2GRFji8=
github.com/wneessen/go-mail v0.6.2/go.mod h1:L/PYjPK3/2ZlNb2/FjEBIn9n1rUWjW+Toy531oVmeb4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+1 -1
View File
@@ -9,7 +9,7 @@ import (
)
type ArchiveService interface {
MakeArchiveHTML(data *models.HTMLTemplateData) (*os.File, error)
MakeArchiveHTML(data *models.HTMLTemplateData, compress bool) (*os.File, error)
CleanArchive(maxAge time.Duration)
}
+8 -9
View File
@@ -7,6 +7,7 @@ import (
"io/fs"
"log"
"miniflux-digest/internal/app"
"miniflux-digest/internal/digest"
"miniflux-digest/internal/models"
"miniflux-digest/internal/templates"
"miniflux-digest/internal/utils"
@@ -35,17 +36,15 @@ type ArchiveServiceImpl struct{}
var _ app.ArchiveService = (*ArchiveServiceImpl)(nil)
func getHTML(data *models.HTMLTemplateData) (string, error) {
func getHTML(data *models.HTMLTemplateData, compress bool) ([]byte, error) {
var buf bytes.Buffer
var htmlOutput string
err := templates.ArchiveTemplate.Execute(&buf, data)
if err == nil {
htmlOutput = buf.String()
if err != nil {
return nil, err
}
return htmlOutput, err
return digest.MinifyHTML(buf.Bytes(), compress)
}
func makeArchiveFile(data *models.HTMLTemplateData) (*os.File, error) {
@@ -62,7 +61,7 @@ func makeArchiveFile(data *models.HTMLTemplateData) (*os.File, error) {
return nil, err
}
func (s *ArchiveServiceImpl) MakeArchiveHTML(data *models.HTMLTemplateData) (*os.File, error) {
func (s *ArchiveServiceImpl) MakeArchiveHTML(data *models.HTMLTemplateData, compress bool) (*os.File, error) {
file, err := makeArchiveFile(data)
if err != nil {
@@ -70,14 +69,14 @@ func (s *ArchiveServiceImpl) MakeArchiveHTML(data *models.HTMLTemplateData) (*os
return nil, err
}
htmlOutput, err := getHTML(data)
htmlOutput, err := getHTML(data, compress)
if err != nil {
log.Printf("Error generating HTML for category %s: %v", data.Category.Title, err)
return file, err
}
_, err = file.WriteString(htmlOutput)
_, err = file.Write(htmlOutput)
if err != nil {
log.Printf("Error writing HTML to file for category '%s': %v", data.Category.Title, err)
+2 -2
View File
@@ -11,7 +11,7 @@ import (
func TestGetHTML(t *testing.T) {
data := testutil.NewMockHTMLTemplateData()
html, err := getHTML(data)
html, err := getHTML(data, true)
if err != nil {
t.Fatalf("getHTML failed: %v", err)
}
@@ -59,7 +59,7 @@ func TestMakeArchiveHTML(t *testing.T) {
data := testutil.NewMockHTMLTemplateData()
archiveService := &ArchiveServiceImpl{}
file, err := archiveService.MakeArchiveHTML(data)
file, err := archiveService.MakeArchiveHTML(data, true)
if err != nil {
t.Fatalf("MakeArchiveHTML failed: %v", err)
}
+7 -6
View File
@@ -1,9 +1,8 @@
package config
import (
"log"
"github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/providers/confmap"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/v2"
)
@@ -19,16 +18,17 @@ type Config struct {
DigestEmailFrom string
DigestSchedule string
DigestHost string
DigestCompress bool
}
var k = koanf.New(".")
func Load(path string) (*Config, error) {
k.Load(confmap.Provider(map[string]interface{}{
"digest.compress": true,
}, "."), nil)
err := k.Load(file.Provider(path), yaml.Parser())
if err != nil {
log.Printf("error loading config file: %v", err)
if err := k.Load(file.Provider(path), yaml.Parser()); err != nil {
return nil, err
}
@@ -43,6 +43,7 @@ func Load(path string) (*Config, error) {
DigestEmailFrom: k.String("digest.email.from"),
DigestSchedule: k.String("digest.schedule"),
DigestHost: k.String("digest.host"),
DigestCompress: k.Bool("digest.compress"),
}
return cfg, nil
+41 -54
View File
@@ -14,7 +14,7 @@ func TestLoad(t *testing.T) {
wantErr bool
}{
{
name: "valid config",
name: "compress not set",
content: `
miniflux:
host: "miniflux.example.com"
@@ -42,20 +42,43 @@ digest:
DigestEmailFrom: "from@example.com",
DigestSchedule: "@daily",
DigestHost: "https://example.com",
DigestCompress: true,
},
wantErr: false,
},
{
name: "missing config file",
content: "",
want: nil,
wantErr: true,
},
{
name: "invalid yaml",
content: "invalid-yaml",
want: nil,
wantErr: true,
name: "compress set to false",
content: `
miniflux:
host: "miniflux.example.com"
api_token: "test-token"
smtp:
host: "smtp.example.com"
port: 587
user: "test-user"
password: "test-password"
digest:
email:
to: "to@example.com"
from: "from@example.com"
schedule: "@daily"
host: "https://example.com"
compress: false
`,
want: &Config{
MinifluxHost: "miniflux.example.com",
MinifluxApiToken: "test-token",
SmtpHost: "smtp.example.com",
SmtpPort: 587,
SmtpUser: "test-user",
SmtpPassword: "test-password",
DigestEmailTo: "to@example.com",
DigestEmailFrom: "from@example.com",
DigestSchedule: "@daily",
DigestHost: "https://example.com",
DigestCompress: false,
},
wantErr: false,
},
}
@@ -70,15 +93,12 @@ digest:
t.Errorf("Failed to remove temp dir: %v", err)
}
}()
configPath := filepath.Join(tmpDir, "config.yaml")
if tt.content != "" {
if err := os.WriteFile(configPath, []byte(tt.content), 0644); err != nil {
t.Fatalf("Failed to write dummy config file: %v", err)
}
} else {
configPath = "non-existent-file.yaml"
if err := os.WriteFile(configPath, []byte(tt.content), 0644); err != nil {
t.Fatalf("Failed to write dummy config file: %v", err)
}
cfg, err := Load(configPath)
if (err != nil) != tt.wantErr {
@@ -86,42 +106,9 @@ digest:
return
}
if tt.want != nil {
if *cfg != *tt.want {
t.Errorf("Load() = %v, want %v", *cfg, *tt.want)
}
if *cfg != *tt.want {
t.Errorf("Load() = %v, want %v", *cfg, *tt.want)
}
})
}
}
func TestLoad_fileNotFound(t *testing.T) {
_, err := Load("non-existent-file.yaml")
if !os.IsNotExist(err) {
t.Errorf("Expected IsNotExist error, got: %v", err)
}
}
func TestLoad_invalidYaml(t *testing.T) {
content := "invalid-yaml"
tmpFile, err := os.CreateTemp("", "config.yaml")
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer func() {
if err := os.Remove(tmpFile.Name()); err != nil {
t.Errorf("Failed to remove temp file: %v", err)
}
}()
if _, err := tmpFile.WriteString(content); err != nil {
t.Fatalf("Failed to write to temp file: %v", err)
}
if err := tmpFile.Close(); err != nil {
t.Fatalf("Failed to close temp file: %v", err)
}
_, err = Load(tmpFile.Name())
if err == nil {
t.Error("Expected error for invalid YAML, got nil")
}
}
}
+28
View File
@@ -0,0 +1,28 @@
package digest
import (
"bytes"
"github.com/tdewolff/minify/v2"
"github.com/tdewolff/minify/v2/css"
"github.com/tdewolff/minify/v2/html"
"github.com/tdewolff/minify/v2/js"
)
func MinifyHTML(input []byte, enabled bool) ([]byte, error) {
if !enabled {
return input, nil
}
m := minify.New()
m.AddFunc("text/css", css.Minify)
m.AddFunc("application/javascript", js.Minify)
m.AddFunc("text/html", html.Minify)
var buf bytes.Buffer
if err := m.Minify("text/html", &buf, bytes.NewReader(input)); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
+1 -1
View File
@@ -11,7 +11,7 @@ func CategoryDigestJob(application *app.App, rawData *app.RawCategoryData, markA
data := application.DigestService.BuildDigestData(rawData.Category, rawData.Entries, rawData.Icons)
if len(*data.Entries) > 0 {
file, err := application.ArchiveService.MakeArchiveHTML(data)
file, err := application.ArchiveService.MakeArchiveHTML(data, application.Config.DigestCompress)
if err != nil {
log.Printf("Error generating File for category %s: %v", data.Category.Title, err)
+4 -4
View File
@@ -46,7 +46,7 @@ func TestCategoryDigestJob(t *testing.T) {
},
EmailService: &testutil.MockEmailService{},
ArchiveService: &testutil.MockArchiveService{
MakeArchiveHTMLFunc: func(data *models.HTMLTemplateData) (*os.File, error) {
MakeArchiveHTMLFunc: func(data *models.HTMLTemplateData, minify bool) (*os.File, error) {
return nil, errors.New("failed to make archive html")
},
},
@@ -73,7 +73,7 @@ func TestCategoryDigestJob(t *testing.T) {
},
},
ArchiveService: &testutil.MockArchiveService{
MakeArchiveHTMLFunc: func(data *models.HTMLTemplateData) (*os.File, error) {
MakeArchiveHTMLFunc: func(data *models.HTMLTemplateData, minify bool) (*os.File, error) {
return os.CreateTemp("", "test-archive-*.html")
},
},
@@ -112,7 +112,7 @@ func TestCategoryDigestJob(t *testing.T) {
},
},
ArchiveService: &testutil.MockArchiveService{
MakeArchiveHTMLFunc: func(data *models.HTMLTemplateData) (*os.File, error) {
MakeArchiveHTMLFunc: func(data *models.HTMLTemplateData, minify bool) (*os.File, error) {
return os.CreateTemp("", "test-archive-*.html")
},
},
@@ -149,7 +149,7 @@ func TestCategoryDigestJob(t *testing.T) {
},
},
ArchiveService: &testutil.MockArchiveService{
MakeArchiveHTMLFunc: func(data *models.HTMLTemplateData) (*os.File, error) {
MakeArchiveHTMLFunc: func(data *models.HTMLTemplateData, minify bool) (*os.File, error) {
return os.CreateTemp("", "test-archive-*.html")
},
},
+3 -3
View File
@@ -68,11 +68,11 @@ func (m *MockMinifluxClient) StreamAllCategoryData() <-chan *app.RawCategoryData
type MockArchiveService struct {
app.ArchiveService
MakeArchiveHTMLFunc func(data *models.HTMLTemplateData) (*os.File, error)
MakeArchiveHTMLFunc func(data *models.HTMLTemplateData, minify bool) (*os.File, error)
}
func (m *MockArchiveService) MakeArchiveHTML(data *models.HTMLTemplateData) (*os.File, error) {
return m.MakeArchiveHTMLFunc(data)
func (m *MockArchiveService) MakeArchiveHTML(data *models.HTMLTemplateData, minify bool) (*os.File, error) {
return m.MakeArchiveHTMLFunc(data, minify)
}
func (m *MockArchiveService) CleanArchive(maxAge time.Duration) {}
+17 -1
View File
@@ -1,14 +1,20 @@
package main
import (
"bytes"
"flag"
"log"
"os"
"miniflux-digest/internal/digest"
"miniflux-digest/internal/templates"
"miniflux-digest/internal/testutil"
)
func main() {
minify := flag.Bool("minify", true, "Minify the HTML output")
flag.Parse()
data := testutil.NewMockHTMLTemplateData()
file, err := os.Create("web/preview.html")
@@ -21,9 +27,19 @@ func main() {
}
}()
if err := templates.ArchiveTemplate.Execute(file, data); err != nil {
var buf bytes.Buffer
if err := templates.ArchiveTemplate.Execute(&buf, data); err != nil {
log.Fatalf("Failed to execute template: %v", err)
}
html, err := digest.MinifyHTML(buf.Bytes(), *minify)
if err != nil {
log.Fatalf("Failed to minify HTML: %v", err)
}
if _, err := file.Write(html); err != nil {
log.Fatalf("Failed to write to preview file: %v", err)
}
log.Println("Successfully generated web/preview.html")
}