Merge pull request #602 from dvdpearson/dev

Dynamize manifest.json with configurable options
This commit is contained in:
Svilen Markov 2025-04-23 12:25:09 +01:00 committed by GitHub
commit efd39e1f80
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 104 additions and 51 deletions

View File

@ -268,6 +268,9 @@ branding:
<p>Powered by <a href="https://github.com/glanceapp/glance">Glance</a></p> <p>Powered by <a href="https://github.com/glanceapp/glance">Glance</a></p>
logo-url: /assets/logo.png logo-url: /assets/logo.png
favicon-url: /assets/logo.png favicon-url: /assets/logo.png
app-name: "My Dashboard"
app-icon-url: "/assets/app-icon.png"
app-background-color: "#151519"
``` ```
### Properties ### Properties
@ -279,6 +282,9 @@ branding:
| logo-text | string | no | G | | logo-text | string | no | G |
| logo-url | string | no | | | logo-url | string | no | |
| favicon-url | string | no | | | favicon-url | string | no | |
| app-name | string | no | Glance |
| app-icon-url | string | no | Glance's default icon |
| app-background-color | string | no | Glance's default background color |
#### `hide-footer` #### `hide-footer`
Hides the footer when set to `true`. Hides the footer when set to `true`.
@ -295,6 +301,15 @@ Specify a URL to a custom image to use instead of the "G" found in the navigatio
#### `favicon-url` #### `favicon-url`
Specify a URL to a custom image to use for the favicon. Specify a URL to a custom image to use for the favicon.
#### `app-name`
Specify the name of the web app shown in browser tab and PWA.
#### `app-icon-url`
Specify URL for PWA and browser tab icon (512x512 PNG).
#### `app-background-color`
Specify background color for PWA. Must be a valid CSS color.
## Theme ## Theme
Theming is done through a top level `theme` property. Values for the colors are in [HSL](https://giggster.com/guide/basics/hue-saturation-lightness/) (hue, saturation, lightness) format. You can use a color picker [like this one](https://hslpicker.com/) to convert colors from other formats to HSL. The values are separated by a space and `%` is not required for any of the numbers. Theming is done through a top level `theme` property. Values for the colors are in [HSL](https://giggster.com/guide/basics/hue-saturation-lightness/) (hue, saturation, lightness) format. You can use a color picker [like this one](https://hslpicker.com/) to convert colors from other formats to HSL. The values are separated by a space and `%` is not required for any of the numbers.

View File

@ -14,7 +14,7 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`) var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?([\d\.]+)(?: |,)+([\d\.]+)%?(?: |,)+([\d\.]+)%?\)?$`)
const ( const (
hslHueMax = 360 hslHueMax = 360
@ -23,13 +23,13 @@ const (
) )
type hslColorField struct { type hslColorField struct {
Hue uint16 Hue float64
Saturation uint8 Saturation float64
Lightness uint8 Lightness float64
} }
func (c *hslColorField) String() string { func (c *hslColorField) String() string {
return fmt.Sprintf("hsl(%d, %d%%, %d%%)", c.Hue, c.Saturation, c.Lightness) return fmt.Sprintf("hsl(%.1f, %.1f%%, %.1f%%)", c.Hue, c.Saturation, c.Lightness)
} }
func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error { func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
@ -45,7 +45,7 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
return fmt.Errorf("invalid HSL color format: %s", value) return fmt.Errorf("invalid HSL color format: %s", value)
} }
hue, err := strconv.ParseUint(matches[1], 10, 16) hue, err := strconv.ParseFloat(matches[1], 64)
if err != nil { if err != nil {
return err return err
} }
@ -54,7 +54,7 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
return fmt.Errorf("HSL hue must be between 0 and %d", hslHueMax) return fmt.Errorf("HSL hue must be between 0 and %d", hslHueMax)
} }
saturation, err := strconv.ParseUint(matches[2], 10, 8) saturation, err := strconv.ParseFloat(matches[2], 64)
if err != nil { if err != nil {
return err return err
} }
@ -63,7 +63,7 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
return fmt.Errorf("HSL saturation must be between 0 and %d", hslSaturationMax) return fmt.Errorf("HSL saturation must be between 0 and %d", hslSaturationMax)
} }
lightness, err := strconv.ParseUint(matches[3], 10, 8) lightness, err := strconv.ParseFloat(matches[3], 64)
if err != nil { if err != nil {
return err return err
} }
@ -72,9 +72,9 @@ func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
return fmt.Errorf("HSL lightness must be between 0 and %d", hslLightnessMax) return fmt.Errorf("HSL lightness must be between 0 and %d", hslLightnessMax)
} }
c.Hue = uint16(hue) c.Hue = hue
c.Saturation = uint8(saturation) c.Saturation = saturation
c.Lightness = uint8(lightness) c.Lightness = lightness
return nil return nil
} }

View File

@ -31,7 +31,6 @@ type config struct {
Port uint16 `yaml:"port"` Port uint16 `yaml:"port"`
AssetsPath string `yaml:"assets-path"` AssetsPath string `yaml:"assets-path"`
BaseURL string `yaml:"base-url"` BaseURL string `yaml:"base-url"`
StartedAt time.Time `yaml:"-"` // used in custom css file
} `yaml:"server"` } `yaml:"server"`
Document struct { Document struct {
@ -55,6 +54,9 @@ type config struct {
LogoText string `yaml:"logo-text"` LogoText string `yaml:"logo-text"`
LogoURL string `yaml:"logo-url"` LogoURL string `yaml:"logo-url"`
FaviconURL string `yaml:"favicon-url"` FaviconURL string `yaml:"favicon-url"`
AppName string `yaml:"app-name"`
AppIconURL string `yaml:"app-icon-url"`
AppBackgroundColor string `yaml:"app-background-color"`
} `yaml:"branding"` } `yaml:"branding"`
Pages []page `yaml:"pages"` Pages []page `yaml:"pages"`

View File

@ -18,15 +18,19 @@ var (
pageTemplate = mustParseTemplate("page.html", "document.html") pageTemplate = mustParseTemplate("page.html", "document.html")
pageContentTemplate = mustParseTemplate("page-content.html") pageContentTemplate = mustParseTemplate("page-content.html")
pageThemeStyleTemplate = mustParseTemplate("theme-style.gotmpl") pageThemeStyleTemplate = mustParseTemplate("theme-style.gotmpl")
manifestTemplate = mustParseTemplate("manifest.json")
) )
const STATIC_ASSETS_CACHE_DURATION = 24 * time.Hour const STATIC_ASSETS_CACHE_DURATION = 24 * time.Hour
type application struct { type application struct {
Version string Version string
CreatedAt time.Time
Config config Config config
ParsedThemeStyle template.HTML ParsedThemeStyle template.HTML
parsedManifest []byte
slugToPage map[string]*page slugToPage map[string]*page
widgetByID map[uint64]widget widgetByID map[uint64]widget
} }
@ -34,6 +38,7 @@ type application struct {
func newApplication(config *config) (*application, error) { func newApplication(config *config) (*application, error) {
app := &application{ app := &application{
Version: buildVersion, Version: buildVersion,
CreatedAt: time.Now(),
Config: *config, Config: *config,
slugToPage: make(map[string]*page), slugToPage: make(map[string]*page),
widgetByID: make(map[uint64]widget), widgetByID: make(map[uint64]widget),
@ -42,7 +47,7 @@ func newApplication(config *config) (*application, error) {
app.slugToPage[""] = &config.Pages[0] app.slugToPage[""] = &config.Pages[0]
providers := &widgetProviders{ providers := &widgetProviders{
assetResolver: app.AssetPath, assetResolver: app.StaticAssetPath,
} }
var err error var err error
@ -88,15 +93,36 @@ func newApplication(config *config) (*application, error) {
config = &app.Config config = &app.Config
config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/") config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
config.Theme.CustomCSSFile = app.transformUserDefinedAssetPath(config.Theme.CustomCSSFile) config.Theme.CustomCSSFile = app.resolveUserDefinedAssetPath(config.Theme.CustomCSSFile)
config.Branding.LogoURL = app.resolveUserDefinedAssetPath(config.Branding.LogoURL)
if config.Branding.FaviconURL == "" { if config.Branding.FaviconURL == "" {
config.Branding.FaviconURL = app.AssetPath("favicon.png") config.Branding.FaviconURL = app.StaticAssetPath("favicon.png")
} else { } else {
config.Branding.FaviconURL = app.transformUserDefinedAssetPath(config.Branding.FaviconURL) config.Branding.FaviconURL = app.resolveUserDefinedAssetPath(config.Branding.FaviconURL)
} }
config.Branding.LogoURL = app.transformUserDefinedAssetPath(config.Branding.LogoURL) if config.Branding.AppName == "" {
config.Branding.AppName = "Glance"
}
if config.Branding.AppIconURL == "" {
config.Branding.AppIconURL = app.StaticAssetPath("app-icon.png")
}
if config.Branding.AppBackgroundColor == "" {
config.Branding.AppBackgroundColor = ternary(
config.Theme.BackgroundColor != nil,
config.Theme.BackgroundColor.String(),
"hsl(240, 8%, 9%)",
)
}
var manifestWriter bytes.Buffer
if err := manifestTemplate.Execute(&manifestWriter, pageTemplateData{App: app}); err != nil {
return nil, fmt.Errorf("parsing manifest.json: %v", err)
}
app.parsedManifest = manifestWriter.Bytes()
return app, nil return app, nil
} }
@ -126,7 +152,7 @@ func (p *page) updateOutdatedWidgets() {
wg.Wait() wg.Wait()
} }
func (a *application) transformUserDefinedAssetPath(path string) string { func (a *application) resolveUserDefinedAssetPath(path string) string {
if strings.HasPrefix(path, "/assets/") { if strings.HasPrefix(path, "/assets/") {
return a.Config.Server.BaseURL + path return a.Config.Server.BaseURL + path
} }
@ -220,10 +246,15 @@ func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request
widget.handleRequest(w, r) widget.handleRequest(w, r)
} }
func (a *application) AssetPath(asset string) string { func (a *application) StaticAssetPath(asset string) string {
return a.Config.Server.BaseURL + "/static/" + staticFSHash + "/" + asset return a.Config.Server.BaseURL + "/static/" + staticFSHash + "/" + asset
} }
func (a *application) VersionedAssetPath(asset string) string {
return a.Config.Server.BaseURL + asset +
"?v=" + strconv.FormatInt(a.CreatedAt.Unix(), 10)
}
func (a *application) server() (func() error, func() 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
@ -246,17 +277,23 @@ func (a *application) server() (func() error, func() error) {
), ),
) )
cssBundleCacheControlValue := fmt.Sprintf( assetCacheControlValue := fmt.Sprintf(
"public, max-age=%d", "public, max-age=%d",
int(STATIC_ASSETS_CACHE_DURATION.Seconds()), int(STATIC_ASSETS_CACHE_DURATION.Seconds()),
) )
mux.HandleFunc(fmt.Sprintf("GET /static/%s/css/bundle.css", staticFSHash), func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc(fmt.Sprintf("GET /static/%s/css/bundle.css", staticFSHash), func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Cache-Control", cssBundleCacheControlValue) w.Header().Add("Cache-Control", assetCacheControlValue)
w.Header().Add("Content-Type", "text/css; charset=utf-8") w.Header().Add("Content-Type", "text/css; charset=utf-8")
w.Write(bundledCSSContents) w.Write(bundledCSSContents)
}) })
mux.HandleFunc("GET /manifest.json", func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Cache-Control", assetCacheControlValue)
w.Header().Add("Content-Type", "application/json")
w.Write(a.parsedManifest)
})
var absAssetsPath string var absAssetsPath string
if a.Config.Server.AssetsPath != "" { if a.Config.Server.AssetsPath != "" {
absAssetsPath, _ = filepath.Abs(a.Config.Server.AssetsPath) absAssetsPath, _ = filepath.Abs(a.Config.Server.AssetsPath)
@ -270,7 +307,6 @@ func (a *application) server() (func() error, func() error) {
} }
start := func() error { start := func() error {
a.Config.Server.StartedAt = time.Now()
log.Printf("Starting server on %s:%d (base-url: \"%s\", assets-path: \"%s\")\n", log.Printf("Starting server on %s:%d (base-url: \"%s\", assets-path: \"%s\")\n",
a.Config.Server.Host, a.Config.Server.Host,
a.Config.Server.Port, a.Config.Server.Port,

View File

@ -1,14 +0,0 @@
{
"name": "Glance",
"display": "standalone",
"background_color": "#151519",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "app-icon.png",
"type": "image/png",
"sizes": "512x512"
}
]
}

View File

@ -10,13 +10,13 @@
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Glance"> <meta name="apple-mobile-web-app-title" content="{{ .App.Config.Branding.AppName }}">
<meta name="theme-color" content="{{ if ne nil .App.Config.Theme.BackgroundColor }}{{ .App.Config.Theme.BackgroundColor }}{{ else }}hsl(240, 8%, 9%){{ end }}"> <meta name="theme-color" content="{{ if ne nil .App.Config.Theme.BackgroundColor }}{{ .App.Config.Theme.BackgroundColor }}{{ else }}hsl(240, 8%, 9%){{ end }}">
<link rel="apple-touch-icon" sizes="512x512" href='{{ .App.AssetPath "app-icon.png" }}'> <link rel="apple-touch-icon" sizes="512x512" href='{{ .App.Config.Branding.AppIconURL }}'>
<link rel="manifest" href='{{ .App.AssetPath "manifest.json" }}'> <link rel="manifest" href='{{ .App.VersionedAssetPath "manifest.json" }}'>
<link rel="icon" type="image/png" href="{{ .App.Config.Branding.FaviconURL }}" /> <link rel="icon" type="image/png" href="{{ .App.Config.Branding.FaviconURL }}" />
<link rel="stylesheet" href='{{ .App.AssetPath "css/bundle.css" }}'> <link rel="stylesheet" href='{{ .App.StaticAssetPath "css/bundle.css" }}'>
<script type="module" src='{{ .App.AssetPath "js/main.js" }}'></script> <script type="module" src='{{ .App.StaticAssetPath "js/main.js" }}'></script>
{{ block "document-head-after" . }}{{ end }} {{ block "document-head-after" . }}{{ end }}
</head> </head>
<body> <body>

View File

@ -0,0 +1,14 @@
{
"name": "{{ .App.Config.Branding.AppName }}",
"display": "standalone",
"background_color": "{{ .App.Config.Branding.AppBackgroundColor }}",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "{{ .App.Config.Branding.AppIconURL }}",
"type": "image/png",
"sizes": "512x512"
}
]
}

View File

@ -17,7 +17,7 @@
{{ .App.ParsedThemeStyle }} {{ .App.ParsedThemeStyle }}
{{ if ne "" .App.Config.Theme.CustomCSSFile }} {{ if ne "" .App.Config.Theme.CustomCSSFile }}
<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.Config.Server.StartedAt.Unix }}"> <link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.CreatedAt.Unix }}">
{{ end }} {{ end }}
{{ if ne "" .App.Config.Document.Head }}{{ .App.Config.Document.Head }}{{ end }} {{ if ne "" .App.Config.Document.Head }}{{ .App.Config.Document.Head }}{{ end }}