Initial Commit
This commit is contained in:
196
openspec/changes/site-icons/specs/site-icons/spec.md
Normal file
196
openspec/changes/site-icons/specs/site-icons/spec.md
Normal 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`
|
||||
Reference in New Issue
Block a user