Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -161,6 +164,10 @@ providers:
enabled: true
getfonts:
enabled: true
fontsquirrel:
enabled: false
fontshare:
enabled: true
registry:
enabled: true
websearch:
Expand Down
1 change: 1 addition & 0 deletions docs/content/docs/how-to/search-and-filter.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ Delete cached provider responses and recreate the cache directory.
| `--format <list>` | `-f` | config default | Comma-separated `otf`, `ttf`, `woff`, `woff2`, `dfont`, `pfb`, or `pfm` |
| `--weight <name>` | `-w` | any | Keep only a normalized font weight |
| `--max <count>` | `-n` | all in TUI / 10 output / 1 get | Maximum results or downloads |
| `--provider <list>` | | all enabled | Comma-separated `github`, `getfonts`, `registry`, `plugins`, or `websearch` |
| `--provider <list>` | | 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 <path>` | `-d` | `~/Downloads/moji` | Download destination |
Expand Down
15 changes: 15 additions & 0 deletions docs/content/docs/reference/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -51,6 +57,12 @@ providers:
enabled: true
getfonts:
enabled: true
fontsquirrel:
enabled: false
instance: ""
fontshare:
enabled: true
instance: ""
registry:
enabled: true
plugins:
Expand All @@ -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 |
Expand Down
28 changes: 28 additions & 0 deletions docs/content/docs/reference/providers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion e2e/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
11 changes: 9 additions & 2 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/app/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ Flags:
-f, --format <list> otf, ttf, woff, woff2, dfont, pfb, pfm
-w, --weight <name> Filter by font weight
-n, --max <count> Maximum results or downloads
--provider <list> github,getfonts,registry,plugins,websearch
--provider <list> github,getfonts,fontsquirrel,fontshare,registry,plugins,websearch
--json Machine-readable output
--dry-run Preview get downloads
-d, --download-dir <path> Download destination
Expand Down
8 changes: 5 additions & 3 deletions internal/app/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Comment thread
Microck marked this conversation as resolved.
"registry": provider.RegistrySearch{
Fontsource: provider.Fontsource{Client: application.Client},
GoogleFonts: provider.GoogleFonts{Client: application.Client},
Expand Down Expand Up @@ -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
Expand Down
62 changes: 56 additions & 6 deletions internal/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
Microck marked this conversation as resolved.
}
Expand All @@ -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
Expand Down
Loading