From 1215bb6ebb14baa5b650e404bdf6fc0eb5c31f4e Mon Sep 17 00:00:00 2001 From: Microck Date: Sun, 30 Aug 2026 10:30:35 +0000 Subject: [PATCH 1/3] feat: expand font discovery and provider guidance --- Formula/moji.rb | 17 +++ README.md | 23 +++- .../content/docs/explanation/architecture.mdx | 2 +- .../content/docs/how-to/search-and-filter.mdx | 1 + docs/content/docs/index.mdx | 8 ++ docs/content/docs/reference/cli.mdx | 2 +- docs/content/docs/reference/configuration.mdx | 5 + .../docs/reference/errors-and-exit-codes.mdx | 5 + docs/content/docs/reference/providers.mdx | 15 +++ docs/content/docs/tutorial.mdx | 9 +- docs/release-runbook.md | 25 ++++ e2e/cli_test.go | 2 +- internal/app/app_test.go | 6 +- internal/app/output.go | 2 +- internal/app/search.go | 5 +- internal/config/config.go | 3 +- internal/config/config_test.go | 5 +- internal/provider/dafont.go | 111 ++++++++++++++++++ internal/provider/dafont_test.go | 84 +++++++++++++ internal/provider/provider.go | 35 ++++-- internal/provider/provider_test.go | 20 ++++ internal/tui/config.go | 8 +- internal/tui/config_test.go | 12 ++ internal/tui/results.go | 108 +++++++++++++---- internal/tui/results_test.go | 57 +++++++++ scripts/release.test.ts | 10 ++ 26 files changed, 527 insertions(+), 53 deletions(-) create mode 100644 Formula/moji.rb create mode 100644 internal/provider/dafont.go create mode 100644 internal/provider/dafont_test.go diff --git a/Formula/moji.rb b/Formula/moji.rb new file mode 100644 index 0000000..99e4287 --- /dev/null +++ b/Formula/moji.rb @@ -0,0 +1,17 @@ +class Moji < Formula + desc "Cozy terminal font finder with safe downloads" + homepage "https://github.com/Microck/moji" + url "https://registry.npmjs.org/@microck/moji/-/moji-0.7.0.tgz" + sha256 "2430859658fc26fe4da37bff6b27b0900dc28b9f6c92c16ebf26f083a2939afb" + license "MIT" + + def install + platform = OS.mac? ? "darwin" : "linux" + architecture = Hardware::CPU.arm? ? "arm64" : "x64" + bin.install "package/binaries/#{platform}-#{architecture}/moji" + end + + test do + assert_equal version.to_s, shell_output("#{bin}/moji --version").strip + end +end diff --git a/README.md b/README.md index 9d41815..11f0123 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,17 @@ pnpm add -g @microck/moji bun add -g @microck/moji ``` +or install the same released native binary from this repository's Homebrew +formula: + +```bash +brew tap microck/moji https://github.com/Microck/moji +brew install microck/moji/moji +``` + +The explicit tap URL is required because this source repository is not named +`homebrew-moji`. Upgrade later with `brew upgrade microck/moji/moji`. + search for a family: ```bash @@ -86,10 +97,12 @@ or another script without relying on its filename. ## providers -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 +the default GetFonts, DaFont, Fontshare, and registry providers work without an +account. DaFont search results are parsed from its public catalog and direct ZIP +downloads pass through Moji's normal archive safety limits. Font Squirrel is +available through `--provider fontsquirrel`, but is off by default because its +site protection can block API clients. All catalog providers inspect source +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 @@ -164,6 +177,8 @@ providers: enabled: true getfonts: enabled: true + dafont: + enabled: true fontsquirrel: enabled: false fontshare: diff --git a/docs/content/docs/explanation/architecture.mdx b/docs/content/docs/explanation/architecture.mdx index b73ff78..6fb7049 100644 --- a/docs/content/docs/explanation/architecture.mdx +++ b/docs/content/docs/explanation/architecture.mdx @@ -16,7 +16,7 @@ download, or TUI code. ## Providers own external contracts Each provider implements the same interface but owns its request format, -response parsing, and source-specific errors. GitHub, GetFonts, and SearXNG +response parsing, and source-specific errors. GitHub, GetFonts, DaFont, and SearXNG live in separate modules so changes to one external contract do not tangle the others. diff --git a/docs/content/docs/how-to/search-and-filter.mdx b/docs/content/docs/how-to/search-and-filter.mdx index 97c58cd..f0052b4 100644 --- a/docs/content/docs/how-to/search-and-filter.mdx +++ b/docs/content/docs/how-to/search-and-filter.mdx @@ -38,6 +38,7 @@ Use a comma-separated provider list: ```sh moji "Futura" --provider getfonts +moji "Peanuts" --provider dafont moji "Futura" --provider github,getfonts moji "Futura" --provider fontsquirrel,fontshare moji "Futura" --provider registry,websearch diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx index 04b3fde..aad9ebd 100644 --- a/docs/content/docs/index.mdx +++ b/docs/content/docs/index.mdx @@ -29,6 +29,14 @@ bun add -g @microck/moji All three commands install the same npm-registry package. It includes binaries for Linux, macOS, and Windows on x64 and arm64. +On macOS or Linux, the repository-owned Homebrew formula installs the native +binary from that same release archive: + +```sh +brew tap microck/moji https://github.com/Microck/moji +brew install microck/moji/moji +``` + ## Two ways to work Run a search in a terminal to open the live interface: diff --git a/docs/content/docs/reference/cli.mdx b/docs/content/docs/reference/cli.mdx index 0e02349..7248cdd 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`, `fontsquirrel`, `fontshare`, `registry`, `plugins`, or `websearch` | +| `--provider ` | | all enabled | Comma-separated `github`, `getfonts`, `dafont`, `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 0dc7244..751c846 100644 --- a/docs/content/docs/reference/configuration.mdx +++ b/docs/content/docs/reference/configuration.mdx @@ -36,6 +36,9 @@ rate_limits: getfonts: timeout_seconds: 15 retries: 1 + dafont: + timeout_seconds: 15 + retries: 1 fontsquirrel: timeout_seconds: 15 retries: 1 @@ -57,6 +60,8 @@ providers: enabled: true getfonts: enabled: true + dafont: + enabled: true fontsquirrel: enabled: false instance: "" diff --git a/docs/content/docs/reference/errors-and-exit-codes.mdx b/docs/content/docs/reference/errors-and-exit-codes.mdx index 424ecdf..bec23c4 100644 --- a/docs/content/docs/reference/errors-and-exit-codes.mdx +++ b/docs/content/docs/reference/errors-and-exit-codes.mdx @@ -21,6 +21,11 @@ Check the provider-specific explanations that follow the first sentence. Verify connectivity and provider configuration, then retry. GetFonts remains the fallback when GitHub authentication is unavailable. +In the TUI, press `H` when the results screen says a provider needs attention. +The Health screen keeps the provider's actual error, wraps long messages, and +names the setting or authentication command to fix. Use `j`/`k` to scroll when +the terminal is too short to show every failure. + ### GitHub search is not configured Set `GITHUB_TOKEN`, set `github_token` in the config, or pass diff --git a/docs/content/docs/reference/providers.mdx b/docs/content/docs/reference/providers.mdx index c45d486..13c8694 100644 --- a/docs/content/docs/reference/providers.mdx +++ b/docs/content/docs/reference/providers.mdx @@ -14,6 +14,21 @@ description: Availability, authentication, trust, and fallback behavior. GetFonts is the zero-configuration default. +## DaFont + +- Name: `dafont` +- Default: enabled +- Authentication: none +- Endpoint: DaFont's public search catalog and direct ZIP downloads +- Result trust: untrusted download bytes +- License: `unknown`; check the author and license information before use + +Moji parses bounded catalog results, chooses at most three closest family +matches, and sends the direct ZIP downloads through the same archive path, +entry-count, expanded-size, format, and HTTPS checks as other providers. This +also covers names that do not exist in the structured registries, such as a +`Peanuts` search matching the Peanuts Gang family. + ## Font Squirrel - Name: `fontsquirrel` diff --git a/docs/content/docs/tutorial.mdx b/docs/content/docs/tutorial.mdx index 5588b45..c8afe74 100644 --- a/docs/content/docs/tutorial.mdx +++ b/docs/content/docs/tutorial.mdx @@ -5,7 +5,7 @@ icon: GraduationCap --- This tutorial takes you from installation to a downloaded font. You need a -terminal and npm, pnpm, or Bun. You do not need Go. +terminal and npm, pnpm, Bun, or Homebrew. You do not need Go. ## 1. Install the CLI @@ -29,6 +29,13 @@ pnpm add -g @microck/moji bun add -g @microck/moji ``` +### Homebrew + +```sh +brew tap microck/moji https://github.com/Microck/moji +brew install microck/moji/moji +``` + Confirm that the launcher can find its bundled binary: ```sh diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 2bb939a..85fbb0d 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -39,6 +39,31 @@ After verification passes, the command publishes the exact verified tarball, creates and pushes an annotated `v` tag, then creates the matching latest GitHub release with generated notes and the tarball attached. +## Update the Homebrew formula + +`Formula/moji.rb` is the repository-owned tap formula. It deliberately installs +the native binary from the same immutable npm archive verified by the release +command instead of introducing another binary build or upload path. + +After `npm run release:publish` has published the new version, download that +archive, calculate its SHA-256 checksum, and update the formula's `url` and +`sha256`. Then verify the formula on Homebrew before committing the formula +update: + +```console +VERSION=0.8.0 +curl -fsSLo moji.tgz https://registry.npmjs.org/@microck/moji/-/moji-$VERSION.tgz +shasum -a 256 moji.tgz +brew reinstall --build-from-source ./Formula/moji.rb +brew test ./Formula/moji.rb +``` + +The formula lives in this repository, so users first run +`brew tap microck/moji https://github.com/Microck/moji` and then install it as +`microck/moji/moji`. The explicit URL is required because this repository is +not named `homebrew-moji`; no external tap repository or Homebrew/core change +is required. + The publish path is resumable. If npm publication succeeded but a later tag or GitHub operation failed, rerun `npm run release:publish`. Moji compares npm's published integrity with the newly verified archive, refuses any mismatch, and diff --git a/e2e/cli_test.go b/e2e/cli_test.go index 2bfb021..fa9e0d6 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 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") + 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 dafont:\n enabled: false\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 c178661..c1d355e 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 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) + 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 dafont:\n enabled: false\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 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) + 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 dafont:\n enabled: false\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) } @@ -339,7 +339,7 @@ func TestRunRejectsUnsupportedProviderAndBadUsage(t *testing.T) { func TestArchiveCatalogProviderNamesAreAccepted(t *testing.T) { t.Parallel() - if err := validateProviderNames("fontsquirrel,fontshare"); err != nil { + if err := validateProviderNames("dafont,fontsquirrel,fontshare"); err != nil { t.Fatal(err) } } diff --git a/internal/app/output.go b/internal/app/output.go index 875e538..4b2e09d 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,fontsquirrel,fontshare,registry,plugins,websearch + --provider github,getfonts,dafont,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 5243995..ac9651b 100644 --- a/internal/app/search.go +++ b/internal/app/search.go @@ -104,6 +104,7 @@ func (application App) searchEvents(ctx context.Context, current config.Config, invalidURLs, _ := store.InvalidURLs() available := map[string]provider.Provider{ "getfonts": provider.GetFonts{Client: application.Client, Endpoint: current.Providers["getfonts"].Instance}, + "dafont": provider.DaFont{Client: application.Client}, "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{ @@ -244,8 +245,8 @@ func validateProviderNames(value string) error { } for _, raw := range strings.Split(value, ",") { name := strings.TrimSpace(strings.ToLower(raw)) - 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) + if name != "github" && name != "getfonts" && name != "dafont" && name != "fontsquirrel" && name != "fontshare" && name != "registry" && name != "plugins" && name != "websearch" { + return fmt.Errorf("unknown provider %q (choose github, getfonts, dafont, fontsquirrel, fontshare, registry, plugins, or websearch)", name) } } return nil diff --git a/internal/config/config.go b/internal/config/config.go index 3ce850c..677a95e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -44,6 +44,7 @@ func Default() Config { RateLimits: map[string]RateLimitConfig{ "github": {TimeoutSeconds: 15, Retries: 2}, "getfonts": {TimeoutSeconds: 15, Retries: 1}, + "dafont": {TimeoutSeconds: 15, Retries: 1}, "fontsquirrel": {TimeoutSeconds: 15, Retries: 1}, "fontshare": {TimeoutSeconds: 15, Retries: 1}, "registry": {TimeoutSeconds: 15, Retries: 1}, @@ -51,7 +52,7 @@ func Default() Config { "websearch": {TimeoutSeconds: 20, Retries: 0}, }, Providers: map[string]ProviderConfig{ - "github": {Enabled: true}, "getfonts": {Enabled: true}, "fontsquirrel": {Enabled: false}, + "github": {Enabled: true}, "getfonts": {Enabled: true}, "dafont": {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 b531020..8aa4bb8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -59,7 +59,10 @@ func TestDefaultConfiguresArchiveCatalogProviders(t *testing.T) { if !current.Providers["fontshare"].Enabled { t.Fatal("Fontshare is not enabled") } - for _, name := range []string{"fontsquirrel", "fontshare"} { + if !current.Providers["dafont"].Enabled { + t.Fatal("DaFont is not enabled") + } + for _, name := range []string{"dafont", "fontsquirrel", "fontshare"} { if current.RateLimits[name].TimeoutSeconds <= 0 { t.Fatalf("provider %q has no timeout policy", name) } diff --git a/internal/provider/dafont.go b/internal/provider/dafont.go new file mode 100644 index 0000000..7351e8f --- /dev/null +++ b/internal/provider/dafont.go @@ -0,0 +1,111 @@ +package provider + +import ( + "context" + "fmt" + "html" + "io" + "net/http" + "net/url" + "regexp" + "strings" +) + +const dafontEndpoint = "https://www.dafont.com" + +var ( + dafontEntryPattern = regexp.MustCompile(`(?is)
(.*?)
.*?]*href="([^"]+)"`) + dafontBadgePattern = regexp.MustCompile(`(?is)]*title="(?:Accents|Euro)"[^>]*>.*?`) + dafontTagPattern = regexp.MustCompile(`(?s)<[^>]+>`) +) + +// DaFont uses a searchable HTML catalog, but its download links are direct ZIP +// responses. Keeping that small, known page parser here lets the archives pass +// through the same HTTPS, size, path, and format checks as every other source. +type DaFont struct { + Client *http.Client + Endpoint string +} + +func (DaFont) Name() string { return "dafont" } + +func (source DaFont) baseURL() string { + if endpoint := strings.TrimRight(source.Endpoint, "/"); endpoint != "" { + return endpoint + } + return dafontEndpoint +} + +func (source DaFont) CacheNamespace() string { + return source.Name() + "\x00" + source.baseURL() +} + +func (DaFont) CacheQuery(query string) string { return archiveCatalogKey(query) } + +func (source DaFont) Search(ctx context.Context, query string, formats []string, out chan<- Event) error { + client := source.Client + if client == nil { + client = http.DefaultClient + } + baseURL := source.baseURL() + entries, err := fetchDaFontCatalog(ctx, client, baseURL+"/search.php?"+url.Values{"q": {query}}.Encode()) + if err != nil { + return fmt.Errorf("DaFont catalog: %w", err) + } + downloadBase := "https://dl.dafont.com" + if source.Endpoint != "" { + downloadBase = baseURL + } + return searchArchiveCatalog(ctx, client, query, formats, entries, func(slug string) string { + return downloadBase + "/dl/?" + url.Values{"f": {slug}}.Encode() + }, out) +} + +func fetchDaFontCatalog(ctx context.Context, client *http.Client, endpoint string) ([]archiveCatalogEntry, error) { + clientCopy := constrainedDiscoveryClient(ctx, client) + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + request.Header.Set("User-Agent", "moji-font-finder") + response, err := clientCopy.Do(request) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, providerHTTPStatusError(response.StatusCode) + } + content, err := io.ReadAll(io.LimitReader(response.Body, maxArchiveCatalogSize+1)) + if err != nil { + return nil, fmt.Errorf("%w: read catalog: %v", ErrBadResponse, err) + } + if len(content) > maxArchiveCatalogSize { + return nil, fmt.Errorf("%w: catalog exceeds %d bytes", ErrBadResponse, maxArchiveCatalogSize) + } + return parseDaFontCatalog(string(content)), nil +} + +func parseDaFontCatalog(content string) []archiveCatalogEntry { + matches := dafontEntryPattern.FindAllStringSubmatch(content, -1) + entries := make([]archiveCatalogEntry, 0, len(matches)) + seen := make(map[string]bool) + for _, match := range matches { + nameMarkup := dafontBadgePattern.ReplaceAllString(match[1], "") + name := strings.Join(strings.Fields(html.UnescapeString(dafontTagPattern.ReplaceAllString(nameMarkup, ""))), " ") + if before, _, found := strings.Cut(name, " by "); found { + name = strings.TrimSpace(before) + } + reference, err := url.Parse(html.UnescapeString(match[2])) + if err != nil { + continue + } + slug := reference.Query().Get("f") + if name == "" || slug == "" || seen[slug] { + continue + } + seen[slug] = true + entries = append(entries, archiveCatalogEntry{Name: name, Slug: slug, License: "unknown"}) + } + return entries +} diff --git a/internal/provider/dafont_test.go b/internal/provider/dafont_test.go new file mode 100644 index 0000000..36631af --- /dev/null +++ b/internal/provider/dafont_test.go @@ -0,0 +1,84 @@ +package provider + +import ( + "archive/zip" + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestDaFontSearchFindsPeanutsGangArchive(t *testing.T) { + t.Parallel() + var archive bytes.Buffer + writer := zip.NewWriter(&archive) + font, err := writer.Create("Peanuts_Gang_Dingbats.ttf") + if err != nil { + t.Fatal(err) + } + if _, err := font.Write([]byte("\x00\x01\x00\x00font")); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + + server := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/search.php": + if got := request.URL.Query().Get("q"); got != "Peanuts" { + t.Fatalf("catalog query = %q, want Peanuts", got) + } + response.Header().Set("Content-Type", "text/html") + response.Write([]byte(` + + +
Peanuts Gang Dings by Jbs
+ + `)) + case "/dl/": + if got := request.URL.Query().Get("f"); got != "peanuts_gang_dings" { + response.WriteHeader(http.StatusNotFound) + return + } + response.Header().Set("Content-Type", "application/zip") + response.Write(archive.Bytes()) + default: + http.NotFound(response, request) + } + })) + defer server.Close() + + out := make(chan Event, 4) + err = (DaFont{Client: server.Client(), Endpoint: server.URL}).Search( + localDiscoveryContext(), "Peanuts", []string{"ttf"}, out, + ) + if err != nil { + t.Fatal(err) + } + close(out) + if len(out) != 1 { + t.Fatalf("result count = %d, want 1", len(out)) + } + got := (<-out).Result + if got.Name != "Peanuts Gang Dings" || got.Filename != "Peanuts_Gang_Dingbats.ttf" || got.Format != "ttf" { + t.Fatalf("result = %#v", got) + } + if got.ArchiveFormat != "zip" || got.ArchiveMember != "Peanuts_Gang_Dingbats.ttf" || got.License != "unknown" { + t.Fatalf("archive result = %#v", got) + } +} + +func TestParseDaFontCatalogKeepsHighlightedFamilyNameAndSlug(t *testing.T) { + t.Parallel() + content := `
Salted Peanuts à by Goma Shin
+ ` + entries := parseDaFontCatalog(content) + if len(entries) != 1 || entries[0].Name != "Salted Peanuts" || entries[0].Slug != "salted_peanuts" { + t.Fatalf("entries = %#v", entries) + } + if !strings.Contains((DaFont{}).CacheNamespace(), dafontEndpoint) { + t.Fatalf("cache namespace = %q", (DaFont{}).CacheNamespace()) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 7e579f5..2061732 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -18,25 +18,42 @@ var ( ) func DescribeFailure(name string, err error) string { + detail := "" + if err != nil { + detail = fmt.Sprintf(". Provider error: %v", err) + } switch { case errors.Is(err, context.DeadlineExceeded): - return fmt.Sprintf("%s timed out. Try again or increase search_timeout_seconds in the config file", name) + return fmt.Sprintf("%s timed out. Try again or run moji config and increase search_timeout_seconds%s", name, detail) case errors.Is(err, context.Canceled): - return fmt.Sprintf("%s search was canceled. No download was started", name) + return fmt.Sprintf("%s search was canceled. No download was started%s", name, detail) case errors.Is(err, ErrRateLimited): - return fmt.Sprintf("%s reached its search limit. Wait a moment, then try again", name) + return fmt.Sprintf("%s reached its search limit. Wait a moment, then try again%s", name, detail) case errors.Is(err, ErrBlocked): - return fmt.Sprintf("%s blocked the search request. Try another enabled provider", name) + return fmt.Sprintf("%s blocked the search request. Try another enabled provider or %s%s", name, providerConfigurationFix(name), detail) case errors.Is(err, ErrUnavailable): - return fmt.Sprintf("Moji couldn't connect to %s. Check your connection, then try again", name) + return fmt.Sprintf("Moji couldn't use %s. Check your connection, then %s%s", name, providerConfigurationFix(name), detail) case errors.Is(err, ErrNonRetryable): - return fmt.Sprintf("%s rejected the search request. Check the query or provider configuration", name) + return fmt.Sprintf("%s rejected the search request. %s%s", name, providerConfigurationFix(name), detail) case errors.Is(err, ErrBadResponse): - return fmt.Sprintf("%s returned a response Moji couldn't use. Try again later or use another provider", name) + return fmt.Sprintf("%s returned a response Moji couldn't use. Try again later, use another provider, or %s%s", name, providerConfigurationFix(name), detail) case err != nil: - return fmt.Sprintf("%s search failed: %v", name, err) + return fmt.Sprintf("%s search failed. %s%s", name, providerConfigurationFix(name), detail) + default: + return fmt.Sprintf("%s search failed. Try again, use another provider, or %s", name, providerConfigurationFix(name)) + } +} + +func providerConfigurationFix(name string) string { + switch name { + case "github": + return "run gh auth status/gh auth login, set GITHUB_TOKEN, or run moji config and check providers.github" + case "websearch": + return "run kagi auth, or run moji config and check providers.websearch.instance" + case "plugins": + return "run moji config and check source_plugins" default: - return fmt.Sprintf("%s search failed. Try again or use another provider", name) + return fmt.Sprintf("run moji config and check providers.%s", name) } } diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index e513d4b..b61283f 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -3,6 +3,7 @@ package provider import ( "context" "errors" + "fmt" "strings" "testing" ) @@ -30,6 +31,25 @@ func TestDescribeFailureProvidesRecovery(t *testing.T) { } } +func TestDescribeFailurePreservesProviderErrorAndNamesConfigurationFix(t *testing.T) { + t.Parallel() + err := fmt.Errorf("%w: github returned HTTP 401", ErrNonRetryable) + message := DescribeFailure("github", err) + for _, wanted := range []string{"gh auth login", "GITHUB_TOKEN", "providers.github", err.Error()} { + if !strings.Contains(message, wanted) { + t.Fatalf("failure guidance %q does not contain %q", message, wanted) + } + } + + webError := fmt.Errorf("%w: kagi search: authentication required", ErrUnavailable) + webMessage := DescribeFailure("websearch", webError) + for _, wanted := range []string{"kagi auth", "providers.websearch.instance", webError.Error()} { + if !strings.Contains(webMessage, wanted) { + t.Fatalf("web failure guidance %q does not contain %q", webMessage, wanted) + } + } +} + func TestUniqueResultsUsesURLOrSourceAndFilename(t *testing.T) { t.Parallel() results := UniqueResults([]Result{ diff --git a/internal/tui/config.go b/internal/tui/config.go index 943fdb9..8e18bf2 100644 --- a/internal/tui/config.go +++ b/internal/tui/config.go @@ -68,9 +68,9 @@ func NewConfigModel(current config.Config, path string, color bool) ConfigModel } model := ConfigModel{path: path, current: current, fields: fields} if color { - model.brand = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF8C00")).Bold(true) - model.accent = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFA500")) - model.faint = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")) + model.brand = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")).Bold(true) + model.accent = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFF87")) + model.faint = lipgloss.NewStyle().Faint(true) model.warning = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")) } return model @@ -214,7 +214,7 @@ func (model ConfigModel) config() (config.Config, error) { } func configurableProviderNames() []string { - return []string{"github", "getfonts", "fontsquirrel", "fontshare", "registry", "plugins", "websearch"} + return []string{"github", "getfonts", "dafont", "fontsquirrel", "fontshare", "registry", "plugins", "websearch"} } func providerHasInstance(name string) bool { diff --git a/internal/tui/config_test.go b/internal/tui/config_test.go index d3bde5a..22440f0 100644 --- a/internal/tui/config_test.go +++ b/internal/tui/config_test.go @@ -7,9 +7,21 @@ import ( "testing" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/microck/moji/internal/config" ) +func TestConfigColorThemeUsesFaintANSIStyleWithoutGray(t *testing.T) { + t.Parallel() + model := NewConfigModel(config.Default(), "/tmp/config.yaml", true) + if !model.faint.GetFaint() { + t.Fatal("configuration theme did not configure ANSI faint text") + } + if _, ok := model.faint.GetForeground().(lipgloss.NoColor); !ok { + t.Fatal("configuration faint text still forces a foreground gray") + } +} + func TestConfigModelEditsTogglesAndSaves(t *testing.T) { path := filepath.Join(t.TempDir(), "config.yaml") model := NewConfigModel(config.Default(), path, false) diff --git a/internal/tui/results.go b/internal/tui/results.go index 8c09b67..ad14c85 100644 --- a/internal/tui/results.go +++ b/internal/tui/results.go @@ -88,22 +88,25 @@ func NewModel(results []provider.Result, downloader DownloadFunc, color bool) Mo selection: lipgloss.NewStyle().Reverse(true), } if color { - model.brand = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF8C00")).Bold(true) - model.accent = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFA500")) - model.faint = lipgloss.NewStyle().Foreground(lipgloss.Color("#858585")) - model.secondary = lipgloss.NewStyle().Foreground(lipgloss.Color("#B0B0B0")) + model.brand = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")).Bold(true) + model.accent = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFF87")) + model.faint = lipgloss.NewStyle().Faint(true) + model.secondary = lipgloss.NewStyle().Faint(true) model.warning = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")).Bold(true) - model.success = lipgloss.NewStyle().Foreground(lipgloss.Color("#5FAF5F")) - model.danger = lipgloss.NewStyle().Foreground(lipgloss.Color("#D75F5F")) + model.success = lipgloss.NewStyle().Foreground(lipgloss.Color("#87FFAF")) + model.danger = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF87AF")) model.selection = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFA500")). + Foreground(lipgloss.Color("#FFFF87")). Bold(true) model.providerStyles = map[string]lipgloss.Style{ - "github": lipgloss.NewStyle().Foreground(lipgloss.Color("#AF87D7")), - "getfonts": lipgloss.NewStyle().Foreground(lipgloss.Color("#5FAFD7")), - "registry": lipgloss.NewStyle().Foreground(lipgloss.Color("#5FAF87")), - "websearch": lipgloss.NewStyle().Foreground(lipgloss.Color("#D7AF5F")), - "plugins": lipgloss.NewStyle().Foreground(lipgloss.Color("#D787AF")), + "github": lipgloss.NewStyle().Foreground(lipgloss.Color("#D7AFFF")), + "getfonts": lipgloss.NewStyle().Foreground(lipgloss.Color("#87D7FF")), + "dafont": lipgloss.NewStyle().Foreground(lipgloss.Color("#FFAF87")), + "fontsquirrel": lipgloss.NewStyle().Foreground(lipgloss.Color("#AFFFD7")), + "fontshare": lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFAF")), + "registry": lipgloss.NewStyle().Foreground(lipgloss.Color("#87FFD7")), + "websearch": lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD787")), + "plugins": lipgloss.NewStyle().Foreground(lipgloss.Color("#FFAFFF")), } } model.refresh() @@ -201,6 +204,12 @@ func (model Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { return model, tea.Quit case "tab", "esc", "H": model.screen = screenResults + model.detailOffset = 0 + case "up", "k": + model.detailOffset = max(0, model.detailOffset-1) + case "down", "j": + maximum := max(0, len(model.healthLines())-model.bodyHeight()) + model.detailOffset = min(maximum, model.detailOffset+1) case "r": if model.search == nil || strings.TrimSpace(model.query) == "" { model.status = "Re-check is available after a search started from the home screen." @@ -297,8 +306,10 @@ func (model Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } case "H": model.screen = screenHealth + model.detailOffset = 0 case "tab": model.screen = screenHealth + model.detailOffset = 0 case "/": model.filtering = true case "f": @@ -388,7 +399,23 @@ func (model Model) resultsBody() string { remaining-- } if len(model.groups) == 0 { - lines = append(lines, "", " No matching results.") + message := " No matching results." + if model.loading { + message = model.warning.Render(" Searching for matching fonts...") + } + contentLimit := model.bodyHeight() + if model.status != "" { + contentLimit-- + } + if len(lines) >= contentLimit { + lines = lines[:max(0, contentLimit-1)] + } + if len(lines)+1 < contentLimit { + lines = append(lines, "") + } + if contentLimit > 0 { + lines = append(lines, message) + } } else if remaining > 0 { lines = append(lines, model.resultWindow(remaining)...) } @@ -502,7 +529,7 @@ func (model Model) providerAttention() string { if count == 1 { noun = "provider needs" } - return fmt.Sprintf("%d %s attention - open Health", count, noun) + return fmt.Sprintf("%d %s attention - press H for the error and fix", count, noun) } func (model Model) renderBrand() string { @@ -1225,28 +1252,61 @@ func (model Model) licenseBadge(license string) string { } func (model Model) healthBody() string { + lines := model.healthLines() + maximum := max(0, len(lines)-model.bodyHeight()) + offset := min(model.detailOffset, maximum) + end := min(len(lines), offset+model.bodyHeight()) + return strings.Join(lines[offset:end], "\n") +} + +func (model Model) healthLines() []string { if len(model.providerStatus) == 0 { - return "\n No provider activity yet. Start a search to collect health information." + return []string{"", " No provider activity yet. Start a search to collect health information."} } names := make([]string, 0, len(model.providerStatus)) for name := range model.providerStatus { names = append(names, name) } sort.Strings(names) - lines := make([]string, 0, len(names)+2) - lines = append(lines, model.faint.Render(" Provider Latest state"), "") + lines := make([]string, 0, len(names)*2+2) + if model.contentWidth() >= 34 { + lines = append(lines, model.faint.Render(" Provider Latest state"), "") + } for _, name := range names { state := model.providerStatus[name] - dot := model.healthDot(state) - lines = append(lines, truncate(fmt.Sprintf(" %s %-14s %s", dot, name, state), model.contentWidth())) + dot := model.healthDot(name, state) + if model.contentWidth() < 34 { + lines = append(lines, truncate(fmt.Sprintf(" %s %s", dot, name), model.contentWidth())) + for _, line := range wrapCells(state, max(1, model.contentWidth()-4)) { + lines = append(lines, " "+line) + } + continue + } + prefix := fmt.Sprintf(" %s %-14s ", dot, name) + wrapped := wrapCells(state, max(1, model.contentWidth()-lipgloss.Width(prefix))) + lines = append(lines, prefix+wrapped[0]) + continuation := strings.Repeat(" ", lipgloss.Width(prefix)) + for _, line := range wrapped[1:] { + lines = append(lines, continuation+line) + } } if model.status != "" { lines = append(lines, "", model.warning.Render(truncate(model.status, model.contentWidth()))) } - return strings.Join(lines, "\n") + return lines } -func (model Model) healthDot(state string) string { +func (model Model) healthDot(name, state string) string { + if current, ok := model.providerStates[name]; ok { + switch current { + case provider.StateDone: + return model.success.Render("●") + case provider.StateFailed: + return model.danger.Render("●") + case provider.StateSearching, provider.StateThrottled: + return model.warning.Render("●") + } + } switch { case strings.HasPrefix(state, "done"): return model.success.Render("●") @@ -1261,12 +1321,12 @@ func (model Model) healthDot(state string) string { func (model Model) healthHelp() string { if model.contentWidth() < 34 { - return model.faint.Render("r check tab back q") + return model.faint.Render("j/k r check tab back") } if model.contentWidth() < 48 { - return model.faint.Render("r check tab/esc back q quit") + return model.faint.Render("j/k scroll r check tab back q") } - return model.faint.Render("r: re-check tab/esc: results q: quit") + return model.faint.Render("j/k: scroll r: re-check tab/esc: results q: quit") } func (model Model) sortName() string { diff --git a/internal/tui/results_test.go b/internal/tui/results_test.go index d4c9e4a..dd9faeb 100644 --- a/internal/tui/results_test.go +++ b/internal/tui/results_test.go @@ -808,6 +808,63 @@ func TestResultsLoadingAndProviderAttentionStayConcise(t *testing.T) { } } +func TestEmptyLiveResultsShowSearchingUntilEventStreamCloses(t *testing.T) { + t.Parallel() + events := make(chan provider.Event) + model := sizedModel(t, NewLiveModel(events, nil, false, "Peanuts", "", rank.DefaultWeights(), 10), 80, 16) + view := model.View() + if !strings.Contains(view, "Searching for matching fonts") || strings.Contains(view, "No matching results") { + t.Fatalf("loading view showed a settled empty state:\n%s", view) + } + compact := sizedModel(t, model, 24, 7).View() + if !strings.Contains(compact, "Searching") || strings.Contains(compact, "No matching results") { + t.Fatalf("compact loading view hid the search state:\n%s", compact) + } + + close(events) + updated, _ := model.Update(model.Init()()) + model = updated.(Model) + view = model.View() + if model.loading || !strings.Contains(view, "No matching results") || strings.Contains(view, "Searching for matching fonts") { + t.Fatalf("settled view did not show the empty state:\n%s", view) + } +} + +func TestColorThemeUsesFaintANSIInsteadOfGrayForeground(t *testing.T) { + t.Parallel() + model := NewModel(nil, nil, true) + if !model.faint.GetFaint() || !model.secondary.GetFaint() { + t.Fatalf("color theme did not configure ANSI faint styles") + } + _, faintHasNoColor := model.faint.GetForeground().(lipgloss.NoColor) + _, secondaryHasNoColor := model.secondary.GetForeground().(lipgloss.NoColor) + if !faintHasNoColor || !secondaryHasNoColor { + t.Fatalf("faint text still forces a foreground gray") + } + plain := NewModel(nil, nil, false).View() + if strings.Contains(plain, "\x1b[") { + t.Fatalf("non-color view contains ANSI escapes: %q", plain) + } +} + +func TestProviderHealthWrapsActionAndActualError(t *testing.T) { + t.Parallel() + model := sizedModel(t, NewModel(nil, nil, false), 60, 18) + err := fmt.Errorf("%w: github returned HTTP 401", provider.ErrNonRetryable) + model.providerStatus["github"] = eventStatus(provider.Event{ + Provider: "github", Type: provider.EventStatus, Status: provider.StateFailed, Err: err, + }) + model.providerStates["github"] = provider.StateFailed + model.screen = screenHealth + view := model.View() + continuous := strings.Join(strings.Fields(view), " ") + for _, wanted := range []string{"gh auth login", "providers.github", "github returned HTTP 401"} { + if !strings.Contains(continuous, wanted) { + t.Fatalf("provider health hid %q:\n%s", wanted, view) + } + } +} + func TestProviderDisplayNeverIncludesOrigin(t *testing.T) { model := NewModel([]provider.Result{ {Filename: "Example-Regular.otf", Format: "otf", Source: "github.com/owner/private-fonts", Provider: "github"}, diff --git a/scripts/release.test.ts b/scripts/release.test.ts index b9344cd..73111ff 100644 --- a/scripts/release.test.ts +++ b/scripts/release.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; import { test } from 'node:test'; import { @@ -112,3 +113,12 @@ test('classifies only explicit GitHub release 404s as missing', () => { assert.equal(isMissingReleaseError('HTTP 503 Service Unavailable'), false); assert.equal(isMissingReleaseError('authentication required'), false); }); + +test('Homebrew formula consumes the canonical npm native-binary archive', async () => { + const formula = await readFile(new URL('../Formula/moji.rb', import.meta.url), 'utf8'); + assert.match(formula, /url "https:\/\/registry\.npmjs\.org\/@microck\/moji\/-\/moji-[0-9.]+\.tgz"/); + assert.match(formula, /sha256 "[0-9a-f]{64}"/); + assert.match(formula, /platform = OS\.mac\? \? "darwin" : "linux"/); + assert.match(formula, /architecture = Hardware::CPU\.arm\? \? "arm64" : "x64"/); + assert.match(formula, /package\/binaries\/\#\{platform\}-\#\{architecture\}\/moji/); +}); From 9dac6495fc7e85e2f53648e8398370d612c6f17b Mon Sep 17 00:00:00 2001 From: Microck Date: Sun, 30 Aug 2026 11:21:38 +0000 Subject: [PATCH 2/3] fix: address review findings --- Formula/moji.rb | 8 +++++++- docs/release-runbook.md | 4 ++-- internal/app/search.go | 2 +- internal/provider/dafont.go | 4 +++- internal/provider/dafont_test.go | 11 +++++++++++ scripts/release.test.ts | 4 +++- 6 files changed, 27 insertions(+), 6 deletions(-) diff --git a/Formula/moji.rb b/Formula/moji.rb index 99e4287..27d4ea4 100644 --- a/Formula/moji.rb +++ b/Formula/moji.rb @@ -7,7 +7,13 @@ class Moji < Formula def install platform = OS.mac? ? "darwin" : "linux" - architecture = Hardware::CPU.arm? ? "arm64" : "x64" + architecture = if Hardware::CPU.arm64? + "arm64" + elsif Hardware::CPU.intel? + "x64" + else + odie "Unsupported CPU architecture: #{Hardware::CPU.arch}" + end bin.install "package/binaries/#{platform}-#{architecture}/moji" end diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 85fbb0d..15a63d3 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -51,10 +51,10 @@ archive, calculate its SHA-256 checksum, and update the formula's `url` and update: ```console -VERSION=0.8.0 +VERSION="$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version")" curl -fsSLo moji.tgz https://registry.npmjs.org/@microck/moji/-/moji-$VERSION.tgz shasum -a 256 moji.tgz -brew reinstall --build-from-source ./Formula/moji.rb +brew install --build-from-source ./Formula/moji.rb brew test ./Formula/moji.rb ``` diff --git a/internal/app/search.go b/internal/app/search.go index ac9651b..eaac465 100644 --- a/internal/app/search.go +++ b/internal/app/search.go @@ -104,7 +104,7 @@ func (application App) searchEvents(ctx context.Context, current config.Config, invalidURLs, _ := store.InvalidURLs() available := map[string]provider.Provider{ "getfonts": provider.GetFonts{Client: application.Client, Endpoint: current.Providers["getfonts"].Instance}, - "dafont": provider.DaFont{Client: application.Client}, + "dafont": provider.DaFont{Client: application.Client, Endpoint: current.Providers["dafont"].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{ diff --git a/internal/provider/dafont.go b/internal/provider/dafont.go index 7351e8f..537e54a 100644 --- a/internal/provider/dafont.go +++ b/internal/provider/dafont.go @@ -40,7 +40,9 @@ func (source DaFont) CacheNamespace() string { return source.Name() + "\x00" + source.baseURL() } -func (DaFont) CacheQuery(query string) string { return archiveCatalogKey(query) } +func (DaFont) CacheQuery(query string) string { + return strings.ToLower(strings.Join(strings.Fields(query), " ")) +} func (source DaFont) Search(ctx context.Context, query string, formats []string, out chan<- Event) error { client := source.Client diff --git a/internal/provider/dafont_test.go b/internal/provider/dafont_test.go index 36631af..9ee398d 100644 --- a/internal/provider/dafont_test.go +++ b/internal/provider/dafont_test.go @@ -82,3 +82,14 @@ func TestParseDaFontCatalogKeepsHighlightedFamilyNameAndSlug(t *testing.T) { t.Fatalf("cache namespace = %q", (DaFont{}).CacheNamespace()) } } + +func TestDaFontCacheQueryPreservesAdaptiveSpellings(t *testing.T) { + t.Parallel() + source := DaFont{} + if source.CacheQuery("ProximaNova") == source.CacheQuery("Proxima Nova") { + t.Fatal("adaptive spellings must use distinct DaFont cache keys") + } + if source.CacheQuery(" Proxima Nova ") != source.CacheQuery("proxima nova") { + t.Fatal("cache keys should normalize case and repeated whitespace") + } +} diff --git a/scripts/release.test.ts b/scripts/release.test.ts index 73111ff..ea8f521 100644 --- a/scripts/release.test.ts +++ b/scripts/release.test.ts @@ -119,6 +119,8 @@ test('Homebrew formula consumes the canonical npm native-binary archive', async assert.match(formula, /url "https:\/\/registry\.npmjs\.org\/@microck\/moji\/-\/moji-[0-9.]+\.tgz"/); assert.match(formula, /sha256 "[0-9a-f]{64}"/); assert.match(formula, /platform = OS\.mac\? \? "darwin" : "linux"/); - assert.match(formula, /architecture = Hardware::CPU\.arm\? \? "arm64" : "x64"/); + assert.match(formula, /Hardware::CPU\.arm64\?/); + assert.match(formula, /Hardware::CPU\.intel\?/); + assert.match(formula, /Unsupported CPU architecture/); assert.match(formula, /package\/binaries\/\#\{platform\}-\#\{architecture\}\/moji/); }); From 9b0fd18df033c1701afb1cb844174b03b1697001 Mon Sep 17 00:00:00 2001 From: Microck Date: Sun, 30 Aug 2026 11:45:09 +0000 Subject: [PATCH 3/3] fix: address follow-up review findings --- Formula/moji.rb | 2 +- docs/content/docs/tutorial.mdx | 2 +- internal/tui/results.go | 1 + internal/tui/results_test.go | 36 ++++++++++++++++++++++++++++++++++ scripts/release.test.ts | 3 ++- 5 files changed, 41 insertions(+), 3 deletions(-) diff --git a/Formula/moji.rb b/Formula/moji.rb index 27d4ea4..a8d2ca2 100644 --- a/Formula/moji.rb +++ b/Formula/moji.rb @@ -14,7 +14,7 @@ def install else odie "Unsupported CPU architecture: #{Hardware::CPU.arch}" end - bin.install "package/binaries/#{platform}-#{architecture}/moji" + bin.install "binaries/#{platform}-#{architecture}/moji" end test do diff --git a/docs/content/docs/tutorial.mdx b/docs/content/docs/tutorial.mdx index c8afe74..8b85df3 100644 --- a/docs/content/docs/tutorial.mdx +++ b/docs/content/docs/tutorial.mdx @@ -42,7 +42,7 @@ Confirm that the launcher can find its bundled binary: moji --version ``` -The command prints `0.2.1`. +The command prints the installed release version. ## 2. Search interactively diff --git a/internal/tui/results.go b/internal/tui/results.go index ad14c85..8d4ae1d 100644 --- a/internal/tui/results.go +++ b/internal/tui/results.go @@ -224,6 +224,7 @@ func (model Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { model.loading = true model.providerStatus = make(map[string]string) model.providerStates = make(map[string]provider.State) + model.detailOffset = 0 return model, model.waitForEvent() } return model, nil diff --git a/internal/tui/results_test.go b/internal/tui/results_test.go index dd9faeb..766d1c6 100644 --- a/internal/tui/results_test.go +++ b/internal/tui/results_test.go @@ -943,6 +943,42 @@ func TestProviderHealthScreenUsesStreamedStatuses(t *testing.T) { } } +func TestHealthRecheckResetsScrollOffset(t *testing.T) { + t.Parallel() + initial := make(chan provider.Event) + model := NewLiveModel(initial, nil, false, "Peanuts", "", rank.DefaultWeights(), 10) + model.screen = screenHealth + model.providerStatus = map[string]string{ + "dafont": "done (1 results)", + "fontshare": "done (1 results)", + "getfonts": "done (1 results)", + } + model.providerStates = map[string]provider.State{ + "dafont": provider.StateDone, + "fontshare": provider.StateDone, + "getfonts": provider.StateDone, + } + model = sizedModel(t, model, 40, 8) + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + model = updated.(Model) + if model.detailOffset == 0 { + t.Fatal("health scroll did not move before re-check") + } + refreshed := make(chan provider.Event, 1) + refreshed <- provider.Event{Provider: "dafont", Type: provider.EventStatus, Status: provider.StateSearching} + model.search = func(string) (<-chan provider.Event, error) { return refreshed, nil } + updated, command := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + model = updated.(Model) + if model.detailOffset != 0 || !model.loading || command == nil { + t.Fatalf("re-check did not reset Health state: offset=%d loading=%v", model.detailOffset, model.loading) + } + updated, _ = model.Update(command()) + model = updated.(Model) + if !strings.Contains(model.View(), "dafont") { + t.Fatalf("re-check hid the first provider status:\\n%s", model.View()) + } +} + func TestResultsReserveAVisibleRowForStatus(t *testing.T) { results := make([]provider.Result, 20) for index := range results { diff --git a/scripts/release.test.ts b/scripts/release.test.ts index ea8f521..d1318a3 100644 --- a/scripts/release.test.ts +++ b/scripts/release.test.ts @@ -122,5 +122,6 @@ test('Homebrew formula consumes the canonical npm native-binary archive', async assert.match(formula, /Hardware::CPU\.arm64\?/); assert.match(formula, /Hardware::CPU\.intel\?/); assert.match(formula, /Unsupported CPU architecture/); - assert.match(formula, /package\/binaries\/\#\{platform\}-\#\{architecture\}\/moji/); + assert.match(formula, /bin\.install "binaries\/\#\{platform\}-\#\{architecture\}\/moji"/); + assert.doesNotMatch(formula, /package\/binaries/); });