mirror of
https://github.com/yusing/godoxy.git
synced 2026-04-25 18:29:23 +02:00
refactor(homepage): reorganize icons into dedicated package structure
Split the monolithic `internal/homepage` icons functionality into a structured package hierarchy: - `internal/homepage/icons/` - Core types (URL, Key, Meta, Provider, Source, Variant) - `internal/homepage/icons/fetch/` - Icon fetching logic (content.go, fetch.go, route.go) - `internal/homepage/icons/list/` - Icon listing and search (list_icons.go, list_icons_test.go) Moved icon-related code from `internal/homepage/`: - `icon_url.go` → `icons/url.go` (+ url_test.go) - `content.go` → `icons/fetch/content.go` - `route.go` → `icons/fetch/route.go` - `list_icons.go` → `icons/list/list_icons.go` (+ list_icons_test.go) Updated all consumers to use the new package structure: - `cmd/main.go` - `internal/api/v1/favicon.go` - `internal/api/v1/icons.go` - `internal/idlewatcher/handle_http.go` - `internal/route/route.go`
This commit is contained in:
37
internal/homepage/icons/fetch/content.go
Normal file
37
internal/homepage/icons/fetch/content.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package iconfetch
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type content struct {
|
||||
header http.Header
|
||||
data []byte
|
||||
status int
|
||||
}
|
||||
|
||||
func newContent() *content {
|
||||
return &content{
|
||||
header: make(http.Header),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *content) Header() http.Header {
|
||||
return c.header
|
||||
}
|
||||
|
||||
func (c *content) Write(data []byte) (int, error) {
|
||||
c.data = append(c.data, data...)
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
func (c *content) WriteHeader(statusCode int) {
|
||||
c.status = statusCode
|
||||
}
|
||||
|
||||
func (c *content) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
return nil, nil, errors.New("not supported")
|
||||
}
|
||||
256
internal/homepage/icons/fetch/fetch.go
Normal file
256
internal/homepage/icons/fetch/fetch.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package iconfetch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/vincent-petithory/dataurl"
|
||||
"github.com/yusing/godoxy/internal/homepage/icons"
|
||||
gphttp "github.com/yusing/godoxy/internal/net/gphttp"
|
||||
apitypes "github.com/yusing/goutils/apitypes"
|
||||
"github.com/yusing/goutils/cache"
|
||||
httputils "github.com/yusing/goutils/http"
|
||||
strutils "github.com/yusing/goutils/strings"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
Icon []byte
|
||||
StatusCode int
|
||||
|
||||
contentType string
|
||||
}
|
||||
|
||||
func FetchResultWithErrorf(statusCode int, msgFmt string, args ...any) (Result, error) {
|
||||
return Result{StatusCode: statusCode}, fmt.Errorf(msgFmt, args...)
|
||||
}
|
||||
|
||||
func FetchResultOK(icon []byte, contentType string) (Result, error) {
|
||||
return Result{Icon: icon, contentType: contentType}, nil
|
||||
}
|
||||
|
||||
func GinError(c *gin.Context, statusCode int, err error) {
|
||||
if statusCode == 0 {
|
||||
statusCode = http.StatusInternalServerError
|
||||
}
|
||||
if statusCode == http.StatusInternalServerError {
|
||||
c.Error(apitypes.InternalServerError(err, "unexpected error"))
|
||||
} else {
|
||||
c.JSON(statusCode, apitypes.Error(err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
const faviconFetchTimeout = 3 * time.Second
|
||||
|
||||
func (res *Result) ContentType() string {
|
||||
if res.contentType == "" {
|
||||
if bytes.HasPrefix(res.Icon, []byte("<svg")) || bytes.HasPrefix(res.Icon, []byte("<?xml")) {
|
||||
return "image/svg+xml"
|
||||
}
|
||||
return "image/x-icon"
|
||||
}
|
||||
return res.contentType
|
||||
}
|
||||
|
||||
const maxRedirectDepth = 5
|
||||
|
||||
func FetchFavIconFromURL(ctx context.Context, iconURL *icons.URL) (Result, error) {
|
||||
switch iconURL.Source {
|
||||
case icons.SourceAbsolute:
|
||||
return FetchIconAbsolute(ctx, iconURL.URL())
|
||||
case icons.SourceRelative:
|
||||
return FetchResultWithErrorf(http.StatusBadRequest, "unexpected relative icon")
|
||||
case icons.SourceWalkXCode, icons.SourceSelfhSt:
|
||||
return fetchKnownIcon(ctx, iconURL)
|
||||
}
|
||||
return FetchResultWithErrorf(http.StatusBadRequest, "invalid icon source")
|
||||
}
|
||||
|
||||
var FetchIconAbsolute = cache.NewKeyFunc(func(ctx context.Context, url string) (Result, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return FetchResultWithErrorf(http.StatusInternalServerError, "cannot create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := gphttp.Do(req)
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
} else {
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
return FetchResultWithErrorf(http.StatusBadGateway, "request timeout")
|
||||
}
|
||||
return FetchResultWithErrorf(http.StatusBadGateway, "connection error: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return FetchResultWithErrorf(resp.StatusCode, "upstream error: http %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
icon, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return FetchResultWithErrorf(http.StatusInternalServerError, "failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
if len(icon) == 0 {
|
||||
return FetchResultWithErrorf(http.StatusNotFound, "empty icon")
|
||||
}
|
||||
|
||||
res := Result{Icon: icon}
|
||||
if contentType := resp.Header.Get("Content-Type"); contentType != "" {
|
||||
res.contentType = contentType
|
||||
}
|
||||
// else leave it empty
|
||||
return res, nil
|
||||
}).WithMaxEntries(200).WithRetriesExponentialBackoff(3).WithTTL(4 * time.Hour).Build()
|
||||
|
||||
var nameSanitizer = strings.NewReplacer(
|
||||
"_", "-",
|
||||
" ", "-",
|
||||
"(", "",
|
||||
")", "",
|
||||
)
|
||||
|
||||
func sanitizeName(name string) string {
|
||||
return strings.ToLower(nameSanitizer.Replace(name))
|
||||
}
|
||||
|
||||
func fetchKnownIcon(ctx context.Context, url *icons.URL) (Result, error) {
|
||||
// if icon isn't in the list, no need to fetch
|
||||
if !url.HasIcon() {
|
||||
return Result{StatusCode: http.StatusNotFound}, errors.New("no such icon")
|
||||
}
|
||||
|
||||
return FetchIconAbsolute(ctx, url.URL())
|
||||
}
|
||||
|
||||
func fetchIcon(ctx context.Context, filename string) (Result, error) {
|
||||
for _, fileType := range []string{"svg", "webp", "png"} {
|
||||
result, err := fetchKnownIcon(ctx, icons.NewURL(icons.SourceSelfhSt, filename, fileType))
|
||||
if err == nil {
|
||||
return result, err
|
||||
}
|
||||
result, err = fetchKnownIcon(ctx, icons.NewURL(icons.SourceWalkXCode, filename, fileType))
|
||||
if err == nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
return FetchResultWithErrorf(http.StatusNotFound, "no icon found")
|
||||
}
|
||||
|
||||
type contextValue struct {
|
||||
r httpRoute
|
||||
uri string
|
||||
}
|
||||
|
||||
func FindIcon(ctx context.Context, r route, uri string, variant icons.Variant) (Result, error) {
|
||||
for _, ref := range r.References() {
|
||||
ref = sanitizeName(ref)
|
||||
if variant != icons.VariantNone {
|
||||
ref += "-" + string(variant)
|
||||
}
|
||||
result, err := fetchIcon(ctx, ref)
|
||||
if err == nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
if r, ok := r.(httpRoute); ok {
|
||||
// fallback to parse html
|
||||
return findIconSlowCached(context.WithValue(ctx, "route", contextValue{r: r, uri: uri}), r.Key())
|
||||
}
|
||||
return FetchResultWithErrorf(http.StatusNotFound, "no icon found")
|
||||
}
|
||||
|
||||
var findIconSlowCached = cache.NewKeyFunc(func(ctx context.Context, key string) (Result, error) {
|
||||
v := ctx.Value("route").(contextValue)
|
||||
return findIconSlow(ctx, v.r, v.uri, nil)
|
||||
}).WithMaxEntries(200).Build() // no retries, no ttl
|
||||
|
||||
func findIconSlow(ctx context.Context, r httpRoute, uri string, stack []string) (Result, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return FetchResultWithErrorf(http.StatusBadGateway, "request timeout")
|
||||
default:
|
||||
}
|
||||
|
||||
if len(stack) > maxRedirectDepth {
|
||||
return FetchResultWithErrorf(http.StatusBadGateway, "too many redirects")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeoutCause(ctx, faviconFetchTimeout, errors.New("favicon request timeout"))
|
||||
defer cancel()
|
||||
|
||||
newReq, err := http.NewRequestWithContext(ctx, http.MethodGet, r.TargetURL().String(), nil)
|
||||
if err != nil {
|
||||
return FetchResultWithErrorf(http.StatusInternalServerError, "cannot create request: %w", err)
|
||||
}
|
||||
newReq.Header.Set("Accept-Encoding", "identity") // disable compression
|
||||
|
||||
u, err := url.ParseRequestURI(strutils.SanitizeURI(uri))
|
||||
if err != nil {
|
||||
return FetchResultWithErrorf(http.StatusInternalServerError, "cannot parse uri: %w", err)
|
||||
}
|
||||
newReq.URL.Path = u.Path
|
||||
newReq.URL.RawPath = u.RawPath
|
||||
newReq.URL.RawQuery = u.RawQuery
|
||||
newReq.RequestURI = u.String()
|
||||
|
||||
c := newContent()
|
||||
r.ServeHTTP(c, newReq)
|
||||
if c.status != http.StatusOK {
|
||||
switch c.status {
|
||||
case 0:
|
||||
return FetchResultWithErrorf(http.StatusBadGateway, "connection error")
|
||||
default:
|
||||
if loc := c.Header().Get("Location"); loc != "" {
|
||||
loc = strutils.SanitizeURI(loc)
|
||||
if loc == "/" || loc == newReq.URL.Path || slices.Contains(stack, loc) {
|
||||
return FetchResultWithErrorf(http.StatusBadGateway, "circular redirect")
|
||||
}
|
||||
// append current path to stack
|
||||
// handles redirect to the same path with different query
|
||||
return findIconSlow(ctx, r, loc, append(stack, newReq.URL.Path))
|
||||
}
|
||||
}
|
||||
return FetchResultWithErrorf(c.status, "upstream error: status %d, %s", c.status, c.data)
|
||||
}
|
||||
// return icon data
|
||||
if !httputils.GetContentType(c.header).IsHTML() {
|
||||
return FetchResultOK(c.data, c.header.Get("Content-Type"))
|
||||
}
|
||||
// try extract from "link[rel=icon]" from path "/"
|
||||
doc, err := goquery.NewDocumentFromReader(bytes.NewBuffer(c.data))
|
||||
if err != nil {
|
||||
return FetchResultWithErrorf(http.StatusInternalServerError, "failed to parse html: %w", err)
|
||||
}
|
||||
ele := doc.Find("head > link[rel=icon]").First()
|
||||
if ele.Length() == 0 {
|
||||
return FetchResultWithErrorf(http.StatusNotFound, "icon element not found")
|
||||
}
|
||||
href := ele.AttrOr("href", "")
|
||||
if href == "" {
|
||||
return FetchResultWithErrorf(http.StatusNotFound, "icon href not found")
|
||||
}
|
||||
// https://en.wikipedia.org/wiki/Data_URI_scheme
|
||||
if strings.HasPrefix(href, "data:image/") {
|
||||
dataURI, err := dataurl.DecodeString(href)
|
||||
if err != nil {
|
||||
return FetchResultWithErrorf(http.StatusInternalServerError, "failed to decode favicon: %w", err)
|
||||
}
|
||||
return FetchResultOK(dataURI.Data, dataURI.ContentType())
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(href, "http://"), strings.HasPrefix(href, "https://"):
|
||||
return FetchIconAbsolute(ctx, href)
|
||||
default:
|
||||
return findIconSlow(ctx, r, href, append(stack, newReq.URL.Path))
|
||||
}
|
||||
}
|
||||
20
internal/homepage/icons/fetch/route.go
Normal file
20
internal/homepage/icons/fetch/route.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package iconfetch
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
nettypes "github.com/yusing/godoxy/internal/net/types"
|
||||
"github.com/yusing/goutils/pool"
|
||||
)
|
||||
|
||||
type route interface {
|
||||
pool.Object
|
||||
ProviderName() string
|
||||
References() []string
|
||||
TargetURL() *nettypes.URL
|
||||
}
|
||||
|
||||
type httpRoute interface {
|
||||
route
|
||||
http.Handler
|
||||
}
|
||||
17
internal/homepage/icons/key.go
Normal file
17
internal/homepage/icons/key.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package icons
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Key string
|
||||
|
||||
func NewKey(source Source, reference string) Key {
|
||||
return Key(fmt.Sprintf("%s/%s", source, reference))
|
||||
}
|
||||
|
||||
func (k Key) SourceRef() (Source, string) {
|
||||
source, ref, _ := strings.Cut(string(k), "/")
|
||||
return Source(source), ref
|
||||
}
|
||||
367
internal/homepage/icons/list/list_icons.go
Normal file
367
internal/homepage/icons/list/list_icons.go
Normal file
@@ -0,0 +1,367 @@
|
||||
package iconlist
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/lithammer/fuzzysearch/fuzzy"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/yusing/godoxy/internal/common"
|
||||
"github.com/yusing/godoxy/internal/homepage/icons"
|
||||
"github.com/yusing/godoxy/internal/serialization"
|
||||
httputils "github.com/yusing/goutils/http"
|
||||
"github.com/yusing/goutils/intern"
|
||||
strutils "github.com/yusing/goutils/strings"
|
||||
"github.com/yusing/goutils/synk"
|
||||
"github.com/yusing/goutils/task"
|
||||
)
|
||||
|
||||
type (
|
||||
IconMap map[icons.Key]*icons.Meta
|
||||
IconList []string
|
||||
|
||||
IconMetaSearch struct {
|
||||
*icons.Meta
|
||||
|
||||
Source icons.Source `json:"Source"`
|
||||
Ref string `json:"Ref"`
|
||||
|
||||
rank int
|
||||
}
|
||||
)
|
||||
|
||||
const updateInterval = 2 * time.Hour
|
||||
|
||||
var iconsCache synk.Value[IconMap]
|
||||
|
||||
const (
|
||||
walkxcodeIcons = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/tree.json"
|
||||
selfhstIcons = "https://raw.githubusercontent.com/selfhst/icons/refs/heads/main/index.json"
|
||||
)
|
||||
|
||||
func InitCache() {
|
||||
m := make(IconMap)
|
||||
err := serialization.LoadJSONIfExist(common.IconListCachePath, &m)
|
||||
if err != nil {
|
||||
// backward compatible
|
||||
oldFormat := struct {
|
||||
Icons IconMap
|
||||
LastUpdate time.Time
|
||||
}{}
|
||||
err = serialization.LoadJSONIfExist(common.IconListCachePath, &oldFormat)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to load icons")
|
||||
} else {
|
||||
m = oldFormat.Icons
|
||||
// store it to disk immediately
|
||||
_ = serialization.SaveJSON(common.IconListCachePath, &m, 0o644)
|
||||
}
|
||||
} else if len(m) > 0 {
|
||||
log.Info().
|
||||
Int("icons", len(m)).
|
||||
Msg("icons loaded")
|
||||
} else {
|
||||
if err := updateIcons(m); err != nil {
|
||||
log.Error().Err(err).Msg("failed to update icons")
|
||||
}
|
||||
}
|
||||
|
||||
iconsCache.Store(m)
|
||||
|
||||
task.OnProgramExit("save_icons_cache", func() {
|
||||
icons := iconsCache.Load()
|
||||
_ = serialization.SaveJSON(common.IconListCachePath, &icons, 0o644)
|
||||
})
|
||||
|
||||
go backgroundUpdateIcons()
|
||||
}
|
||||
|
||||
func backgroundUpdateIcons() {
|
||||
ticker := time.NewTicker(updateInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
log.Info().Msg("updating icon data")
|
||||
newCache := make(IconMap, len(iconsCache.Load()))
|
||||
if err := updateIcons(newCache); err != nil {
|
||||
log.Error().Err(err).Msg("failed to update icons")
|
||||
} else {
|
||||
// swap old cache with new cache
|
||||
iconsCache.Store(newCache)
|
||||
// save it to disk
|
||||
err := serialization.SaveJSON(common.IconListCachePath, &newCache, 0o644)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("failed to save icons")
|
||||
}
|
||||
log.Info().Int("icons", len(newCache)).Msg("icons list updated")
|
||||
}
|
||||
case <-task.RootContext().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearIconsCache() {
|
||||
clear(iconsCache.Load())
|
||||
}
|
||||
|
||||
func ListAvailableIcons() IconMap {
|
||||
return iconsCache.Load()
|
||||
}
|
||||
|
||||
func SearchIcons(keyword string, limit int) []*IconMetaSearch {
|
||||
if keyword == "" {
|
||||
return []*IconMetaSearch{}
|
||||
}
|
||||
|
||||
if limit == 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
searchLimit := min(limit*5, 50)
|
||||
|
||||
results := make([]*IconMetaSearch, 0, searchLimit)
|
||||
|
||||
sortByRank := func(a, b *IconMetaSearch) int {
|
||||
return a.rank - b.rank
|
||||
}
|
||||
|
||||
var rank int
|
||||
icons := ListAvailableIcons()
|
||||
for k, icon := range icons {
|
||||
if strutils.ContainsFold(string(k), keyword) || strutils.ContainsFold(icon.DisplayName, keyword) {
|
||||
rank = 0
|
||||
} else {
|
||||
rank = fuzzy.RankMatchFold(keyword, string(k))
|
||||
if rank == -1 || rank > 3 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
source, ref := k.SourceRef()
|
||||
ranked := &IconMetaSearch{
|
||||
Source: source,
|
||||
Ref: ref,
|
||||
Meta: icon,
|
||||
rank: rank,
|
||||
}
|
||||
// Sorted insert based on rank (lower rank = better match)
|
||||
insertPos, _ := slices.BinarySearchFunc(results, ranked, sortByRank)
|
||||
results = slices.Insert(results, insertPos, ranked)
|
||||
if len(results) == searchLimit {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Extract results and limit to the requested count
|
||||
return results[:min(len(results), limit)]
|
||||
}
|
||||
|
||||
func HasIcon(icon *icons.URL) bool {
|
||||
if icon.Extra == nil {
|
||||
return false
|
||||
}
|
||||
if common.IsTest {
|
||||
return true
|
||||
}
|
||||
meta, ok := ListAvailableIcons()[icon.Extra.Key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch icon.Extra.FileType {
|
||||
case "png":
|
||||
return meta.PNG && (!icon.Extra.IsLight || meta.Light) && (!icon.Extra.IsDark || meta.Dark)
|
||||
case "svg":
|
||||
return meta.SVG && (!icon.Extra.IsLight || meta.Light) && (!icon.Extra.IsDark || meta.Dark)
|
||||
case "webp":
|
||||
return meta.WebP && (!icon.Extra.IsLight || meta.Light) && (!icon.Extra.IsDark || meta.Dark)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type HomepageMeta struct {
|
||||
DisplayName string
|
||||
Tag string
|
||||
}
|
||||
|
||||
func GetMetadata(ref string) (HomepageMeta, bool) {
|
||||
meta, ok := ListAvailableIcons()[icons.NewKey(icons.SourceSelfhSt, ref)]
|
||||
// these info is not available in walkxcode
|
||||
// if !ok {
|
||||
// meta, ok = iconsCache.Icons[icons.NewIconKey(icons.IconSourceWalkXCode, ref)]
|
||||
// }
|
||||
if !ok {
|
||||
return HomepageMeta{}, false
|
||||
}
|
||||
return HomepageMeta{
|
||||
DisplayName: meta.DisplayName,
|
||||
Tag: meta.Tag,
|
||||
}, true
|
||||
}
|
||||
|
||||
func updateIcons(m IconMap) error {
|
||||
if err := UpdateWalkxCodeIcons(m); err != nil {
|
||||
return err
|
||||
}
|
||||
return UpdateSelfhstIcons(m)
|
||||
}
|
||||
|
||||
var httpGet = httpGetImpl
|
||||
|
||||
func MockHTTPGet(body []byte) {
|
||||
httpGet = func(_ string) ([]byte, func([]byte), error) {
|
||||
return body, func([]byte) {}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func httpGetImpl(url string) ([]byte, func([]byte), error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return httputils.ReadAllBody(resp)
|
||||
}
|
||||
|
||||
/*
|
||||
format:
|
||||
|
||||
{
|
||||
"png": [
|
||||
"*.png",
|
||||
],
|
||||
"svg": [
|
||||
"*.svg",
|
||||
],
|
||||
"webp": [
|
||||
"*.webp",
|
||||
]
|
||||
}
|
||||
*/
|
||||
func UpdateWalkxCodeIcons(m IconMap) error {
|
||||
body, release, err := httpGet(walkxcodeIcons)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data := make(map[string][]string)
|
||||
err = sonic.Unmarshal(body, &data)
|
||||
release(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for fileType, files := range data {
|
||||
var setExt func(icon *icons.Meta)
|
||||
switch fileType {
|
||||
case "png":
|
||||
setExt = func(icon *icons.Meta) { icon.PNG = true }
|
||||
case "svg":
|
||||
setExt = func(icon *icons.Meta) { icon.SVG = true }
|
||||
case "webp":
|
||||
setExt = func(icon *icons.Meta) { icon.WebP = true }
|
||||
}
|
||||
for _, f := range files {
|
||||
f = strings.TrimSuffix(f, "."+fileType)
|
||||
isLight := strings.HasSuffix(f, "-light")
|
||||
if isLight {
|
||||
f = strings.TrimSuffix(f, "-light")
|
||||
}
|
||||
isDark := strings.HasSuffix(f, "-dark")
|
||||
if isDark {
|
||||
f = strings.TrimSuffix(f, "-dark")
|
||||
}
|
||||
key := icons.NewKey(icons.SourceWalkXCode, f)
|
||||
icon, ok := m[key]
|
||||
if !ok {
|
||||
icon = new(icons.Meta)
|
||||
m[key] = icon
|
||||
}
|
||||
setExt(icon)
|
||||
if isLight {
|
||||
icon.Light = true
|
||||
}
|
||||
if isDark {
|
||||
icon.Dark = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/*
|
||||
format:
|
||||
|
||||
{
|
||||
"Name": "2FAuth",
|
||||
"Reference": "2fauth",
|
||||
"SVG": "Yes",
|
||||
"PNG": "Yes",
|
||||
"WebP": "Yes",
|
||||
"Light": "Yes",
|
||||
"Dark": "Yes",
|
||||
"Tag": "",
|
||||
"Category": "Self-Hosted",
|
||||
"CreatedAt": "2024-08-16 00:27:23+00:00"
|
||||
}
|
||||
*/
|
||||
|
||||
func UpdateSelfhstIcons(m IconMap) error {
|
||||
type SelfhStIcon struct {
|
||||
Name string
|
||||
Reference string
|
||||
SVG string
|
||||
PNG string
|
||||
WebP string
|
||||
Light string
|
||||
Dark string
|
||||
Tags string
|
||||
}
|
||||
|
||||
body, release, err := httpGet(selfhstIcons)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data := make([]SelfhStIcon, 0)
|
||||
err = sonic.Unmarshal(body, &data) //nolint:musttag
|
||||
release(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, item := range data {
|
||||
var tag string
|
||||
if item.Tags != "" {
|
||||
tag, _, _ = strings.Cut(item.Tags, ",")
|
||||
tag = strings.TrimSpace(tag)
|
||||
}
|
||||
icon := &icons.Meta{
|
||||
DisplayName: item.Name,
|
||||
Tag: intern.Make(tag).Value(),
|
||||
SVG: item.SVG == "Yes",
|
||||
PNG: item.PNG == "Yes",
|
||||
WebP: item.WebP == "Yes",
|
||||
Light: item.Light == "Yes",
|
||||
Dark: item.Dark == "Yes",
|
||||
}
|
||||
key := icons.NewKey(icons.SourceSelfhSt, item.Reference)
|
||||
m[key] = icon
|
||||
}
|
||||
return nil
|
||||
}
|
||||
185
internal/homepage/icons/list/list_icons_test.go
Normal file
185
internal/homepage/icons/list/list_icons_test.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package iconlist_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/yusing/godoxy/internal/homepage/icons"
|
||||
. "github.com/yusing/godoxy/internal/homepage/icons/list"
|
||||
)
|
||||
|
||||
const walkxcodeIcons = `{
|
||||
"png": [
|
||||
"app1.png",
|
||||
"app1-light.png",
|
||||
"app2.png",
|
||||
"karakeep.png",
|
||||
"karakeep-dark.png"
|
||||
],
|
||||
"svg": [
|
||||
"app1.svg",
|
||||
"app1-light.svg",
|
||||
"karakeep.svg",
|
||||
"karakeep-dark.svg"
|
||||
],
|
||||
"webp": [
|
||||
"app1.webp",
|
||||
"app1-light.webp",
|
||||
"app2.webp",
|
||||
"karakeep.webp",
|
||||
"karakeep-dark.webp"
|
||||
]
|
||||
}`
|
||||
|
||||
const selfhstIcons = `[
|
||||
{
|
||||
"Name": "2FAuth",
|
||||
"Reference": "2fauth",
|
||||
"SVG": "Yes",
|
||||
"PNG": "Yes",
|
||||
"WebP": "Yes",
|
||||
"Light": "Yes",
|
||||
"Dark": "Yes",
|
||||
"Category": "Self-Hosted",
|
||||
"Tags": "",
|
||||
"CreatedAt": "2024-08-16 00:27:23+00:00"
|
||||
},
|
||||
{
|
||||
"Name": "Dittofeed",
|
||||
"Reference": "dittofeed",
|
||||
"SVG": "No",
|
||||
"PNG": "Yes",
|
||||
"WebP": "Yes",
|
||||
"Light": "No",
|
||||
"Dark": "No",
|
||||
"Category": "Self-Hosted",
|
||||
"Tags": "",
|
||||
"CreatedAt": "2024-08-22 11:33:37+00:00"
|
||||
},
|
||||
{
|
||||
"Name": "Ars Technica",
|
||||
"Reference": "ars-technica",
|
||||
"SVG": "Yes",
|
||||
"PNG": "Yes",
|
||||
"WebP": "Yes",
|
||||
"Light": "Yes",
|
||||
"Dark": "Yes",
|
||||
"Category": "Other",
|
||||
"Tags": "News",
|
||||
"CreatedAt": "2025-04-09 11:15:01+00:00"
|
||||
}
|
||||
]`
|
||||
|
||||
type testCases struct {
|
||||
Key Key
|
||||
Meta
|
||||
}
|
||||
|
||||
func runTests(t *testing.T, iconsCache IconMap, test []testCases) {
|
||||
t.Helper()
|
||||
|
||||
for _, item := range test {
|
||||
icon, ok := iconsCache[item.Key]
|
||||
if !ok {
|
||||
t.Fatalf("icon %s not found", item.Key)
|
||||
}
|
||||
if icon.PNG != item.PNG || icon.SVG != item.SVG || icon.WebP != item.WebP {
|
||||
t.Fatalf("icon %s file format mismatch", item.Key)
|
||||
}
|
||||
if icon.Light != item.Light || icon.Dark != item.Dark {
|
||||
t.Fatalf("icon %s variant mismatch", item.Key)
|
||||
}
|
||||
if icon.DisplayName != item.DisplayName {
|
||||
t.Fatalf("icon %s display name mismatch, expect %s, got %s", item.Key, item.DisplayName, icon.DisplayName)
|
||||
}
|
||||
if icon.Tag != item.Tag {
|
||||
t.Fatalf("icon %s tag mismatch, expect %s, got %s", item.Key, item.Tag, icon.Tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWalkxCodeIcons(t *testing.T) {
|
||||
t.Cleanup(TestClearIconsCache)
|
||||
|
||||
MockHTTPGet([]byte(walkxcodeIcons))
|
||||
m := make(IconMap)
|
||||
if err := UpdateWalkxCodeIcons(m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m) != 3 {
|
||||
t.Fatalf("expect 3 icons, got %d", len(m))
|
||||
}
|
||||
test := []testCases{
|
||||
{
|
||||
Key: NewKey(SourceWalkXCode, "app1"),
|
||||
Meta: Meta{
|
||||
SVG: true,
|
||||
PNG: true,
|
||||
WebP: true,
|
||||
Light: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: NewKey(SourceWalkXCode, "app2"),
|
||||
Meta: Meta{
|
||||
PNG: true,
|
||||
WebP: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: NewKey(SourceWalkXCode, "karakeep"),
|
||||
Meta: Meta{
|
||||
SVG: true,
|
||||
PNG: true,
|
||||
WebP: true,
|
||||
Dark: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
runTests(t, m, test)
|
||||
}
|
||||
|
||||
func TestListSelfhstIcons(t *testing.T) {
|
||||
t.Cleanup(TestClearIconsCache)
|
||||
MockHTTPGet([]byte(selfhstIcons))
|
||||
m := make(IconMap)
|
||||
if err := UpdateSelfhstIcons(m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m) != 3 {
|
||||
t.Fatalf("expect 3 icons, got %d", len(m))
|
||||
}
|
||||
test := []testCases{
|
||||
{
|
||||
Key: NewKey(SourceSelfhSt, "2fauth"),
|
||||
Meta: Meta{
|
||||
SVG: true,
|
||||
PNG: true,
|
||||
WebP: true,
|
||||
Light: true,
|
||||
Dark: true,
|
||||
DisplayName: "2FAuth",
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: NewKey(SourceSelfhSt, "dittofeed"),
|
||||
Meta: Meta{
|
||||
PNG: true,
|
||||
WebP: true,
|
||||
DisplayName: "Dittofeed",
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: NewKey(SourceSelfhSt, "ars-technica"),
|
||||
Meta: Meta{
|
||||
SVG: true,
|
||||
PNG: true,
|
||||
WebP: true,
|
||||
Light: true,
|
||||
Dark: true,
|
||||
DisplayName: "Ars Technica",
|
||||
Tag: "News",
|
||||
},
|
||||
},
|
||||
}
|
||||
runTests(t, m, test)
|
||||
}
|
||||
43
internal/homepage/icons/metadata.go
Normal file
43
internal/homepage/icons/metadata.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package icons
|
||||
|
||||
type Meta struct {
|
||||
SVG bool `json:"SVG"`
|
||||
PNG bool `json:"PNG"`
|
||||
WebP bool `json:"WebP"`
|
||||
Light bool `json:"Light"`
|
||||
Dark bool `json:"Dark"`
|
||||
DisplayName string `json:"-"`
|
||||
Tag string `json:"-"`
|
||||
}
|
||||
|
||||
func (icon *Meta) Filenames(ref string) []string {
|
||||
filenames := make([]string, 0)
|
||||
if icon.SVG {
|
||||
filenames = append(filenames, ref+".svg")
|
||||
if icon.Light {
|
||||
filenames = append(filenames, ref+"-light.svg")
|
||||
}
|
||||
if icon.Dark {
|
||||
filenames = append(filenames, ref+"-dark.svg")
|
||||
}
|
||||
}
|
||||
if icon.PNG {
|
||||
filenames = append(filenames, ref+".png")
|
||||
if icon.Light {
|
||||
filenames = append(filenames, ref+"-light.png")
|
||||
}
|
||||
if icon.Dark {
|
||||
filenames = append(filenames, ref+"-dark.png")
|
||||
}
|
||||
}
|
||||
if icon.WebP {
|
||||
filenames = append(filenames, ref+".webp")
|
||||
if icon.Light {
|
||||
filenames = append(filenames, ref+"-light.webp")
|
||||
}
|
||||
if icon.Dark {
|
||||
filenames = append(filenames, ref+"-dark.webp")
|
||||
}
|
||||
}
|
||||
return filenames
|
||||
}
|
||||
21
internal/homepage/icons/provider.go
Normal file
21
internal/homepage/icons/provider.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package icons
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
type Provider interface {
|
||||
HasIcon(u *URL) bool
|
||||
}
|
||||
|
||||
var provider atomic.Value
|
||||
|
||||
func SetProvider(p Provider) {
|
||||
provider.Store(p)
|
||||
}
|
||||
|
||||
func hasIcon(u *URL) bool {
|
||||
v := provider.Load()
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
return v.(Provider).HasIcon(u)
|
||||
}
|
||||
215
internal/homepage/icons/url.go
Normal file
215
internal/homepage/icons/url.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package icons
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
gperr "github.com/yusing/goutils/errs"
|
||||
)
|
||||
|
||||
type (
|
||||
URL struct {
|
||||
Source `json:"source"`
|
||||
|
||||
FullURL *string `json:"value,omitempty"` // only for absolute/relative icons
|
||||
Extra *Extra `json:"extra,omitempty"` // only for walkxcode/selfhst icons
|
||||
}
|
||||
|
||||
Extra struct {
|
||||
Key Key `json:"key"`
|
||||
Ref string `json:"ref"`
|
||||
FileType string `json:"file_type"`
|
||||
IsLight bool `json:"is_light"`
|
||||
IsDark bool `json:"is_dark"`
|
||||
}
|
||||
|
||||
Source string
|
||||
Variant string
|
||||
)
|
||||
|
||||
const (
|
||||
SourceAbsolute Source = "https://"
|
||||
SourceRelative Source = "@target"
|
||||
SourceWalkXCode Source = "@walkxcode"
|
||||
SourceSelfhSt Source = "@selfhst"
|
||||
)
|
||||
|
||||
const (
|
||||
VariantNone Variant = ""
|
||||
VariantLight Variant = "light"
|
||||
VariantDark Variant = "dark"
|
||||
)
|
||||
|
||||
var ErrInvalidIconURL = gperr.New("invalid icon url")
|
||||
|
||||
func NewURL(source Source, refOrName, format string) *URL {
|
||||
switch source {
|
||||
case SourceWalkXCode, SourceSelfhSt:
|
||||
default:
|
||||
panic("invalid icon source")
|
||||
}
|
||||
isLight, isDark := false, false
|
||||
if strings.HasSuffix(refOrName, "-light") {
|
||||
isLight = true
|
||||
refOrName = strings.TrimSuffix(refOrName, "-light")
|
||||
} else if strings.HasSuffix(refOrName, "-dark") {
|
||||
isDark = true
|
||||
refOrName = strings.TrimSuffix(refOrName, "-dark")
|
||||
}
|
||||
return &URL{
|
||||
Source: source,
|
||||
Extra: &Extra{
|
||||
Key: NewKey(source, refOrName),
|
||||
FileType: format,
|
||||
Ref: refOrName,
|
||||
IsLight: isLight,
|
||||
IsDark: isDark,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (u *URL) HasIcon() bool {
|
||||
return hasIcon(u)
|
||||
}
|
||||
|
||||
func (u *URL) WithVariant(variant Variant) *URL {
|
||||
switch u.Source {
|
||||
case SourceWalkXCode, SourceSelfhSt:
|
||||
default:
|
||||
return u // no variant for absolute/relative icons
|
||||
}
|
||||
|
||||
var extra *Extra
|
||||
if u.Extra != nil {
|
||||
extra = &Extra{
|
||||
Key: u.Extra.Key,
|
||||
Ref: u.Extra.Ref,
|
||||
FileType: u.Extra.FileType,
|
||||
IsLight: variant == VariantLight,
|
||||
IsDark: variant == VariantDark,
|
||||
}
|
||||
extra.Ref = strings.TrimSuffix(extra.Ref, "-light")
|
||||
extra.Ref = strings.TrimSuffix(extra.Ref, "-dark")
|
||||
}
|
||||
return &URL{
|
||||
Source: u.Source,
|
||||
FullURL: u.FullURL,
|
||||
Extra: extra,
|
||||
}
|
||||
}
|
||||
|
||||
// Parse implements strutils.Parser.
|
||||
func (u *URL) Parse(v string) error {
|
||||
return u.parse(v, true)
|
||||
}
|
||||
|
||||
func (u *URL) parse(v string, checkExists bool) error {
|
||||
if v == "" {
|
||||
return ErrInvalidIconURL
|
||||
}
|
||||
slashIndex := strings.Index(v, "/")
|
||||
if slashIndex == -1 {
|
||||
return ErrInvalidIconURL
|
||||
}
|
||||
beforeSlash := v[:slashIndex]
|
||||
switch beforeSlash {
|
||||
case "http:", "https:":
|
||||
u.FullURL = &v
|
||||
u.Source = SourceAbsolute
|
||||
case "@target", "": // @target/favicon.ico, /favicon.ico
|
||||
url := v[slashIndex:]
|
||||
if url == "/" {
|
||||
return ErrInvalidIconURL.Withf("%s", "empty path")
|
||||
}
|
||||
u.FullURL = &url
|
||||
u.Source = SourceRelative
|
||||
case "@selfhst", "@walkxcode": // selfh.st / walkxcode Icons, @selfhst/<reference>.<format>
|
||||
if beforeSlash == "@selfhst" {
|
||||
u.Source = SourceSelfhSt
|
||||
} else {
|
||||
u.Source = SourceWalkXCode
|
||||
}
|
||||
parts := strings.Split(v[slashIndex+1:], ".")
|
||||
if len(parts) != 2 {
|
||||
return ErrInvalidIconURL.Withf("expect @%s/<reference>.<format>, e.g. @%s/adguard-home.webp", beforeSlash, beforeSlash)
|
||||
}
|
||||
reference, format := parts[0], strings.ToLower(parts[1])
|
||||
if reference == "" || format == "" {
|
||||
return ErrInvalidIconURL
|
||||
}
|
||||
switch format {
|
||||
case "svg", "png", "webp":
|
||||
default:
|
||||
return ErrInvalidIconURL.Withf("%s", "invalid image format, expect svg/png/webp")
|
||||
}
|
||||
isLight, isDark := false, false
|
||||
if strings.HasSuffix(reference, "-light") {
|
||||
isLight = true
|
||||
reference = strings.TrimSuffix(reference, "-light")
|
||||
} else if strings.HasSuffix(reference, "-dark") {
|
||||
isDark = true
|
||||
reference = strings.TrimSuffix(reference, "-dark")
|
||||
}
|
||||
u.Extra = &Extra{
|
||||
Key: NewKey(u.Source, reference),
|
||||
FileType: format,
|
||||
Ref: reference,
|
||||
IsLight: isLight,
|
||||
IsDark: isDark,
|
||||
}
|
||||
if checkExists && !u.HasIcon() {
|
||||
return ErrInvalidIconURL.Withf("no such icon %s.%s from %s", reference, format, u.Source)
|
||||
}
|
||||
default:
|
||||
return ErrInvalidIconURL.Subject(v)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *URL) URL() string {
|
||||
if u.FullURL != nil {
|
||||
return *u.FullURL
|
||||
}
|
||||
if u.Extra == nil {
|
||||
return ""
|
||||
}
|
||||
filename := u.Extra.Ref
|
||||
if u.Extra.IsLight {
|
||||
filename += "-light"
|
||||
} else if u.Extra.IsDark {
|
||||
filename += "-dark"
|
||||
}
|
||||
switch u.Source {
|
||||
case SourceWalkXCode:
|
||||
return fmt.Sprintf("https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/%s/%s.%s", u.Extra.FileType, filename, u.Extra.FileType)
|
||||
case SourceSelfhSt:
|
||||
return fmt.Sprintf("https://cdn.jsdelivr.net/gh/selfhst/icons/%s/%s.%s", u.Extra.FileType, filename, u.Extra.FileType)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (u *URL) String() string {
|
||||
if u.FullURL != nil {
|
||||
return *u.FullURL
|
||||
}
|
||||
if u.Extra == nil {
|
||||
return ""
|
||||
}
|
||||
var suffix string
|
||||
if u.Extra.IsLight {
|
||||
suffix = "-light"
|
||||
} else if u.Extra.IsDark {
|
||||
suffix = "-dark"
|
||||
}
|
||||
return fmt.Sprintf("%s/%s%s.%s", u.Source, u.Extra.Ref, suffix, u.Extra.FileType)
|
||||
}
|
||||
|
||||
func (u *URL) MarshalText() ([]byte, error) {
|
||||
return []byte(u.String()), nil
|
||||
}
|
||||
|
||||
// UnmarshalText implements encoding.TextUnmarshaler.
|
||||
func (u *URL) UnmarshalText(data []byte) error {
|
||||
return u.parse(string(data), false)
|
||||
}
|
||||
156
internal/homepage/icons/url_test.go
Normal file
156
internal/homepage/icons/url_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package icons_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/yusing/godoxy/internal/homepage/icons"
|
||||
expect "github.com/yusing/goutils/testing"
|
||||
)
|
||||
|
||||
func strPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func TestIconURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantValue *URL
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "absolute",
|
||||
input: "http://example.com/icon.png",
|
||||
wantValue: &URL{
|
||||
FullURL: strPtr("http://example.com/icon.png"),
|
||||
Source: SourceAbsolute,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "relative",
|
||||
input: "@target/icon.png",
|
||||
wantValue: &URL{
|
||||
FullURL: strPtr("/icon.png"),
|
||||
Source: SourceRelative,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "relative2",
|
||||
input: "/icon.png",
|
||||
wantValue: &URL{
|
||||
FullURL: strPtr("/icon.png"),
|
||||
Source: SourceRelative,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "relative_empty_path",
|
||||
input: "@target/",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "relative_empty_path2",
|
||||
input: "/",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "walkxcode",
|
||||
input: "@walkxcode/adguard-home.png",
|
||||
wantValue: &URL{
|
||||
Source: SourceWalkXCode,
|
||||
Extra: &Extra{
|
||||
Key: NewKey(SourceWalkXCode, "adguard-home"),
|
||||
FileType: "png",
|
||||
Ref: "adguard-home",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "walkxcode_light",
|
||||
input: "@walkxcode/pfsense-light.png",
|
||||
wantValue: &URL{
|
||||
Source: SourceWalkXCode,
|
||||
Extra: &Extra{
|
||||
Key: NewKey(SourceWalkXCode, "pfsense"),
|
||||
FileType: "png",
|
||||
Ref: "pfsense",
|
||||
IsLight: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "walkxcode_invalid_format",
|
||||
input: "foo/walkxcode.png",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "selfh.st_valid",
|
||||
input: "@selfhst/adguard-home.webp",
|
||||
wantValue: &URL{
|
||||
Source: SourceSelfhSt,
|
||||
Extra: &Extra{
|
||||
Key: NewKey(SourceSelfhSt, "adguard-home"),
|
||||
FileType: "webp",
|
||||
Ref: "adguard-home",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "selfh.st_light",
|
||||
input: "@selfhst/adguard-home-light.png",
|
||||
wantValue: &URL{
|
||||
Source: SourceSelfhSt,
|
||||
Extra: &Extra{
|
||||
Key: NewKey(SourceSelfhSt, "adguard-home"),
|
||||
FileType: "png",
|
||||
Ref: "adguard-home",
|
||||
IsLight: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "selfh.st_dark",
|
||||
input: "@selfhst/adguard-home-dark.svg",
|
||||
wantValue: &URL{
|
||||
Source: SourceSelfhSt,
|
||||
Extra: &Extra{
|
||||
Key: NewKey(SourceSelfhSt, "adguard-home"),
|
||||
FileType: "svg",
|
||||
Ref: "adguard-home",
|
||||
IsDark: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "selfh.st_invalid",
|
||||
input: "@selfhst/foo",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "selfh.st_invalid_format",
|
||||
input: "@selfhst/foo.bar",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid",
|
||||
input: "invalid",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
input: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
u := &URL{}
|
||||
err := u.Parse(tc.input)
|
||||
if tc.wantErr {
|
||||
expect.ErrorIs(t, ErrInvalidIconURL, err)
|
||||
} else {
|
||||
expect.NoError(t, err)
|
||||
expect.Equal(t, u, tc.wantValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user