Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,196 @@
# Capability Spec Delta: site-icons
**Package**: `@suaveplan/site-icons`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Domain normalization before fetch
`fetchSiteIcons(domain)` SHALL normalize the input domain via `@suaveplan/domain-normalizer` before initiating any network request. Protocols, paths, query strings, and port numbers MUST be stripped. Internationalized domain names MUST be converted to Punycode.
#### Scenario: URL with path and query
- **WHEN** `fetchSiteIcons("https://example.com/login?redirect=/dashboard")` is called
- **THEN** the effective domain used for all fetches is `example.com`
#### Scenario: Unicode domain
- **WHEN** `fetchSiteIcons("https://münchen.example/")` is called
- **THEN** the effective domain is the Punycode representation `xn--mnchen-3ya.example`
**Verified by:** `src/fetcher/fetcher.test.ts::"normalizes URL with path and query to bare domain"` AND `src/fetcher/fetcher.test.ts::"converts unicode domain to punycode before fetching"`
---
### Requirement: Multi-source icon discovery
`fetchSiteIcons` SHALL attempt to discover icons from the following sources in parallel:
1. `https://{domain}/favicon.ico`
2. `https://{domain}/apple-touch-icon.png`
3. `https://{domain}/manifest.json` — parsed for `icons` array entries
4. Open Graph image from `https://{domain}/` — parsed for `<meta property="og:image">`
Each source that returns an HTTP 200 with a valid image MIME type MUST produce a `SiteIconResult`. Sources that return 404 or non-image MIME types MUST be silently excluded.
#### Scenario: favicon.ico exists
- **WHEN** `fetchSiteIcons` is called and `/favicon.ico` returns 200 with `image/x-icon`
- **THEN** the result array includes a `SiteIconResult` with `source: { kind: 'favicon' }`
#### Scenario: manifest with multiple icons
- **WHEN** `fetchSiteIcons` is called and `/manifest.json` returns a JSON object with `icons: [{ src: "/icon-192.png", sizes: "192x192", type: "image/png" }]`
- **THEN** the result array includes a `SiteIconResult` with `source: { kind: 'manifestIcon', src: '/icon-192.png', sizes: '192x192' }`
#### Scenario: non-image response excluded
- **WHEN** `fetchSiteIcons` is called and `/favicon.ico` returns 200 with `text/html`
- **THEN** no `SiteIconResult` with `source.kind === 'favicon'` is present in the results
**Verified by:** `src/fetcher/fetcher.test.ts::"includes favicon result when favicon.ico returns image mime type"` AND `src/fetcher/fetcher.test.ts::"parses manifest icons into separate results"` AND `src/fetcher/fetcher.test.ts::"excludes non-image responses from results"`
---
### Requirement: Size-aware selection and sorting
`selectBestIcon(candidates, options?)` SHALL rank candidates by proximity to the requested size (default 64×64). Candidates with explicit `size` metadata MUST be preferred over candidates with `null` size. If `options.preferLarger` is `true`, the closest candidate that is equal to or larger than the target MUST be chosen; otherwise the closest by absolute difference MUST be chosen.
#### Scenario: Exact size match preferred
- **WHEN** candidates include 32×32, 64×64, and 128×128 icons and target size is 64×64
- **THEN** `selectBestIcon` returns the 64×64 candidate
#### Scenario: Null-size candidates deprioritized
- **WHEN** candidates include one icon with size `null` and one with size 32×32
- **THEN** `selectBestIcon` returns the 32×32 candidate
#### Scenario: Prefer larger fallback
- **WHEN** `options.preferLarger` is `true`, target is 64×64, and candidates are 32×32 and 128×128
- **THEN** `selectBestIcon` returns the 128×128 candidate
**Verified by:** `src/selector/selector.test.ts::"returns exact size match when available"` AND `src/selector/selector.test.ts::"deprioritizes candidates with unknown size"` AND `src/selector/selector.test.ts::"prefers larger candidate when preferLarger is true"`
---
### Requirement: Caching via cache-browser
Successfully fetched and validated icons MUST be stored in `@suaveplan/cache-browser` using a domain-normalized cache key (`site-icon:{domain}:{sourceKind}:{url}`). The default TTL MUST be 7 days. On subsequent calls, cached entries MUST be returned without network activity.
#### Scenario: Cache hit returns cached data
- **WHEN** `fetchSiteIcons("example.com")` is called and a valid cached entry exists
- **THEN** no network request is made and the cached `SiteIconResult` is returned
#### Scenario: Cache miss performs fetch
- **WHEN** `fetchSiteIcons("example.com")` is called and no cached entry exists
- **THEN** network requests are performed and the result is stored in cache
#### Scenario: Expired cache entry ignored
- **WHEN** a cached entry exists but its TTL has expired
- **THEN** the entry is treated as a cache miss
**Verified by:** `src/cache/cache.test.ts::"returns cached result without network on cache hit"` AND `src/cache/cache.test.ts::"stores result in cache after successful fetch"` AND `src/cache/cache.test.ts::"treats expired entry as cache miss"`
---
### Requirement: Fallback chain when sources fail
If all parallel discovery sources fail (non-2xx, non-image MIME type, or network error), `fetchSiteIcons` MUST attempt a sequential fallback chain:
1. `https://{domain}/favicon.ico`
2. `https://{domain}/apple-touch-icon.png`
3. `https://www.google.com/s2/favicons?domain={domain}&sz=128`
If the fallback chain is also exhausted, `fetchSiteIcons` MUST throw `NotFoundError` (code `SITE_ICON.NOT_FOUND`) with `context.attemptedSources` listing every URL tried.
#### Scenario: Primary sources fail, Google fallback succeeds
- **WHEN** all primary sources return 404 and the Google favicon service returns 200 with an image
- **THEN** the result includes the Google-sourced icon
#### Scenario: Complete exhaustion throws
- **WHEN** all primary sources and all fallback sources return 404
- **THEN** `fetchSiteIcons` throws `NotFoundError` with `context.attemptedSources` containing every attempted URL
**Verified by:** `src/fallback/fallback.test.ts::"uses google favicon fallback when primary sources fail"` AND `src/fallback/fallback.test.ts::"throws NotFoundError when all sources including fallbacks fail"`
---
### Requirement: MIME type validation via mime
Every icon response MUST have its Content-Type validated via `@suaveplan/mime`. Only types whose category is `image` MUST be accepted. Responses with missing or non-image Content-Type headers MUST be rejected.
#### Scenario: Valid image MIME accepted
- **WHEN** a response returns `Content-Type: image/png`
- **THEN** the icon is accepted and returned
#### Scenario: Non-image MIME rejected
- **WHEN** a response returns `Content-Type: text/plain`
- **THEN** the source is excluded from results
**Verified by:** `src/fetcher/fetcher.test.ts::"accepts image/png response as valid icon"` AND `src/fetcher/fetcher.test.ts::"rejects text/plain response as invalid icon"`
---
### Requirement: Timeout and abort signal support
All network operations MUST respect an optional `AbortSignal`. If the signal aborts, all in-flight fetches MUST be cancelled and the promise MUST reject with a structured abort error. A per-source timeout (default 5000 ms) MUST be enforced; on timeout `FetchTimeoutError` (code `SITE_ICON.TIMEOUT`) MUST be thrown with `context.url` and `context.timeoutMs` populated.
#### Scenario: Abort signal cancels in-flight fetches
- **WHEN** `fetchSiteIcons` is called with an `AbortSignal` that aborts mid-flight
- **THEN** the promise rejects and no partial results are returned
#### Scenario: Per-source timeout throws FetchTimeoutError
- **WHEN** a source takes longer than the configured timeout
- **THEN** `FetchTimeoutError` is thrown for that source and the fallback chain continues
**Verified by:** `src/fetcher/fetcher.test.ts::"rejects when abort signal fires mid-flight"` AND `src/fetcher/fetcher.test.ts::"throws FetchTimeoutError when source exceeds timeout"`
---
### Requirement: Structured errors
All thrown errors MUST extend `SiteIconError` which extends `SuaveplanError`. Error codes MUST be registered via `registerSiteIconErrorCodes()` called as a side-effect of importing `src/index.ts`. The following codes MUST be defined:
- `SITE_ICON.TIMEOUT` — a source fetch exceeded the timeout
- `SITE_ICON.INVALID` — MIME type or response body failed validation
- `SITE_ICON.NOT_FOUND` — no icon could be discovered for the domain
**Verified by:** `src/errors/errors.test.ts::"all error classes extend SiteIconError"` AND `src/errors/errors.spec-conformance.test.ts::"error codes are registered on module import"`
---
### Requirement: Property-based and contract tests
The following fast-check properties MUST be included:
1. **Domain normalization idempotence**: for any string input, normalizing twice yields the same domain as normalizing once.
2. **Selector monotonicity**: adding a better-sized candidate to the list never causes `selectBestIcon` to return a worse-ranked candidate.
**Verified by:** `src/fetcher/fetcher.test.ts::"domain normalization is idempotent for arbitrary strings"` AND `src/selector/selector.test.ts::"adding a better candidate never worsens selection"`
---
### Requirement: Benchmarks and 100% coverage
Mitata benchmarks MUST be provided under `src/__benchmarks__/`:
- `fetchSiteIcons` with cache hit — 10 000 iterations
- `selectBestIcon` over 50 candidates — 100 000 iterations
Vitest thresholds MUST be set to 100/100/100/100. `sideEffects: false` MUST be declared in `package.json`.
**Verified by:** `src/__benchmarks__/fetch.bench.ts` AND `src/__benchmarks__/select.bench.ts` AND `vitest.config.ts`