diff --git a/README.md b/README.md index 82f6dcb..9d41815 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,10 @@ or another script without relying on its filename. ## providers -the default GetFonts and registry providers work without an account. GitHub's +the default GetFonts, Fontshare, and registry providers work without an +account. Font Squirrel is available through `--provider fontsquirrel`, but is +off by default because its site protection can block API clients. Both catalog +providers inspect official download archives for the formats you requested. GitHub's repository, tree, and release search also uses its small unauthenticated allowance. Moji automatically uses an existing authenticated `gh` session when GitHub CLI is installed. `GITHUB_TOKEN` and `github_token` take precedence and @@ -161,6 +164,10 @@ providers: enabled: true getfonts: enabled: true + fontsquirrel: + enabled: false + fontshare: + enabled: true registry: enabled: true websearch: diff --git a/docs/content/docs/how-to/search-and-filter.mdx b/docs/content/docs/how-to/search-and-filter.mdx index 36ad670..97c58cd 100644 --- a/docs/content/docs/how-to/search-and-filter.mdx +++ b/docs/content/docs/how-to/search-and-filter.mdx @@ -39,6 +39,7 @@ Use a comma-separated provider list: ```sh moji "Futura" --provider getfonts moji "Futura" --provider github,getfonts +moji "Futura" --provider fontsquirrel,fontshare moji "Futura" --provider registry,websearch ``` diff --git a/docs/content/docs/reference/cli.mdx b/docs/content/docs/reference/cli.mdx index 2b1b838..0e02349 100644 --- a/docs/content/docs/reference/cli.mdx +++ b/docs/content/docs/reference/cli.mdx @@ -147,7 +147,7 @@ Delete cached provider responses and recreate the cache directory. | `--format ` | `-f` | config default | Comma-separated `otf`, `ttf`, `woff`, `woff2`, `dfont`, `pfb`, or `pfm` | | `--weight ` | `-w` | any | Keep only a normalized font weight | | `--max ` | `-n` | all in TUI / 10 output / 1 get | Maximum results or downloads | -| `--provider ` | | all enabled | Comma-separated `github`, `getfonts`, `registry`, `plugins`, or `websearch` | +| `--provider ` | | all enabled | Comma-separated `github`, `getfonts`, `fontsquirrel`, `fontshare`, `registry`, `plugins`, or `websearch` | | `--json` | | off | Print machine-readable JSON | | `--dry-run` | | off | Preview a `get` selection without downloading | | `--download-dir ` | `-d` | `~/Downloads/moji` | Download destination | diff --git a/docs/content/docs/reference/configuration.mdx b/docs/content/docs/reference/configuration.mdx index e50f335..0dc7244 100644 --- a/docs/content/docs/reference/configuration.mdx +++ b/docs/content/docs/reference/configuration.mdx @@ -36,6 +36,12 @@ rate_limits: getfonts: timeout_seconds: 15 retries: 1 + fontsquirrel: + timeout_seconds: 15 + retries: 1 + fontshare: + timeout_seconds: 15 + retries: 1 registry: timeout_seconds: 15 retries: 1 @@ -51,6 +57,12 @@ providers: enabled: true getfonts: enabled: true + fontsquirrel: + enabled: false + instance: "" + fontshare: + enabled: true + instance: "" registry: enabled: true plugins: @@ -74,6 +86,9 @@ providers: | `rate_limits` | mapping | shown above | Per-provider timeout and retry count | | `providers` | mapping | shown above | Provider enablement and optional instance | +`providers.fontsquirrel.instance` and `providers.fontshare.instance` can replace +the official API base URL. Leave them empty for the public catalogs. + ## Environment variables | Variable | Meaning | diff --git a/docs/content/docs/reference/providers.mdx b/docs/content/docs/reference/providers.mdx index 3bf41dc..c45d486 100644 --- a/docs/content/docs/reference/providers.mdx +++ b/docs/content/docs/reference/providers.mdx @@ -14,6 +14,34 @@ description: Availability, authentication, trust, and fallback behavior. GetFonts is the zero-configuration default. +## Font Squirrel + +- Name: `fontsquirrel` +- Default: disabled because site protection can challenge API clients +- Authentication: none +- Endpoint: Font Squirrel's JSON family catalog and official ZIP downloads +- Result trust: untrusted download bytes +- License: `unknown`; check the license file included with the family + +Moji matches the query against the catalog, inspects up to three closest family +archives, and emits only members in the requested formats. Archive paths, entry +counts, and expanded sizes pass through the same fixed safety limits as web +search downloads. Select it explicitly with `--provider fontsquirrel` or enable +it in the config. A site challenge is reported as a blocked provider. + +## Fontshare + +- Name: `fontshare` +- Default: enabled +- Authentication: none +- Endpoint: Fontshare's v2 catalog and official family ZIP downloads +- Result trust: untrusted download bytes +- License: the catalog's `license_type` value when present, otherwise `unknown` + +Fontshare uses the same bounded family matching and archive inspection path as +Font Squirrel. A catalog license code identifies the agreement to check; it is +not a substitute for reading that agreement before redistribution. + ## GitHub - Name: `github` diff --git a/e2e/cli_test.go b/e2e/cli_test.go index 0fc2cdc..2bfb021 100644 --- a/e2e/cli_test.go +++ b/e2e/cli_test.go @@ -60,7 +60,7 @@ func TestMojiBinaryEndToEnd(t *testing.T) { configPath := filepath.Join(root, "config.yaml") downloadDirectory := filepath.Join(root, "downloads") - configBody := fmt.Sprintf("download_dir: %s\nsearch_timeout_seconds: 2\ncache_ttl_seconds: 60\ndefault_formats: [otf]\nproviders:\n github:\n enabled: false\n getfonts:\n enabled: true\n instance: %s\n registry:\n enabled: false\n websearch:\n enabled: false\n", downloadDirectory, server.URL+"/api/search") + configBody := fmt.Sprintf("download_dir: %s\nsearch_timeout_seconds: 2\ncache_ttl_seconds: 60\ndefault_formats: [otf]\nproviders:\n github:\n enabled: false\n getfonts:\n enabled: true\n instance: %s\n fontsquirrel:\n enabled: false\n fontshare:\n enabled: false\n registry:\n enabled: false\n websearch:\n enabled: false\n", downloadDirectory, server.URL+"/api/search") if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { t.Fatal(err) } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 85e9b10..c178661 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -104,7 +104,7 @@ func TestRunSearchJSONAndGetDownload(t *testing.T) { root := t.TempDir() configPath := filepath.Join(root, "config.yaml") - configBody := fmt.Sprintf("download_dir: %s\nsearch_timeout_seconds: 2\ncache_ttl_seconds: 60\ndefault_formats: [otf]\nproviders:\n github:\n enabled: false\n getfonts:\n enabled: true\n instance: %s\n registry:\n enabled: false\n websearch:\n enabled: false\n", filepath.Join(root, "fonts"), server.URL) + configBody := fmt.Sprintf("download_dir: %s\nsearch_timeout_seconds: 2\ncache_ttl_seconds: 60\ndefault_formats: [otf]\nproviders:\n github:\n enabled: false\n getfonts:\n enabled: true\n instance: %s\n fontsquirrel:\n enabled: false\n fontshare:\n enabled: false\n registry:\n enabled: false\n websearch:\n enabled: false\n", filepath.Join(root, "fonts"), server.URL) if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { t.Fatal(err) } @@ -187,7 +187,7 @@ func TestRunGetKeepsCandidatesBeyondRequestedMaximumForFallback(t *testing.T) { defer server.Close() root := t.TempDir() configPath := filepath.Join(root, "config.yaml") - configBody := fmt.Sprintf("download_dir: %s\nsearch_timeout_seconds: 2\ncache_ttl_seconds: 60\ndefault_formats: [otf, ttf]\nproviders:\n github:\n enabled: false\n getfonts:\n enabled: true\n instance: %s\n registry:\n enabled: false\n websearch:\n enabled: false\n", filepath.Join(root, "fonts"), server.URL) + configBody := fmt.Sprintf("download_dir: %s\nsearch_timeout_seconds: 2\ncache_ttl_seconds: 60\ndefault_formats: [otf, ttf]\nproviders:\n github:\n enabled: false\n getfonts:\n enabled: true\n instance: %s\n fontsquirrel:\n enabled: false\n fontshare:\n enabled: false\n registry:\n enabled: false\n websearch:\n enabled: false\n", filepath.Join(root, "fonts"), server.URL) if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { t.Fatal(err) } @@ -337,6 +337,13 @@ func TestRunRejectsUnsupportedProviderAndBadUsage(t *testing.T) { } } +func TestArchiveCatalogProviderNamesAreAccepted(t *testing.T) { + t.Parallel() + if err := validateProviderNames("fontsquirrel,fontshare"); err != nil { + t.Fatal(err) + } +} + func TestHelpUsesPlainProductDescription(t *testing.T) { t.Parallel() var stdout bytes.Buffer diff --git a/internal/app/output.go b/internal/app/output.go index 4a89ff7..875e538 100644 --- a/internal/app/output.go +++ b/internal/app/output.go @@ -109,7 +109,7 @@ Flags: -f, --format otf, ttf, woff, woff2, dfont, pfb, pfm -w, --weight Filter by font weight -n, --max Maximum results or downloads ---provider github,getfonts,registry,plugins,websearch + --provider github,getfonts,fontsquirrel,fontshare,registry,plugins,websearch --json Machine-readable output --dry-run Preview get downloads -d, --download-dir Download destination diff --git a/internal/app/search.go b/internal/app/search.go index 5bddb0e..5243995 100644 --- a/internal/app/search.go +++ b/internal/app/search.go @@ -103,7 +103,9 @@ func (application App) searchEvents(ctx context.Context, current config.Config, store := cache.Store{Directory: cacheDirectory, TTL: time.Duration(current.CacheTTLSeconds) * time.Second} invalidURLs, _ := store.InvalidURLs() available := map[string]provider.Provider{ - "getfonts": provider.GetFonts{Client: application.Client, Endpoint: current.Providers["getfonts"].Instance}, + "getfonts": provider.GetFonts{Client: application.Client, Endpoint: current.Providers["getfonts"].Instance}, + "fontsquirrel": provider.FontSquirrel{Client: application.Client, Endpoint: current.Providers["fontsquirrel"].Instance}, + "fontshare": provider.Fontshare{Client: application.Client, Endpoint: current.Providers["fontshare"].Instance}, "registry": provider.RegistrySearch{ Fontsource: provider.Fontsource{Client: application.Client}, GoogleFonts: provider.GoogleFonts{Client: application.Client}, @@ -242,8 +244,8 @@ func validateProviderNames(value string) error { } for _, raw := range strings.Split(value, ",") { name := strings.TrimSpace(strings.ToLower(raw)) - if name != "github" && name != "getfonts" && name != "registry" && name != "plugins" && name != "websearch" { - return fmt.Errorf("unknown provider %q (choose github, getfonts, registry, plugins, or websearch)", name) + if name != "github" && name != "getfonts" && name != "fontsquirrel" && name != "fontshare" && name != "registry" && name != "plugins" && name != "websearch" { + return fmt.Errorf("unknown provider %q (choose github, getfonts, fontsquirrel, fontshare, registry, plugins, or websearch)", name) } } return nil diff --git a/internal/cache/cache.go b/internal/cache/cache.go index a7284eb..a573a00 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -53,13 +53,37 @@ type CachedProvider struct { Bypass bool } +type cacheNamespacer interface { + CacheNamespace() string +} + +type cacheQueryProvider interface { + CacheQuery(string) string +} + func (cached CachedProvider) Name() string { return cached.Source.Name() } func (cached CachedProvider) Search(ctx context.Context, query string, formats []string, out chan<- provider.Event) error { + if err := ctx.Err(); err != nil { + return err + } + namespace := cached.Name() + if source, ok := cached.Source.(cacheNamespacer); ok { + namespace = source.CacheNamespace() + } + cacheQuery := query + if source, ok := cached.Source.(cacheQueryProvider); ok { + cacheQuery = source.CacheQuery(query) + } if !cached.Bypass { - results, hit, err := cached.Store.Get(query, cached.Name(), formats) + results, hit, err := cached.Store.Get(cacheQuery, namespace, formats) if err == nil && hit { for _, result := range results { - out <- provider.Event{Type: provider.EventResult, Result: result} + if !forwardEvent(ctx, out, provider.Event{Type: provider.EventResult, Result: result}) { + return ctx.Err() + } + } + if err := ctx.Err(); err != nil { + return err } return nil } @@ -68,24 +92,50 @@ func (cached CachedProvider) Search(ctx context.Context, query string, formats [ events := make(chan provider.Event) done := make(chan error, 1) go func() { done <- cached.Source.Search(ctx, query, formats, events) }() + ctxDone := ctx.Done() + canceled := false for { select { case event := <-events: + if canceled { + // Keep draining until the source returns so an in-flight send cannot + // strand the provider goroutine after downstream cancellation. + continue + } if event.Type == provider.EventResult { captured = append(captured, event.Result) } - out <- event + if !forwardEvent(ctx, out, event) { + canceled = true + ctxDone = nil + } case err := <-done: + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } if err == nil && !cached.Bypass { - _ = cached.Store.Put(query, cached.Name(), formats, captured) + _ = cached.Store.Put(cacheQuery, namespace, formats, captured) } return err - case <-ctx.Done(): - return ctx.Err() + case <-ctxDone: + canceled = true + ctxDone = nil } } } +func forwardEvent(ctx context.Context, out chan<- provider.Event, event provider.Event) bool { + if ctx.Err() != nil { + return false + } + select { + case out <- event: + return true + case <-ctx.Done(): + return false + } +} + func DefaultDirectory() (string, error) { if root := os.Getenv("XDG_CACHE_HOME"); root != "" { return filepath.Join(root, "moji"), nil diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index 6ffa632..e4d7030 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -7,6 +7,7 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" "time" @@ -20,6 +21,37 @@ func (skippedProvider) Search(context.Context, string, []string, chan<- provider return provider.ErrSearchSkipped } +type forwardingProvider struct { + forwarded chan struct{} + finished chan struct{} +} + +func (forwardingProvider) Name() string { return "forwarding" } +func (source forwardingProvider) Search(_ context.Context, _ string, _ []string, out chan<- provider.Event) error { + out <- provider.Event{Type: provider.EventResult, Result: provider.Result{Filename: "Example-Regular.otf"}} + close(source.forwarded) + out <- provider.Event{Type: provider.EventResult, Result: provider.Result{Filename: "Example-Bold.otf"}} + close(source.finished) + return nil +} + +type namespacedProvider struct { + namespace string + filename string + calls *int +} + +func (source namespacedProvider) Name() string { return "namespaced" } +func (source namespacedProvider) CacheNamespace() string { return source.namespace } +func (source namespacedProvider) CacheQuery(query string) string { + return strings.ReplaceAll(query, " ", "") +} +func (source namespacedProvider) Search(_ context.Context, _ string, _ []string, out chan<- provider.Event) error { + (*source.calls)++ + out <- provider.Event{Type: provider.EventResult, Result: provider.Result{Filename: source.filename}} + return nil +} + func TestCachedProviderDoesNotCacheSkippedSearch(t *testing.T) { t.Parallel() store := Store{Directory: t.TempDir(), TTL: time.Hour} @@ -34,6 +66,117 @@ func TestCachedProviderDoesNotCacheSkippedSearch(t *testing.T) { } } +func TestCachedProviderCancelsBlockedCacheHitForwarding(t *testing.T) { + t.Parallel() + store := Store{Directory: t.TempDir(), TTL: time.Hour} + if err := store.Put("Example", "skipped", []string{"otf"}, []provider.Result{{Filename: "Example.otf"}}); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- (CachedProvider{Source: skippedProvider{}, Store: store}).Search( + ctx, "Example", []string{"otf"}, make(chan provider.Event), + ) + }() + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want context.Canceled", err) + } + case <-time.After(time.Second): + t.Fatal("cache-hit forwarding did not stop after cancellation") + } +} + +func TestCachedProviderReturnsPreCanceledContextForEmptyCacheHit(t *testing.T) { + t.Parallel() + store := Store{Directory: t.TempDir(), TTL: time.Hour} + if err := store.Put("Example", "skipped", []string{"otf"}, nil); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := (CachedProvider{Source: skippedProvider{}, Store: store}).Search( + ctx, "Example", []string{"otf"}, make(chan provider.Event, 1), + ) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want context.Canceled", err) + } +} + +func TestCachedProviderCancelsBlockedLiveForwarding(t *testing.T) { + t.Parallel() + forwarded := make(chan struct{}) + finished := make(chan struct{}) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- (CachedProvider{Source: forwardingProvider{forwarded: forwarded, finished: finished}, Bypass: true}).Search( + ctx, "Example", []string{"otf"}, make(chan provider.Event), + ) + }() + <-forwarded + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want context.Canceled", err) + } + case <-time.After(time.Second): + t.Fatal("live forwarding did not stop after cancellation") + } + select { + case <-finished: + case <-time.After(time.Second): + t.Fatal("live forwarding stranded the source provider") + } +} + +func TestCachedProviderSeparatesProviderNamespaces(t *testing.T) { + t.Parallel() + store := Store{Directory: t.TempDir(), TTL: time.Hour} + firstCalls, secondCalls := 0, 0 + for _, test := range []struct { + source namespacedProvider + want string + }{ + {source: namespacedProvider{namespace: "namespaced:first", filename: "First.otf", calls: &firstCalls}, want: "First.otf"}, + {source: namespacedProvider{namespace: "namespaced:second", filename: "Second.otf", calls: &secondCalls}, want: "Second.otf"}, + } { + out := make(chan provider.Event, 1) + if err := (CachedProvider{Source: test.source, Store: store}).Search(context.Background(), "Example", []string{"otf"}, out); err != nil { + t.Fatal(err) + } + if got := (<-out).Result.Filename; got != test.want { + t.Fatalf("filename = %q, want %q", got, test.want) + } + } + if firstCalls != 1 || secondCalls != 1 { + t.Fatalf("provider calls = %d, %d; want 1, 1", firstCalls, secondCalls) + } +} + +func TestCachedProviderUsesCanonicalProviderQuery(t *testing.T) { + t.Parallel() + store := Store{Directory: t.TempDir(), TTL: time.Hour} + calls := 0 + source := namespacedProvider{namespace: "namespaced:one", filename: "Example.otf", calls: &calls} + for _, query := range []string{"Example Font", "ExampleFont"} { + out := make(chan provider.Event, 1) + if err := (CachedProvider{Source: source, Store: store}).Search(context.Background(), query, []string{"otf"}, out); err != nil { + t.Fatal(err) + } + if got := (<-out).Result.Filename; got != "Example.otf" { + t.Fatalf("filename = %q, want Example.otf", got) + } + } + if calls != 1 { + t.Fatalf("provider calls = %d, want 1", calls) + } +} + func TestStoreHonorsTTLAndFormatIndependentOrder(t *testing.T) { t.Parallel() now := time.Unix(1000, 0) diff --git a/internal/config/config.go b/internal/config/config.go index e2ade5c..3ce850c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -42,15 +42,18 @@ func Default() Config { SearchTimeoutSeconds: 15, CacheTTLSeconds: 3600, DefaultFormats: []string{"otf", "ttf", "woff2", "dfont", "pfb", "pfm"}, Ranking: rank.DefaultWeights(), RateLimits: map[string]RateLimitConfig{ - "github": {TimeoutSeconds: 15, Retries: 2}, - "getfonts": {TimeoutSeconds: 15, Retries: 1}, - "registry": {TimeoutSeconds: 15, Retries: 1}, - "plugins": {TimeoutSeconds: 20, Retries: 0}, - "websearch": {TimeoutSeconds: 20, Retries: 0}, + "github": {TimeoutSeconds: 15, Retries: 2}, + "getfonts": {TimeoutSeconds: 15, Retries: 1}, + "fontsquirrel": {TimeoutSeconds: 15, Retries: 1}, + "fontshare": {TimeoutSeconds: 15, Retries: 1}, + "registry": {TimeoutSeconds: 15, Retries: 1}, + "plugins": {TimeoutSeconds: 20, Retries: 0}, + "websearch": {TimeoutSeconds: 20, Retries: 0}, }, Providers: map[string]ProviderConfig{ - "github": {Enabled: true}, "getfonts": {Enabled: true}, - "registry": {Enabled: true}, "plugins": {Enabled: true}, "websearch": {Enabled: true}, + "github": {Enabled: true}, "getfonts": {Enabled: true}, "fontsquirrel": {Enabled: false}, + "fontshare": {Enabled: true}, "registry": {Enabled: true}, "plugins": {Enabled: true}, + "websearch": {Enabled: true}, }, } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5f05105..b531020 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -50,6 +50,22 @@ func TestParseFormats(t *testing.T) { } } +func TestDefaultConfiguresArchiveCatalogProviders(t *testing.T) { + t.Parallel() + current := Default() + if current.Providers["fontsquirrel"].Enabled { + t.Fatal("Font Squirrel should require explicit selection because its site protection can block API clients") + } + if !current.Providers["fontshare"].Enabled { + t.Fatal("Fontshare is not enabled") + } + for _, name := range []string{"fontsquirrel", "fontshare"} { + if current.RateLimits[name].TimeoutSeconds <= 0 { + t.Fatalf("provider %q has no timeout policy", name) + } + } +} + func TestEnvironmentTokenPrecedesConfig(t *testing.T) { t.Setenv("GITHUB_TOKEN", "environment") config := Default() diff --git a/internal/provider/catalogs.go b/internal/provider/catalogs.go new file mode 100644 index 0000000..a53ecf8 --- /dev/null +++ b/internal/provider/catalogs.go @@ -0,0 +1,233 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "unicode" +) + +const ( + maxArchiveCatalogSize = 8 << 20 + maxArchiveCatalogMatches = 3 + fontSquirrelEndpoint = "https://www.fontsquirrel.com" + fontshareEndpoint = "https://api.fontshare.com" +) + +type FontSquirrel struct { + Client *http.Client + Endpoint string +} + +func (FontSquirrel) Name() string { return "fontsquirrel" } + +func (source FontSquirrel) baseURL() string { + if endpoint := strings.TrimRight(source.Endpoint, "/"); endpoint != "" { + return endpoint + } + return fontSquirrelEndpoint +} + +func (source FontSquirrel) CacheNamespace() string { + return source.Name() + "\x00" + source.baseURL() +} + +func (FontSquirrel) CacheQuery(query string) string { + return archiveCatalogKey(query) +} + +func (source FontSquirrel) Search(ctx context.Context, query string, formats []string, out chan<- Event) error { + client := source.Client + if client == nil { + client = http.DefaultClient + } + baseURL := source.baseURL() + var payload []struct { + Name string `json:"family_name"` + Slug string `json:"family_urlname"` + } + if err := fetchArchiveCatalog(ctx, client, baseURL+"/api/fontlist/all", &payload); err != nil { + return fmt.Errorf("Font Squirrel catalog: %w", err) + } + entries := make([]archiveCatalogEntry, 0, len(payload)) + for _, family := range payload { + entries = append(entries, archiveCatalogEntry{Name: family.Name, Slug: family.Slug, License: "unknown"}) + } + return searchArchiveCatalog(ctx, client, query, formats, entries, func(slug string) string { + return baseURL + "/fonts/download/" + url.PathEscape(slug) + }, out) +} + +type Fontshare struct { + Client *http.Client + Endpoint string +} + +func (Fontshare) Name() string { return "fontshare" } + +func (source Fontshare) baseURL() string { + if endpoint := strings.TrimRight(source.Endpoint, "/"); endpoint != "" { + return endpoint + } + return fontshareEndpoint +} + +func (source Fontshare) CacheNamespace() string { + return source.Name() + "\x00" + source.baseURL() +} + +func (Fontshare) CacheQuery(query string) string { + return archiveCatalogKey(query) +} + +func (source Fontshare) Search(ctx context.Context, query string, formats []string, out chan<- Event) error { + client := source.Client + if client == nil { + client = http.DefaultClient + } + baseURL := source.baseURL() + parameters := url.Values{"limit": {"1000"}, "offset": {"0"}, "order_by": {"popularity"}} + var payload struct { + Fonts []struct { + Name string `json:"name"` + Slug string `json:"slug"` + License string `json:"license_type"` + } `json:"fonts"` + } + if err := fetchArchiveCatalog(ctx, client, baseURL+"/v2/fonts?"+parameters.Encode(), &payload); err != nil { + return fmt.Errorf("Fontshare catalog: %w", err) + } + entries := make([]archiveCatalogEntry, 0, len(payload.Fonts)) + for _, family := range payload.Fonts { + entries = append(entries, archiveCatalogEntry{Name: family.Name, Slug: family.Slug, License: family.License}) + } + return searchArchiveCatalog(ctx, client, query, formats, entries, func(slug string) string { + return baseURL + "/v2/fonts/download/" + url.PathEscape(slug) + }, out) +} + +type archiveCatalogEntry struct { + Name string + Slug string + License string +} + +func fetchArchiveCatalog(ctx context.Context, client *http.Client, endpoint string, target any) error { + client = constrainedDiscoveryClient(ctx, client) + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return err + } + request.Header.Set("User-Agent", "moji-font-finder") + response, err := client.Do(request) + if err != nil { + return fmt.Errorf("%w: %v", ErrUnavailable, err) + } + defer response.Body.Close() + if strings.EqualFold(response.Header.Get("X-Amzn-Waf-Action"), "challenge") { + return fmt.Errorf("%w: site challenge", ErrBlocked) + } + switch response.StatusCode { + case http.StatusOK: + default: + return providerHTTPStatusError(response.StatusCode) + } + content, err := io.ReadAll(io.LimitReader(response.Body, maxArchiveCatalogSize+1)) + if err != nil { + return fmt.Errorf("%w: read catalog: %v", ErrBadResponse, err) + } + if len(content) > maxArchiveCatalogSize { + return fmt.Errorf("%w: catalog exceeds %d bytes", ErrBadResponse, maxArchiveCatalogSize) + } + if err := json.Unmarshal(content, target); err != nil { + return fmt.Errorf("%w: decode catalog: %v", ErrBadResponse, err) + } + return nil +} + +func searchArchiveCatalog(ctx context.Context, client *http.Client, query string, formats []string, entries []archiveCatalogEntry, downloadURL func(string) string, out chan<- Event) error { + allowed := formatSet(formats) + var firstErr error + emitted := 0 + for _, entry := range archiveCatalogMatches(entries, query) { + results, err := resolveDiscoveredURL(ctx, client, downloadURL(entry.Slug), entry.Name, allowed) + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + for _, result := range results { + result.License = entry.License + if result.License == "" { + result.License = "unknown" + } + select { + case out <- Event{Type: EventResult, Result: result}: + case <-ctx.Done(): + return ctx.Err() + } + emitted++ + } + } + if emitted == 0 && firstErr != nil { + return firstErr + } + return nil +} + +func archiveCatalogMatches(entries []archiveCatalogEntry, query string) []archiveCatalogEntry { + queryKey := archiveCatalogKey(query) + if queryKey == "" { + return nil + } + type match struct { + entry archiveCatalogEntry + rank int + } + matches := make([]match, 0) + for _, entry := range entries { + nameKey := archiveCatalogKey(entry.Name) + rank := 2 + switch { + case nameKey == "" || !strings.Contains(nameKey, queryKey): + continue + case nameKey == queryKey: + rank = 0 + case strings.HasPrefix(nameKey, queryKey): + rank = 1 + } + matches = append(matches, match{entry: entry, rank: rank}) + } + sort.SliceStable(matches, func(left, right int) bool { + if matches[left].rank != matches[right].rank { + return matches[left].rank < matches[right].rank + } + return len(matches[left].entry.Name) < len(matches[right].entry.Name) + }) + if len(matches) > 0 && matches[0].rank == 0 { + matches = matches[:1] + } else if len(matches) > maxArchiveCatalogMatches { + matches = matches[:maxArchiveCatalogMatches] + } + result := make([]archiveCatalogEntry, len(matches)) + for index, candidate := range matches { + result[index] = candidate.entry + } + return result +} + +func archiveCatalogKey(value string) string { + var normalized strings.Builder + for _, character := range strings.ToLower(value) { + if unicode.IsLetter(character) || unicode.IsNumber(character) { + normalized.WriteRune(character) + } + } + return normalized.String() +} diff --git a/internal/provider/catalogs_test.go b/internal/provider/catalogs_test.go new file mode 100644 index 0000000..cc47c1f --- /dev/null +++ b/internal/provider/catalogs_test.go @@ -0,0 +1,280 @@ +package provider + +import ( + "archive/zip" + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestFontSquirrelSearchResolvesMatchingArchiveMembers(t *testing.T) { + t.Parallel() + archive := catalogTestZIP(t, map[string]string{ + "Amatic_SC/AmaticSC-Regular.ttf": "\x00\x01\x00\x00font", + "Amatic_SC/AmaticSC-Bold.otf": "OTTOfont", + "Amatic_SC/readme.txt": "license details", + }) + server := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/api/fontlist/all": + response.Write([]byte(`[ + {"family_name":"Amatic SC","family_urlname":"amatic-sc"}, + {"family_name":"Amatic SC Pro","family_urlname":"amatic-sc-pro"}, + {"family_name":"Unrelated","family_urlname":"unrelated"} + ]`)) + case "/fonts/download/amatic-sc": + response.Header().Set("Content-Type", "application/octet-stream") + response.Write(archive) + default: + http.NotFound(response, request) + } + })) + defer server.Close() + + out := make(chan Event, 4) + err := (FontSquirrel{Client: server.Client(), Endpoint: server.URL}).Search( + localDiscoveryContext(), "Amatic SC", []string{"ttf"}, out, + ) + if err != nil { + t.Fatal(err) + } + if len(out) != 1 { + t.Fatalf("event count = %d, want 1", len(out)) + } + got := (<-out).Result + if got.Name != "Amatic SC" || got.Filename != "AmaticSC-Regular.ttf" || got.Format != "ttf" { + t.Fatalf("result = %#v", got) + } + if got.ArchiveMember != "Amatic_SC/AmaticSC-Regular.ttf" || got.ArchiveFormat != "zip" { + t.Fatalf("archive result = %#v", got) + } + if got.Source != strings.TrimPrefix(server.URL, "https://") || got.Trusted || got.License != "unknown" { + t.Fatalf("provenance result = %#v", got) + } +} + +func TestFontshareSearchPreservesCatalogLicenseAndFormats(t *testing.T) { + t.Parallel() + archive := catalogTestZIP(t, map[string]string{ + "Satoshi/OTF/Satoshi-Regular.otf": "OTTOfont", + "Satoshi/WEB/fonts/Satoshi.woff2": "wOF2font", + "Satoshi/TTF/Satoshi-Regular.ttf": "\x00\x01\x00\x00font", + }) + server := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/v2/fonts": + if request.URL.Query().Get("limit") == "" || request.URL.Query().Get("order_by") != "popularity" { + t.Fatalf("catalog query = %q", request.URL.RawQuery) + } + response.Write([]byte(`{"fonts":[ + {"name":"Satoshi","slug":"satoshi","license_type":"itf_ffl"}, + {"name":"General Sans","slug":"general-sans","license_type":"fontshare_ffl"} + ]}`)) + case "/v2/fonts/download/satoshi": + response.Header().Set("Content-Type", "application/zip") + response.Write(archive) + default: + http.NotFound(response, request) + } + })) + defer server.Close() + + out := make(chan Event, 4) + err := (Fontshare{Client: server.Client(), Endpoint: server.URL}).Search( + localDiscoveryContext(), "Satoshi", []string{"otf", "woff2"}, out, + ) + if err != nil { + t.Fatal(err) + } + if len(out) != 2 { + t.Fatalf("event count = %d, want 2", len(out)) + } + for len(out) > 0 { + got := (<-out).Result + if got.Name != "Satoshi" || got.Source != strings.TrimPrefix(server.URL, "https://") || got.Trusted || got.License != "itf_ffl" { + t.Fatalf("result = %#v", got) + } + if got.Format != "otf" && got.Format != "woff2" { + t.Fatalf("unexpected format in result = %#v", got) + } + } +} + +func TestCatalogProvidersReportRateLimits(t *testing.T) { + t.Parallel() + server := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.WriteHeader(http.StatusTooManyRequests) + })) + defer server.Close() + + providers := []Provider{ + FontSquirrel{Client: server.Client(), Endpoint: server.URL}, + Fontshare{Client: server.Client(), Endpoint: server.URL}, + } + for _, source := range providers { + t.Run(source.Name(), func(t *testing.T) { + err := source.Search(localDiscoveryContext(), "Example", []string{"otf"}, make(chan Event, 1)) + if !errors.Is(err, ErrRateLimited) { + t.Fatalf("error = %v, want ErrRateLimited", err) + } + }) + } +} + +func TestCatalogProvidersReportSiteChallenge(t *testing.T) { + t.Parallel() + server := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("X-Amzn-Waf-Action", "challenge") + response.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + err := (FontSquirrel{Client: server.Client(), Endpoint: server.URL}).Search( + localDiscoveryContext(), "Example", []string{"otf"}, make(chan Event, 1), + ) + if !errors.Is(err, ErrBlocked) { + t.Fatalf("error = %v, want ErrBlocked", err) + } +} + +func TestCatalogProvidersReportArchiveHTTPFailures(t *testing.T) { + t.Parallel() + for name, test := range map[string]struct { + status int + want error + }{ + "blocked": {status: http.StatusForbidden, want: ErrBlocked}, + "rate limited": {status: http.StatusTooManyRequests, want: ErrRateLimited}, + "bad response": {status: http.StatusInternalServerError, want: ErrBadResponse}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + server := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/api/fontlist/all": + response.Write([]byte(`[{"family_name":"Example","family_urlname":"example"}]`)) + case "/fonts/download/example": + response.WriteHeader(test.status) + default: + http.NotFound(response, request) + } + })) + defer server.Close() + + err := (FontSquirrel{Client: server.Client(), Endpoint: server.URL}).Search( + localDiscoveryContext(), "Example", []string{"otf"}, make(chan Event, 1), + ) + if !errors.Is(err, test.want) { + t.Fatalf("error = %v, want %v", err, test.want) + } + }) + } +} + +func TestArchiveCatalogBlocksPrivateNetworkDestinations(t *testing.T) { + t.Parallel() + server := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.Write([]byte(`[]`)) + })) + defer server.Close() + err := (FontSquirrel{Client: server.Client(), Endpoint: server.URL}).Search( + context.Background(), "Example", []string{"otf"}, make(chan Event, 1), + ) + if err == nil || !strings.Contains(err.Error(), "non-public address") { + t.Fatalf("error = %v, want private-network rejection", err) + } +} + +func TestArchiveCatalogStopsBlockedSendOnCancellation(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + downloadStarted := make(chan struct{}) + done := make(chan error, 1) + go func() { + done <- searchArchiveCatalog(ctx, http.DefaultClient, "Example", []string{"otf"}, + []archiveCatalogEntry{{Name: "Example", Slug: "example", License: "test"}}, + func(string) string { + close(downloadStarted) + return "https://example.com/Example.otf" + }, make(chan Event)) + }() + <-downloadStarted + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want context.Canceled", err) + } +} + +func TestArchiveCatalogMatchesPreferExactFamily(t *testing.T) { + t.Parallel() + entries := []archiveCatalogEntry{ + {Name: "Amatic SC Pro", Slug: "amatic-sc-pro"}, + {Name: "Amatic SC", Slug: "amatic-sc"}, + {Name: "SC Amatic", Slug: "sc-amatic"}, + } + got := archiveCatalogMatches(entries, "Amatic SC") + if len(got) != 1 || got[0].Name != "Amatic SC" { + t.Fatalf("matches = %#v", got) + } +} + +func TestCatalogProviderCacheNamespacesIncludeEffectiveEndpoint(t *testing.T) { + t.Parallel() + for name, test := range map[string]struct { + defaultNamespace string + customNamespace string + }{ + "fontsquirrel": { + defaultNamespace: (FontSquirrel{}).CacheNamespace(), + customNamespace: (FontSquirrel{Endpoint: "https://fonts.example.test/"}).CacheNamespace(), + }, + "fontshare": { + defaultNamespace: (Fontshare{}).CacheNamespace(), + customNamespace: (Fontshare{Endpoint: "https://fonts.example.test/"}).CacheNamespace(), + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + if test.defaultNamespace == test.customNamespace { + t.Fatalf("default and custom cache namespaces are both %q", test.defaultNamespace) + } + if strings.HasSuffix(test.customNamespace, "/") { + t.Fatalf("custom cache namespace is not normalized: %q", test.customNamespace) + } + }) + } +} + +func TestCatalogProviderCacheQueriesDeduplicateAdaptiveSpellings(t *testing.T) { + t.Parallel() + for _, source := range []interface{ CacheQuery(string) string }{FontSquirrel{}, Fontshare{}} { + want := source.CacheQuery("Example Font") + for _, query := range []string{"ExampleFont", "Example-Font", "Example_Font"} { + if got := source.CacheQuery(query); got != want { + t.Fatalf("CacheQuery(%q) = %q, want %q", query, got, want) + } + } + } +} + +func catalogTestZIP(t *testing.T, files map[string]string) []byte { + t.Helper() + var archive bytes.Buffer + writer := zip.NewWriter(&archive) + for name, content := range files { + file, err := writer.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := file.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return archive.Bytes() +} diff --git a/internal/provider/discovery.go b/internal/provider/discovery.go index 635643a..9b55faf 100644 --- a/internal/provider/discovery.go +++ b/internal/provider/discovery.go @@ -135,21 +135,7 @@ func fetchDiscoveryContent(ctx context.Context, client *http.Client, rawURL stri } func fetchDiscoveryContentWithType(ctx context.Context, client *http.Client, rawURL string, maximum int64, requireBinary bool) ([]byte, string, error) { - allowPrivate, _ := ctx.Value(privateDiscoveryContextKey{}).(bool) - clientCopy := safehttp.Constrain(client, allowPrivate) - originalRedirect := clientCopy.CheckRedirect - clientCopy.CheckRedirect = func(request *http.Request, via []*http.Request) error { - if request.URL.Scheme != "https" { - return errors.New("discovery redirected to an insecure URL") - } - if len(via) >= 5 { - return errors.New("discovery redirected more than 5 times") - } - if originalRedirect != nil { - return originalRedirect(request, via) - } - return nil - } + clientCopy := constrainedDiscoveryClient(ctx, client) request, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return nil, "", err @@ -159,8 +145,11 @@ func fetchDiscoveryContentWithType(ctx context.Context, client *http.Client, raw return nil, "", fmt.Errorf("%w: discovery request: %v", ErrUnavailable, err) } defer response.Body.Close() + if strings.EqualFold(response.Header.Get("X-Amzn-Waf-Action"), "challenge") { + return nil, "", fmt.Errorf("%w: site challenge", ErrBlocked) + } if response.StatusCode < 200 || response.StatusCode >= 300 { - return nil, "", nil + return nil, "", providerHTTPStatusError(response.StatusCode) } if response.ContentLength > maximum { return nil, "", errors.New("discovery response exceeds the size limit") @@ -179,6 +168,36 @@ func fetchDiscoveryContentWithType(ctx context.Context, client *http.Client, raw return content, contentType, nil } +func providerHTTPStatusError(statusCode int) error { + switch statusCode { + case http.StatusTooManyRequests: + return fmt.Errorf("%w: HTTP %d", ErrRateLimited, statusCode) + case http.StatusForbidden: + return fmt.Errorf("%w: HTTP %d", ErrBlocked, statusCode) + default: + return fmt.Errorf("%w: HTTP %d", ErrBadResponse, statusCode) + } +} + +func constrainedDiscoveryClient(ctx context.Context, client *http.Client) *http.Client { + allowPrivate, _ := ctx.Value(privateDiscoveryContextKey{}).(bool) + clientCopy := safehttp.Constrain(client, allowPrivate) + originalRedirect := clientCopy.CheckRedirect + clientCopy.CheckRedirect = func(request *http.Request, via []*http.Request) error { + if request.URL.Scheme != "https" { + return errors.New("discovery redirected to an insecure URL") + } + if len(via) >= 5 { + return errors.New("discovery redirected more than 5 times") + } + if originalRedirect != nil { + return originalRedirect(request, via) + } + return nil + } + return &clientCopy +} + func pageContentType(value string) bool { if strings.TrimSpace(value) == "" { return false diff --git a/internal/provider/discovery_test.go b/internal/provider/discovery_test.go index 4707de5..c87b26e 100644 --- a/internal/provider/discovery_test.go +++ b/internal/provider/discovery_test.go @@ -4,6 +4,7 @@ import ( "archive/zip" "bytes" "context" + "errors" "net/http" "net/http/httptest" "strings" @@ -174,6 +175,19 @@ func TestDiscoveryRejectsInsecureRedirect(t *testing.T) { } } +func TestDiscoveryReportsSiteChallenge(t *testing.T) { + t.Parallel() + server := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("X-Amzn-Waf-Action", "challenge") + response.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + _, err := resolveDiscoveredURL(localDiscoveryContext(), server.Client(), server.URL+"/font.zip", "Example", map[string]bool{"otf": true}) + if !errors.Is(err, ErrBlocked) { + t.Fatalf("error = %v, want ErrBlocked", err) + } +} + func TestResolveDiscoveredURLConvertsGitHubBlobToRaw(t *testing.T) { results, err := resolveDiscoveredURL(context.Background(), nil, "https://github.com/acme/fonts/blob/main/Example.otf", "Example", map[string]bool{"otf": true}) if err != nil { diff --git a/internal/tui/config.go b/internal/tui/config.go index 2294ad4..943fdb9 100644 --- a/internal/tui/config.go +++ b/internal/tui/config.go @@ -35,7 +35,7 @@ type ConfigModel struct { } func NewConfigModel(current config.Config, path string, color bool) ConfigModel { - providers := []string{"github", "getfonts", "registry", "plugins", "websearch"} + providers := configurableProviderNames() fields := []configField{ {label: "Download directory", value: current.DownloadDir}, {label: "GitHub token", value: current.GitHubToken, secret: true}, @@ -46,7 +46,7 @@ func NewConfigModel(current config.Config, path string, color bool) ConfigModel for _, name := range providers { setting := current.Providers[name] fields = append(fields, configField{label: name + " enabled", value: strconv.FormatBool(setting.Enabled), boolean: true}) - if name == "github" || name == "getfonts" || name == "websearch" { + if providerHasInstance(name) { fields = append(fields, configField{label: name + " instance", value: setting.Instance}) } } @@ -165,11 +165,11 @@ func (model ConfigModel) config() (config.Config, error) { updated.DefaultFormats = formats index := 5 - for _, name := range []string{"github", "getfonts", "registry", "plugins", "websearch"} { + for _, name := range configurableProviderNames() { setting := updated.Providers[name] setting.Enabled = model.fields[index].value == "true" index++ - if name == "github" || name == "getfonts" || name == "websearch" { + if providerHasInstance(name) { setting.Instance = strings.TrimSpace(model.fields[index].value) index++ } @@ -197,7 +197,7 @@ func (model ConfigModel) config() (config.Config, error) { *destination = value index++ } - for _, name := range []string{"github", "getfonts", "registry", "plugins", "websearch"} { + for _, name := range configurableProviderNames() { timeout, err := positiveInt(model.fields[index].value, name+" timeout") if err != nil { return config.Config{}, err @@ -213,6 +213,14 @@ func (model ConfigModel) config() (config.Config, error) { return updated, nil } +func configurableProviderNames() []string { + return []string{"github", "getfonts", "fontsquirrel", "fontshare", "registry", "plugins", "websearch"} +} + +func providerHasInstance(name string) bool { + return name == "github" || name == "getfonts" || name == "fontsquirrel" || name == "fontshare" || name == "websearch" +} + func formatFloat(value float64) string { return strconv.FormatFloat(value, 'f', -1, 64) } func positiveInt(value, label string) (int, error) { diff --git a/internal/tui/config_test.go b/internal/tui/config_test.go index 60f85ee..d3bde5a 100644 --- a/internal/tui/config_test.go +++ b/internal/tui/config_test.go @@ -22,9 +22,12 @@ func TestConfigModelEditsTogglesAndSaves(t *testing.T) { model.cursor = 5 model = updateConfigModel(t, model, tea.KeyMsg{Type: tea.KeySpace}) - model.fields[14].value = "9" - model.fields[20].value = "21" - model.fields[21].value = "4" + model.fields[configFieldIndex(t, model, "fontsquirrel enabled")].value = "true" + model.fields[configFieldIndex(t, model, "fontsquirrel instance")].value = "https://fonts.example" + model.fields[configFieldIndex(t, model, "fontshare instance")].value = "https://fontshare.example" + model.fields[configFieldIndex(t, model, "Ranking: format")].value = "9" + model.fields[configFieldIndex(t, model, "github timeout")].value = "21" + model.fields[configFieldIndex(t, model, "github retries")].value = "4" model = updateConfigModel(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}}) if !model.saved { t.Fatalf("config was not saved: %s", model.status) @@ -35,6 +38,8 @@ func TestConfigModelEditsTogglesAndSaves(t *testing.T) { t.Fatal(err) } if saved.DownloadDir != "/tmp/fonts" || saved.Providers["github"].Enabled || saved.Ranking.Format != 9 || + !saved.Providers["fontsquirrel"].Enabled || saved.Providers["fontsquirrel"].Instance != "https://fonts.example" || + saved.Providers["fontshare"].Instance != "https://fontshare.example" || saved.RateLimits["github"].TimeoutSeconds != 21 || saved.RateLimits["github"].Retries != 4 { t.Fatalf("saved config = %#v", saved) } @@ -96,3 +101,14 @@ func updateConfigModel(t *testing.T, model ConfigModel, message tea.Msg) ConfigM updated, _ := model.Update(message) return updated.(ConfigModel) } + +func configFieldIndex(t *testing.T, model ConfigModel, label string) int { + t.Helper() + for index, field := range model.fields { + if field.label == label { + return index + } + } + t.Fatalf("config field %q was not found", label) + return -1 +} diff --git a/package-lock.json b/package-lock.json index a9fdff4..19d3721 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@microck/moji", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@microck/moji", - "version": "0.6.0", + "version": "0.7.0", "license": "MIT", "workspaces": [ "docs" diff --git a/package.json b/package.json index d4d130f..7de7923 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@microck/moji", - "version": "0.6.0", + "version": "0.7.0", "description": "A cozy terminal font finder with safe downloads and script-friendly output.", "type": "module", "bin": {