detached 21cfe2a

refactor: Streamline asset serving and localization handling

- Refactored static file serving to utilize Echo's built-in middleware for improved clarity and maintainability.
- Removed deprecated template loading logic and replaced it with a direct approach using embedded localization files.
- Simplified the localization retrieval process by leveraging a pre-parsed strings map, enhancing performance and reducing complexity.
- Updated various template components to reference assets directly from the new serving path.
- Enhanced logging for localization errors to provide better feedback during runtime.
commit
21cfe2ad66712e0b77e3cbca622dc7e70fc47f9f
author
bamen · · 2025-03-30 15:47 UTC
parent
d255313
signature
unsigned
16 files · +180 -174
M internal/httpserve/handlers/container_management.go +2 -12
@@ -9,7 +9,6 @@ import (
99 "time"
1010
1111 "github.com/bnema/gordon/internal/server"
12- "github.com/bnema/gordon/internal/templating/load"
1312 components "github.com/bnema/gordon/internal/templating/models/templ/components"
1413 "github.com/bnema/gordon/internal/templating/render"
1514 "github.com/bnema/gordon/pkg/docker"
@@ -100,18 +99,9 @@ type ContainerDisplay struct {
10099
101100 // ActionSuccess returns the success HTML fragment
102101 func ActionSuccess(a *server.App) string {
103- // Create a data structure to pass to the template
104- data := map[string]interface{}{
105- "Message": "Container deleted successfully!",
106- }
107-
108- successFragment, err := load.Fragment(a, "success", data)
109- if err != nil {
110- fmt.Println(err)
111- return ""
112- }
113102
114- return successFragment
103+ // return just a string
104+ return "success"
115105 }
116106
117107 // ImageManagerComponent handles the /image-manager route (HTMX route)
@@ -9,7 +9,6 @@ import (
9 "time"9 "time"
10 10
11 "github.com/bnema/gordon/internal/server"11 "github.com/bnema/gordon/internal/server"
12- "github.com/bnema/gordon/internal/templating/load"
13 components "github.com/bnema/gordon/internal/templating/models/templ/components"12 components "github.com/bnema/gordon/internal/templating/models/templ/components"
14 "github.com/bnema/gordon/internal/templating/render"13 "github.com/bnema/gordon/internal/templating/render"
15 "github.com/bnema/gordon/pkg/docker"14 "github.com/bnema/gordon/pkg/docker"
@@ -100,18 +99,9 @@ type ContainerDisplay struct {
100 99
101 // ActionSuccess returns the success HTML fragment100 // ActionSuccess returns the success HTML fragment
102 func ActionSuccess(a *server.App) string {101 func ActionSuccess(a *server.App) string {
103- // Create a data structure to pass to the template
104- data := map[string]interface{}{
105- "Message": "Container deleted successfully!",
106- }
107-
108- successFragment, err := load.Fragment(a, "success", data)
109- if err != nil {
110- fmt.Println(err)
111- return ""
112- }
113 102
114- return successFragment103+ // return just a string
104+ return "success"
115 }105 }
116 106
117 // ImageManagerComponent handles the /image-manager route (HTMX route)107 // ImageManagerComponent handles the /image-manager route (HTMX route)
M internal/httpserve/middleware/secure.go +24 -1
@@ -1,6 +1,7 @@
11 package middleware
22
33 import (
4+ "fmt"
45 "log"
56 "net/http"
67 "os"
@@ -16,13 +17,35 @@ import (
1617 func SecureRoutes(a *server.App) echo.MiddlewareFunc {
1718 proxyURL := a.Config.Build.ProxyURL
1819 urlCheckVersion := a.Config.Build.ProxyURL + "/version"
20+
21+ // Base directives
22+ defaultSrc := "'self'"
23+ styleSrc := "'self' 'unsafe-inline'"
24+ fontSrc := "'self' data:"
25+ imgSrc := "'self' data:"
26+ connectSrc := fmt.Sprintf("'self' %s %s", proxyURL, urlCheckVersion)
27+
28+ // Start building script-src
29+ scriptSrc := "'self' 'unsafe-inline' 'unsafe-eval'"
30+
31+ // Conditionally add Cloudflare Insights if SkipCertificates is true
32+ if a.Config.ReverseProxy.SkipCertificates {
33+ scriptSrc += " https://static.cloudflareinsights.com"
34+ }
35+
36+ // Construct the final CSP string
37+ csp := fmt.Sprintf(
38+ "default-src %s; style-src %s; font-src %s; img-src %s; script-src %s; connect-src %s",
39+ defaultSrc, styleSrc, fontSrc, imgSrc, scriptSrc, connectSrc,
40+ )
41+
1942 return middleware.SecureWithConfig(middleware.SecureConfig{
2043 XSSProtection: "1; mode=block",
2144 ContentTypeNosniff: "nosniff",
2245 XFrameOptions: "SAMEORIGIN",
2346 HSTSMaxAge: 3600,
2447 HSTSExcludeSubdomains: false,
25- ContentSecurityPolicy: "default-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self' " + proxyURL + " " + urlCheckVersion,
48+ ContentSecurityPolicy: csp, // Use the dynamically constructed CSP
2649 })
2750 }
2851
@@ -1,6 +1,7 @@
1 package middleware1 package middleware
2 2
3 import (3 import (
4+ "fmt"
4 "log"5 "log"
5 "net/http"6 "net/http"
6 "os"7 "os"
@@ -16,13 +17,35 @@ import (
16 func SecureRoutes(a *server.App) echo.MiddlewareFunc {17 func SecureRoutes(a *server.App) echo.MiddlewareFunc {
17 proxyURL := a.Config.Build.ProxyURL18 proxyURL := a.Config.Build.ProxyURL
18 urlCheckVersion := a.Config.Build.ProxyURL + "/version"19 urlCheckVersion := a.Config.Build.ProxyURL + "/version"
20+
21+ // Base directives
22+ defaultSrc := "'self'"
23+ styleSrc := "'self' 'unsafe-inline'"
24+ fontSrc := "'self' data:"
25+ imgSrc := "'self' data:"
26+ connectSrc := fmt.Sprintf("'self' %s %s", proxyURL, urlCheckVersion)
27+
28+ // Start building script-src
29+ scriptSrc := "'self' 'unsafe-inline' 'unsafe-eval'"
30+
31+ // Conditionally add Cloudflare Insights if SkipCertificates is true
32+ if a.Config.ReverseProxy.SkipCertificates {
33+ scriptSrc += " https://static.cloudflareinsights.com"
34+ }
35+
36+ // Construct the final CSP string
37+ csp := fmt.Sprintf(
38+ "default-src %s; style-src %s; font-src %s; img-src %s; script-src %s; connect-src %s",
39+ defaultSrc, styleSrc, fontSrc, imgSrc, scriptSrc, connectSrc,
40+ )
41+
19 return middleware.SecureWithConfig(middleware.SecureConfig{42 return middleware.SecureWithConfig(middleware.SecureConfig{
20 XSSProtection: "1; mode=block",43 XSSProtection: "1; mode=block",
21 ContentTypeNosniff: "nosniff",44 ContentTypeNosniff: "nosniff",
22 XFrameOptions: "SAMEORIGIN",45 XFrameOptions: "SAMEORIGIN",
23 HSTSMaxAge: 3600,46 HSTSMaxAge: 3600,
24 HSTSExcludeSubdomains: false,47 HSTSExcludeSubdomains: false,
25- ContentSecurityPolicy: "default-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self' " + proxyURL + " " + urlCheckVersion,48+ ContentSecurityPolicy: csp, // Use the dynamically constructed CSP
26 })49 })
27 }50 }
28 51
M internal/httpserve/router.go +8 -9
@@ -56,25 +56,24 @@ func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo {
5656
5757 log.Debug("Binding static routes under /assets/*")
5858
59- // Setup static file server
60- httpFS := http.FS(a.PublicFS)
61- noDirFS := handlers.NewNoDirFileSys(httpFS)
62- fileServer := http.FileServer(noDirFS)
63-
6459 // Middleware to set cache headers for static files
6560 staticCacheMiddleware := func(next echo.HandlerFunc) echo.HandlerFunc {
6661 return func(c echo.Context) error {
62+ // Determine caching based on environment
6763 if os.Getenv("RUN_ENV") != "dev" {
68- c.Response().Header().Set("Cache-Control", "public, max-age=86400") // 1 day
64+ c.Response().Header().Set("Cache-Control", "public, max-age=86400") // Cache for 1 day in production
6965 } else {
70- c.Response().Header().Set("Cache-Control", "no-cache")
66+ c.Response().Header().Set("Cache-Control", "no-cache") // No caching in development
7167 }
7268 return next(c)
7369 }
7470 }
7571
76- // Serve static files from /assets path
77- e.GET("/assets/*", echo.WrapHandler(http.StripPrefix("/assets/", fileServer)), staticCacheMiddleware, echomid.Gzip())
72+ // Use Echo's static file middleware to serve assets directly from the filesystem.
73+ // The path is relative to the project root.
74+ staticGroup := e.Group("/assets")
75+ staticGroup.Use(staticCacheMiddleware)
76+ staticGroup.Static("/", "internal/webui/public/assets")
7877
7978 // Only bind HTMX endpoints if Admin WebUI is enabled
8079 if a.Config.Admin.IsAdminWebUIEnabled() {
@@ -56,25 +56,24 @@ func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo {
56 56
57 log.Debug("Binding static routes under /assets/*")57 log.Debug("Binding static routes under /assets/*")
58 58
59- // Setup static file server
60- httpFS := http.FS(a.PublicFS)
61- noDirFS := handlers.NewNoDirFileSys(httpFS)
62- fileServer := http.FileServer(noDirFS)
63-
64 // Middleware to set cache headers for static files59 // Middleware to set cache headers for static files
65 staticCacheMiddleware := func(next echo.HandlerFunc) echo.HandlerFunc {60 staticCacheMiddleware := func(next echo.HandlerFunc) echo.HandlerFunc {
66 return func(c echo.Context) error {61 return func(c echo.Context) error {
62+ // Determine caching based on environment
67 if os.Getenv("RUN_ENV") != "dev" {63 if os.Getenv("RUN_ENV") != "dev" {
68- c.Response().Header().Set("Cache-Control", "public, max-age=86400") // 1 day64+ c.Response().Header().Set("Cache-Control", "public, max-age=86400") // Cache for 1 day in production
69 } else {65 } else {
70- c.Response().Header().Set("Cache-Control", "no-cache")66+ c.Response().Header().Set("Cache-Control", "no-cache") // No caching in development
71 }67 }
72 return next(c)68 return next(c)
73 }69 }
74 }70 }
75 71
76- // Serve static files from /assets path72+ // Use Echo's static file middleware to serve assets directly from the filesystem.
77- e.GET("/assets/*", echo.WrapHandler(http.StripPrefix("/assets/", fileServer)), staticCacheMiddleware, echomid.Gzip())73+ // The path is relative to the project root.
74+ staticGroup := e.Group("/assets")
75+ staticGroup.Use(staticCacheMiddleware)
76+ staticGroup.Static("/", "internal/webui/public/assets")
78 77
79 // Only bind HTMX endpoints if Admin WebUI is enabled78 // Only bind HTMX endpoints if Admin WebUI is enabled
80 if a.Config.Admin.IsAdminWebUIEnabled() {79 if a.Config.Admin.IsAdminWebUIEnabled() {
M internal/server/app_server.go +4 -4
@@ -4,7 +4,6 @@ import (
44 "context"
55 "database/sql"
66 "fmt"
7- "io/fs"
87 "time"
98
109 "github.com/bnema/gordon/internal/common"
@@ -14,9 +13,10 @@ import (
1413 )
1514
1615 type App struct {
17- TemplateFS fs.FS
18- PublicFS fs.FS
19- LocYML []byte // strings.yml contains the strings for the current language
16+ // TemplateFS fs.FS // No longer needed
17+ // PublicFS fs.FS // Removed as we now serve assets directly
18+ // LocYML []byte // Replaced by parsed Strings map
19+ Strings map[string]interface{} // Parsed localization strings
2020 DBDir string
2121 DBFilename string
2222 DBPath string
@@ -4,7 +4,6 @@ import (
4 "context"4 "context"
5 "database/sql"5 "database/sql"
6 "fmt"6 "fmt"
7- "io/fs"
8 "time"7 "time"
9 8
10 "github.com/bnema/gordon/internal/common"9 "github.com/bnema/gordon/internal/common"
@@ -14,9 +13,10 @@ import (
14 )13 )
15 14
16 type App struct {15 type App struct {
17- TemplateFS fs.FS16+ // TemplateFS fs.FS // No longer needed
18- PublicFS fs.FS17+ // PublicFS fs.FS // Removed as we now serve assets directly
19- LocYML []byte // strings.yml contains the strings for the current language18+ // LocYML []byte // Replaced by parsed Strings map
19+ Strings map[string]interface{} // Parsed localization strings
20 DBDir string20 DBDir string
21 DBFilename string21 DBFilename string
22 DBPath string22 DBPath string
M internal/server/new_server.go +9 -14
@@ -1,7 +1,6 @@
11 package server
22
33 import (
4- "io"
54 "os"
65 "path/filepath"
76 "time"
@@ -9,8 +8,8 @@ import (
98 "github.com/bnema/gordon/internal/common"
109 "github.com/bnema/gordon/internal/proxy"
1110 "github.com/bnema/gordon/internal/templating"
12- "github.com/bnema/gordon/internal/webui"
1311 "github.com/bnema/gordon/pkg/logger"
12+ "gopkg.in/yaml.v3"
1413 )
1514
1615 func NewServerApp(buildConfig *common.BuildConfig) (*App, error) {
@@ -40,28 +39,24 @@ func NewServerApp(buildConfig *common.BuildConfig) (*App, error) {
4039 }
4140 }
4241
43- // Open the strings.yml file containing the strings for the current language
44- file, err := templating.TemplateFS.Open("txt/locstrings.yml")
42+ // Read the embedded localization file
43+ locBytes, err := templating.LocFS.ReadFile("models/txt/locstrings.yml")
4544 if err != nil {
46- logger.Fatalf("Failed to open strings.yml: %v", err)
45+ logger.Fatalf("Failed to read embedded locstrings.yml: %v", err)
4746 }
4847
49- // Read the file content into a byte slice
50- bytes, err := io.ReadAll(file)
51- if err != nil {
52- logger.Fatalf("Failed to read strings.yml: %v", err)
48+ // Parse the YAML content
49+ var stringsMap map[string]interface{}
50+ if err := yaml.Unmarshal(locBytes, &stringsMap); err != nil {
51+ logger.Fatalf("Failed to parse locstrings.yml: %v", err)
5352 }
5453
55- file.Close()
56-
5754 // Initialize DB
5855 DBDir := config.General.StorageDir + "/db"
5956
6057 // Initialize App
6158 a := &App{
62- TemplateFS: templating.TemplateFS,
63- PublicFS: webui.PublicFS,
64- LocYML: bytes,
59+ Strings: stringsMap,
6560 DBDir: DBDir,
6661 DBFilename: DBFilename,
6762 Config: *config,
@@ -1,7 +1,6 @@
1 package server1 package server
2 2
3 import (3 import (
4- "io"
5 "os"4 "os"
6 "path/filepath"5 "path/filepath"
7 "time"6 "time"
@@ -9,8 +8,8 @@ import (
9 "github.com/bnema/gordon/internal/common"8 "github.com/bnema/gordon/internal/common"
10 "github.com/bnema/gordon/internal/proxy"9 "github.com/bnema/gordon/internal/proxy"
11 "github.com/bnema/gordon/internal/templating"10 "github.com/bnema/gordon/internal/templating"
12- "github.com/bnema/gordon/internal/webui"
13 "github.com/bnema/gordon/pkg/logger"11 "github.com/bnema/gordon/pkg/logger"
12+ "gopkg.in/yaml.v3"
14 )13 )
15 14
16 func NewServerApp(buildConfig *common.BuildConfig) (*App, error) {15 func NewServerApp(buildConfig *common.BuildConfig) (*App, error) {
@@ -40,28 +39,24 @@ func NewServerApp(buildConfig *common.BuildConfig) (*App, error) {
40 }39 }
41 }40 }
42 41
43- // Open the strings.yml file containing the strings for the current language42+ // Read the embedded localization file
44- file, err := templating.TemplateFS.Open("txt/locstrings.yml")43+ locBytes, err := templating.LocFS.ReadFile("models/txt/locstrings.yml")
45 if err != nil {44 if err != nil {
46- logger.Fatalf("Failed to open strings.yml: %v", err)45+ logger.Fatalf("Failed to read embedded locstrings.yml: %v", err)
47 }46 }
48 47
49- // Read the file content into a byte slice48+ // Parse the YAML content
50- bytes, err := io.ReadAll(file)49+ var stringsMap map[string]interface{}
51- if err != nil {50+ if err := yaml.Unmarshal(locBytes, &stringsMap); err != nil {
52- logger.Fatalf("Failed to read strings.yml: %v", err)51+ logger.Fatalf("Failed to parse locstrings.yml: %v", err)
53 }52 }
54 53
55- file.Close()
56-
57 // Initialize DB54 // Initialize DB
58 DBDir := config.General.StorageDir + "/db"55 DBDir := config.General.StorageDir + "/db"
59 56
60 // Initialize App57 // Initialize App
61 a := &App{58 a := &App{
62- TemplateFS: templating.TemplateFS,59+ Strings: stringsMap,
63- PublicFS: webui.PublicFS,
64- LocYML: bytes,
65 DBDir: DBDir,60 DBDir: DBDir,
66 DBFilename: DBFilename,61 DBFilename: DBFilename,
67 Config: *config,62 Config: *config,
M internal/templating/embed.go +6 -6
@@ -2,12 +2,12 @@ package templating
22
33 import (
44 "embed"
5-
6- "github.com/labstack/echo/v4"
75 )
86
9-// Embedding templates directories
7+// Embedding localization file
8+
9+//go:embed models/txt/locstrings.yml
10+var locFS embed.FS
1011
11-//go:embed models/*
12-var template embed.FS
13-var TemplateFS = echo.MustSubFS(template, "models")
12+// LocFS provides access to the embedded localization file.
13+var LocFS = locFS
@@ -2,12 +2,12 @@ package templating
2 2
3 import (3 import (
4 "embed"4 "embed"
5-
6- "github.com/labstack/echo/v4"
7 )5 )
8 6
9-// Embedding templates directories7+// Embedding localization file
8+
9+//go:embed models/txt/locstrings.yml
10+var locFS embed.FS
10 11
11-//go:embed models/*12+// LocFS provides access to the embedded localization file.
12-var template embed.FS13+var LocFS = locFS
13-var TemplateFS = echo.MustSubFS(template, "models")
D internal/templating/load/fragment.go +0 -48
@@ -1,48 +0,0 @@
1-package load
2-
3-import (
4- "bytes"
5- "fmt"
6- "html/template"
7- "io"
8-
9- "github.com/bnema/gordon/internal/server"
10-)
11-
12-// Fragment loads and renders a specific HTML fragment from the app's TemplateFS
13-func Fragment(a *server.App, fragmentName string, data ...interface{}) (string, error) {
14- filePath := fmt.Sprintf("html/fragments/%s.gohtml", fragmentName)
15-
16- // Open the file
17- file, err := a.TemplateFS.Open(filePath)
18- if err != nil {
19- return "", err
20- }
21- defer file.Close()
22-
23- // Read the file's content
24- content, err := io.ReadAll(file)
25- if err != nil {
26- return "", err
27- }
28-
29- // If no data is provided, just return the raw content
30- if len(data) == 0 {
31- return string(content), nil
32- }
33-
34- // Parse the template
35- tmpl, err := template.New(fragmentName).Parse(string(content))
36- if err != nil {
37- return "", err
38- }
39-
40- // Execute the template with the provided data
41- var buf bytes.Buffer
42- err = tmpl.Execute(&buf, data[0])
43- if err != nil {
44- return "", err
45- }
46-
47- return buf.String(), nil
48-}
@@ -1,48 +0,0 @@
1-package load
2-
3-import (
4- "bytes"
5- "fmt"
6- "html/template"
7- "io"
8-
9- "github.com/bnema/gordon/internal/server"
10-)
11-
12-// Fragment loads and renders a specific HTML fragment from the app's TemplateFS
13-func Fragment(a *server.App, fragmentName string, data ...interface{}) (string, error) {
14- filePath := fmt.Sprintf("html/fragments/%s.gohtml", fragmentName)
15-
16- // Open the file
17- file, err := a.TemplateFS.Open(filePath)
18- if err != nil {
19- return "", err
20- }
21- defer file.Close()
22-
23- // Read the file's content
24- content, err := io.ReadAll(file)
25- if err != nil {
26- return "", err
27- }
28-
29- // If no data is provided, just return the raw content
30- if len(data) == 0 {
31- return string(content), nil
32- }
33-
34- // Parse the template
35- tmpl, err := template.New(fragmentName).Parse(string(content))
36- if err != nil {
37- return "", err
38- }
39-
40- // Execute the template with the provided data
41- var buf bytes.Buffer
42- err = tmpl.Execute(&buf, data[0])
43- if err != nil {
44- return "", err
45- }
46-
47- return buf.String(), nil
48-}
M internal/templating/models/templ/components/header.templ +7 -7
@@ -9,13 +9,13 @@ templ Header(title string) {
99 <title>{ title }</title>
1010 <link rel="stylesheet" href="/assets/css/tailwind.css" />
1111
12- <script src="/libs/jquery.min.js"></script>
13- <script src="/libs/htmx.min.js"></script>
14- <script src="/libs/htmx-response-targets.js"></script>
15- <script src="/libs/htmx-debug.js"></script>
16- <script src="/libs/htmx-sse.js"></script>
17- <script src="/libs/daisyui.js" type="module"></script>
18- <script defer src="/libs/hyperscript.min.js"></script>
12+ <script src="/assets/js/jquery.min.js"></script>
13+ <script src="/assets/js/htmx.min.js"></script>
14+ <script src="/assets/js/htmx-response-targets.js"></script>
15+ <script src="/assets/js/htmx-debug.js"></script>
16+ <script src="/assets/js/htmx-sse.js"></script>
17+ <script src="/assets/js/daisyui.js" type="module"></script>
18+ <script defer src="/assets/js/hyperscript.min.js"></script>
1919 <script defer src="/assets/js/custom.js"></script>
2020
2121 <!-- daisyUI theme configuration -->
@@ -9,13 +9,13 @@ templ Header(title string) {
9 <title>{ title }</title>9 <title>{ title }</title>
10 <link rel="stylesheet" href="/assets/css/tailwind.css" />10 <link rel="stylesheet" href="/assets/css/tailwind.css" />
11 11
12- <script src="/libs/jquery.min.js"></script>12+ <script src="/assets/js/jquery.min.js"></script>
13- <script src="/libs/htmx.min.js"></script>13+ <script src="/assets/js/htmx.min.js"></script>
14- <script src="/libs/htmx-response-targets.js"></script>14+ <script src="/assets/js/htmx-response-targets.js"></script>
15- <script src="/libs/htmx-debug.js"></script>15+ <script src="/assets/js/htmx-debug.js"></script>
16- <script src="/libs/htmx-sse.js"></script>16+ <script src="/assets/js/htmx-sse.js"></script>
17- <script src="/libs/daisyui.js" type="module"></script>17+ <script src="/assets/js/daisyui.js" type="module"></script>
18- <script defer src="/libs/hyperscript.min.js"></script>18+ <script defer src="/assets/js/hyperscript.min.js"></script>
19 <script defer src="/assets/js/custom.js"></script>19 <script defer src="/assets/js/custom.js"></script>
20 20
21 <!-- daisyUI theme configuration -->21 <!-- daisyUI theme configuration -->
M internal/templating/models/templ/components/header_templ.go +1 -1 generated file: collapsed
M internal/templating/models/templ/components/imagelist_templ.go +11 -11 generated file: collapsed
M internal/templating/models/templ/pages/login/index.templ +4 -4
@@ -13,13 +13,13 @@ templ LoginPage(title string, buildVersion string, errorMsg string) {
1313 <!-- Custom CSS + Tailwind -->
1414 <link rel="stylesheet" href="/assets/css/tailwind.css" />
1515 <!-- HTMX min js -->
16- <script src="/libs/htmx.min.js"></script>
16+ <script src="/assets/js/htmx.min.js"></script>
1717 <!-- HTMX SSE -->
18- <script src="/libs/htmx-sse.js"></script>
18+ <script src="/assets/js/htmx-sse.js"></script>
1919 <!-- DaisyUI -->
20- <script src="/libs/daisyui.js" type="module"></script>
20+ <script src="/assets/js/daisyui.js" type="module"></script>
2121 <!-- jQuery -->
22- <script src="/libs/jquery.min.js"></script>
22+ <script src="/assets/js/jquery.min.js"></script>
2323 <!-- Custom JS from assets/js -->
2424 <script src="/assets/js/custom.js" defer></script>
2525
@@ -13,13 +13,13 @@ templ LoginPage(title string, buildVersion string, errorMsg string) {
13 <!-- Custom CSS + Tailwind -->13 <!-- Custom CSS + Tailwind -->
14 <link rel="stylesheet" href="/assets/css/tailwind.css" />14 <link rel="stylesheet" href="/assets/css/tailwind.css" />
15 <!-- HTMX min js -->15 <!-- HTMX min js -->
16- <script src="/libs/htmx.min.js"></script>16+ <script src="/assets/js/htmx.min.js"></script>
17 <!-- HTMX SSE -->17 <!-- HTMX SSE -->
18- <script src="/libs/htmx-sse.js"></script>18+ <script src="/assets/js/htmx-sse.js"></script>
19 <!-- DaisyUI -->19 <!-- DaisyUI -->
20- <script src="/libs/daisyui.js" type="module"></script>20+ <script src="/assets/js/daisyui.js" type="module"></script>
21 <!-- jQuery -->21 <!-- jQuery -->
22- <script src="/libs/jquery.min.js"></script>22+ <script src="/assets/js/jquery.min.js"></script>
23 <!-- Custom JS from assets/js -->23 <!-- Custom JS from assets/js -->
24 <script src="/assets/js/custom.js" defer></script>24 <script src="/assets/js/custom.js" defer></script>
25 25
M internal/templating/models/templ/pages/login/index_templ.go +1 -1 generated file: collapsed
A internal/templating/render/html.go +77 -0
@@ -0,0 +1,77 @@
1+package render
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "html/template"
7+ "io/fs"
8+ "path"
9+
10+ "github.com/bnema/gordon/internal/server"
11+)
12+
13+type Renderer struct {
14+ Template *template.Template
15+ ParseError error
16+ BuildVersion string
17+ LatestVersion string
18+}
19+
20+// Render function renders the template with the given data
21+func (r *Renderer) Render(data interface{}, a *server.App) (string, error) {
22+ if r.ParseError != nil {
23+ return "", fmt.Errorf("failed to parse template: %w", r.ParseError)
24+
25+ }
26+ if r.Template == nil {
27+ return "", fmt.Errorf("template is nil")
28+ }
29+ // Type assert data to map[string]interface{} to add BuildVersion
30+ dataMap, ok := data.(map[string]interface{})
31+ if !ok {
32+ return "", fmt.Errorf("data is not a map[string]interface{}")
33+ }
34+
35+ // Automatically add BuildVersion to the data map
36+ dataMap["BuildVersion"] = r.BuildVersion
37+ buf := new(bytes.Buffer)
38+ err := r.Template.Execute(buf, dataMap)
39+ if err != nil {
40+ return "", fmt.Errorf("failed to execute template: %w", err)
41+ }
42+
43+ return buf.String(), nil
44+}
45+
46+// GetHTMLRenderer function returns a new Renderer instance
47+func GetHTMLRenderer(mainPath string, filename string, fs fs.FS, a *server.App, fragmentsPath ...string) (*Renderer, error) {
48+ // Full path to the main template
49+ fullPath := path.Join(mainPath, filename)
50+ // Check if the file exists in the provided fs.FS using fs.Open
51+ file, err := fs.Open(fullPath)
52+ if err != nil {
53+ return nil, fmt.Errorf("failed to open %s: %w", filename, err)
54+ }
55+ file.Close()
56+
57+ // Parse the main template
58+ tmpl, err := template.New(filename).ParseFS(fs, fullPath)
59+ if err != nil {
60+ return nil, fmt.Errorf("failed to parse %s: %w", filename, err)
61+ }
62+
63+ // If a fragments path is provided, parse the fragment templates
64+ if len(fragmentsPath) > 0 && fragmentsPath[0] != "" {
65+ fragmentsGlob := path.Join(fragmentsPath[0], "*.gohtml")
66+ _, err = tmpl.ParseFS(fs, fragmentsGlob)
67+ if err != nil {
68+ return nil, fmt.Errorf("failed to parse fragments: %w", err)
69+ }
70+ }
71+
72+ // Return the Renderer instance
73+ return &Renderer{
74+ Template: tmpl,
75+ BuildVersion: a.Config.Build.BuildVersion,
76+ }, nil
77+}
@@ -0,0 +1,77 @@
1+package render
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "html/template"
7+ "io/fs"
8+ "path"
9+
10+ "github.com/bnema/gordon/internal/server"
11+)
12+
13+type Renderer struct {
14+ Template *template.Template
15+ ParseError error
16+ BuildVersion string
17+ LatestVersion string
18+}
19+
20+// Render function renders the template with the given data
21+func (r *Renderer) Render(data interface{}, a *server.App) (string, error) {
22+ if r.ParseError != nil {
23+ return "", fmt.Errorf("failed to parse template: %w", r.ParseError)
24+
25+ }
26+ if r.Template == nil {
27+ return "", fmt.Errorf("template is nil")
28+ }
29+ // Type assert data to map[string]interface{} to add BuildVersion
30+ dataMap, ok := data.(map[string]interface{})
31+ if !ok {
32+ return "", fmt.Errorf("data is not a map[string]interface{}")
33+ }
34+
35+ // Automatically add BuildVersion to the data map
36+ dataMap["BuildVersion"] = r.BuildVersion
37+ buf := new(bytes.Buffer)
38+ err := r.Template.Execute(buf, dataMap)
39+ if err != nil {
40+ return "", fmt.Errorf("failed to execute template: %w", err)
41+ }
42+
43+ return buf.String(), nil
44+}
45+
46+// GetHTMLRenderer function returns a new Renderer instance
47+func GetHTMLRenderer(mainPath string, filename string, fs fs.FS, a *server.App, fragmentsPath ...string) (*Renderer, error) {
48+ // Full path to the main template
49+ fullPath := path.Join(mainPath, filename)
50+ // Check if the file exists in the provided fs.FS using fs.Open
51+ file, err := fs.Open(fullPath)
52+ if err != nil {
53+ return nil, fmt.Errorf("failed to open %s: %w", filename, err)
54+ }
55+ file.Close()
56+
57+ // Parse the main template
58+ tmpl, err := template.New(filename).ParseFS(fs, fullPath)
59+ if err != nil {
60+ return nil, fmt.Errorf("failed to parse %s: %w", filename, err)
61+ }
62+
63+ // If a fragments path is provided, parse the fragment templates
64+ if len(fragmentsPath) > 0 && fragmentsPath[0] != "" {
65+ fragmentsGlob := path.Join(fragmentsPath[0], "*.gohtml")
66+ _, err = tmpl.ParseFS(fs, fragmentsGlob)
67+ if err != nil {
68+ return nil, fmt.Errorf("failed to parse fragments: %w", err)
69+ }
70+ }
71+
72+ // Return the Renderer instance
73+ return &Renderer{
74+ Template: tmpl,
75+ BuildVersion: a.Config.Build.BuildVersion,
76+ }, nil
77+}
M internal/templating/render/localization.go +20 -42
@@ -4,55 +4,33 @@ import (
44 "fmt"
55
66 "github.com/bnema/gordon/internal/server"
7- "gopkg.in/yaml.v3"
87 )
98
10-type Localizations map[string]map[string]map[string]interface{}
11-
12-// GetLocalization function returns the localization for the given language
9+// GetLocalization function returns the localization map for the given language
1310 func GetLocalization(lang string, a *server.App) (map[string]interface{}, error) {
14- // 1. Check if the language is supported
15- supportedLanguages := []string{"en", "fr"} // Add more languages here
16- if !contains(supportedLanguages, lang) {
17- return nil, fmt.Errorf("language %s is not supported", lang)
18- }
19-
20- // 2. Get the localizations from the strings.yml file
21- localizations, err := getLocalizations(a)
22- if err != nil {
23- return nil, fmt.Errorf("failed to get localizations: %w", err)
24- }
25-
26- // 3. Return the localizations for the given language
27- if localization, ok := localizations[lang]; ok {
28- result := make(map[string]interface{})
29- for k, v := range localization {
30- result[k] = v
11+ // 1. Check if the language exists in the pre-parsed map
12+ langData, langExists := a.Strings[lang]
13+ if !langExists {
14+ // Fallback to default language (e.g., "en") if primary is not found
15+ defaultLang := "en"
16+ langData, langExists = a.Strings[defaultLang]
17+ if !langExists {
18+ return nil, fmt.Errorf("default language '%s' not found in localization data", defaultLang)
3119 }
32- return result, nil
20+ // Use default language if primary was not found
21+ lang = defaultLang
22+ fmt.Printf("Warning: Language '%s' not found, falling back to '%s'\n", lang, defaultLang)
3323 }
3424
35- return nil, fmt.Errorf("language %s is not available", lang)
36-}
37-
38-// getLocalizations function returns the localizations from the strings.yml file
39-func getLocalizations(a *server.App) (Localizations, error) {
40- var loc Localizations
41-
42- err := yaml.Unmarshal(a.LocYML, &loc)
43- if err != nil {
44- return loc, fmt.Errorf("failed to unmarshal strings.yml: %w", err)
25+ // 2. Assert that the language data is a map
26+ localizationMap, ok := langData.(map[string]interface{})
27+ if !ok {
28+ return nil, fmt.Errorf("localization data for language '%s' is not in the expected map format", lang)
4529 }
4630
47- return loc, nil
31+ // 3. Return the localization map for the requested (or fallback) language
32+ return localizationMap, nil
4833 }
4934
50-// Helper function to check if a slice contains a string
51-func contains(slice []string, str string) bool {
52- for _, v := range slice {
53- if v == str {
54- return true
55- }
56- }
57- return false
58-}
35+// Helper function to check if a slice contains a string - No longer needed as we check map keys
36+// func contains(slice []string, str string) bool { ... }
@@ -4,55 +4,33 @@ import (
4 "fmt"4 "fmt"
5 5
6 "github.com/bnema/gordon/internal/server"6 "github.com/bnema/gordon/internal/server"
7- "gopkg.in/yaml.v3"
8 )7 )
9 8
10-type Localizations map[string]map[string]map[string]interface{}9+// GetLocalization function returns the localization map for the given language
11-
12-// GetLocalization function returns the localization for the given language
13 func GetLocalization(lang string, a *server.App) (map[string]interface{}, error) {10 func GetLocalization(lang string, a *server.App) (map[string]interface{}, error) {
14- // 1. Check if the language is supported11+ // 1. Check if the language exists in the pre-parsed map
15- supportedLanguages := []string{"en", "fr"} // Add more languages here12+ langData, langExists := a.Strings[lang]
16- if !contains(supportedLanguages, lang) {13+ if !langExists {
17- return nil, fmt.Errorf("language %s is not supported", lang)14+ // Fallback to default language (e.g., "en") if primary is not found
18- }15+ defaultLang := "en"
19-16+ langData, langExists = a.Strings[defaultLang]
20- // 2. Get the localizations from the strings.yml file17+ if !langExists {
21- localizations, err := getLocalizations(a)18+ return nil, fmt.Errorf("default language '%s' not found in localization data", defaultLang)
22- if err != nil {
23- return nil, fmt.Errorf("failed to get localizations: %w", err)
24- }
25-
26- // 3. Return the localizations for the given language
27- if localization, ok := localizations[lang]; ok {
28- result := make(map[string]interface{})
29- for k, v := range localization {
30- result[k] = v
31 }19 }
32- return result, nil20+ // Use default language if primary was not found
21+ lang = defaultLang
22+ fmt.Printf("Warning: Language '%s' not found, falling back to '%s'\n", lang, defaultLang)
33 }23 }
34 24
35- return nil, fmt.Errorf("language %s is not available", lang)25+ // 2. Assert that the language data is a map
36-}26+ localizationMap, ok := langData.(map[string]interface{})
37-27+ if !ok {
38-// getLocalizations function returns the localizations from the strings.yml file28+ return nil, fmt.Errorf("localization data for language '%s' is not in the expected map format", lang)
39-func getLocalizations(a *server.App) (Localizations, error) {
40- var loc Localizations
41-
42- err := yaml.Unmarshal(a.LocYML, &loc)
43- if err != nil {
44- return loc, fmt.Errorf("failed to unmarshal strings.yml: %w", err)
45 }29 }
46 30
47- return loc, nil31+ // 3. Return the localization map for the requested (or fallback) language
32+ return localizationMap, nil
48 }33 }
49 34
50-// Helper function to check if a slice contains a string35+// Helper function to check if a slice contains a string - No longer needed as we check map keys
51-func contains(slice []string, str string) bool {36+// func contains(slice []string, str string) bool { ... }
52- for _, v := range slice {
53- if v == str {
54- return true
55- }
56- }
57- return false
58-}
D internal/webui/embed.go +0 -14
@@ -1,14 +0,0 @@
1-package webui
2-
3-import (
4- "embed"
5-
6- "github.com/labstack/echo/v4"
7-)
8-
9-// Embedding the public directory
10-//
11-//go:embed public/*
12-var public embed.FS
13-
14-var PublicFS = echo.MustSubFS(public, "public")
@@ -1,14 +0,0 @@
1-package webui
2-
3-import (
4- "embed"
5-
6- "github.com/labstack/echo/v4"
7-)
8-
9-// Embedding the public directory
10-//
11-//go:embed public/*
12-var public embed.FS
13-
14-var PublicFS = echo.MustSubFS(public, "public")
M internal/webui/public/assets/css/tailwind.css +6 -0
@@ -3484,6 +3484,9 @@
34843484 font-size: 0.625rem;
34853485 }
34863486 }
3487+ .bottom-4 {
3488+ bottom: calc(var(--spacing) * 4);
3489+ }
34873490 .file-input {
34883491 cursor: pointer;
34893492 cursor: pointer;
@@ -4626,6 +4629,9 @@
46264629 .ml-10 {
46274630 margin-left: calc(var(--spacing) * 10);
46284631 }
4632+ .ml-auto {
4633+ margin-left: auto;
4634+ }
46294635 .carousel-item {
46304636 box-sizing: content-box;
46314637 display: flex;
@@ -3484,6 +3484,9 @@
3484 font-size: 0.625rem;3484 font-size: 0.625rem;
3485 }3485 }
3486 }3486 }
3487+ .bottom-4 {
3488+ bottom: calc(var(--spacing) * 4);
3489+ }
3487 .file-input {3490 .file-input {
3488 cursor: pointer;3491 cursor: pointer;
3489 cursor: pointer;3492 cursor: pointer;
@@ -4626,6 +4629,9 @@
4626 .ml-10 {4629 .ml-10 {
4627 margin-left: calc(var(--spacing) * 10);4630 margin-left: calc(var(--spacing) * 10);
4628 }4631 }
4632+ .ml-auto {
4633+ margin-left: auto;
4634+ }
4629 .carousel-item {4635 .carousel-item {
4630 box-sizing: content-box;4636 box-sizing: content-box;
4631 display: flex;4637 display: flex;

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close