130 lines
13 KiB
Markdown
130 lines
13 KiB
Markdown
# Tasks — `@suaveplan/i18n-detector` (locale detection chain)
|
|
|
|
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick.
|
|
|
|
## 1. Spec authoring
|
|
|
|
- [x] 1.1 Read `openspec/cross-cutting-designs/2026-05-05-i18n-modular-split.md` end-to-end (especially §3 row 11 and §11 out of scope)
|
|
- [x] 1.2 Read `openspec/AGENTS.md` Sections 0, 0a, 6a, 7, 9, 13
|
|
- [x] 1.3 Read every source file listed in `## Source mapping` table in proposal.md (chain orchestrator + 8 detectors + default-chain + utils.ts direction helpers)
|
|
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
|
|
- [x] 1.5 Fill `## Source mapping` table with line ranges from the actual source files
|
|
- [x] 1.6 Draft `specs/i18n-detector/spec.md` from `openspec/templates/spec-delta.md`
|
|
- [x] 1.7 Every requirement carries a `**Verified by:**` line naming concrete `it(...)` titles
|
|
- [x] 1.8 `openspec change validate i18n-detector` exits with no errors
|
|
|
|
## 2. Package scaffold
|
|
|
|
- [x] 2.1 `mkdir -p packages/i18n/i18n-detector/src/{chain,direction,detectors/{querystring,user-preference,cookie,local-storage,html-lang,accept-language,navigator,cloudflare-ip-geo,fallback},default-chain}`
|
|
- [x] 2.2 Author `package.json` — name `@suaveplan/i18n-detector`, version `0.1.0`, type `module`, sideEffects `false`. Deps: `@suaveplan/i18n-shared workspace:^`, `@suaveplan/locale-data workspace:^`, `@suaveplan/types workspace:^`, `@suaveplan/error workspace:^`, `@opentelemetry/api catalog:observability`, `tslib catalog:build`, `zod catalog:forms`. devDeps: `@suaveplan/testing workspace:^`, `@suaveplan/typescript-config workspace:^`, `@vitest/coverage-istanbul catalog:testing`, `vitest catalog:testing`, `@types/node catalog:`, `vite catalog:build`, `vite-plugin-dts catalog:build`, `typescript catalog:`.
|
|
- [x] 2.3 Author `tsconfig.json` extending `@suaveplan/typescript-config/library.json`
|
|
- [x] 2.4 Author `vite.config.ts` using `createTestConfig()` from `@suaveplan/testing/config`
|
|
- [x] 2.5 Author `README.md` (≥200 words, includes Modules table linking to each `.md`, dual-tier strategy callout, default-chain example)
|
|
- [x] 2.6 Add `package.json` `exports` map per proposal.md "Subpath exports" section (root + `./chain` + `./direction` + 9 `./detectors/<name>` + `./default-chain`)
|
|
|
|
## 3. Implementation — types and chain orchestrator
|
|
|
|
- [x] 3.1 Author `src/detector.types.ts` — port `DetectorStorageLike`, `DetectorContext`, `DetectorEnvironment`, `LocaleDetector`, `DetectorChainResult` from monolith verbatim
|
|
- [x] 3.2 Author `src/chain/bcp47-negotiate.ts` — exports `bcp47Negotiate(raw, supported, fallback): string | null` consuming `LOCALE_BCP47_MAP` from `@suaveplan/i18n-shared/constants/locale-bcp47-map`; exact match → language-subtag fallback → `null`
|
|
- [x] 3.3 Author `src/chain/chain.ts` — port `createDetectorChain` + `DETECTOR_CHAIN_UNRESOLVED` + `DetectorChain` + `DetectorChainUnresolved` from monolith; **modify** to accept `options?: { negotiate?: NegotiateFn; throwOnExhausted?: boolean }`; default `negotiate` = `bcp47Negotiate`; throws `I18nError(I18N.DETECTOR_CHAIN_EXHAUSTED)` when `throwOnExhausted: true` AND chain returns sentinel; emit `i18n.detector.chain.exhausted_total` counter on every sentinel return
|
|
- [x] 3.4 Author `src/chain/chain.types.ts` — `NegotiateFn`, `CreateDetectorChainOptions`
|
|
- [x] 3.5 Author `src/chain/index.ts` — explicit named re-exports
|
|
|
|
## 4. Implementation — direction helper
|
|
|
|
- [x] 4.1 Author `src/direction/direction.ts` — port `getTextDirection` + `isRtl` from `packages/i18n/i18n/src/utils/utils.ts` L138-L153; inline `type TextDirection = "ltr" | "rtl"` (drop the cross-folder import)
|
|
- [x] 4.2 Author `src/direction/index.ts` — explicit named re-exports
|
|
|
|
## 5. Implementation — individual detectors
|
|
|
|
- [x] 5.1 Author `src/detectors/querystring/{querystring.ts,querystring.types.ts,index.ts}` — port from `packages/react/react-i18n/src/detector/querystring/`; rewire `LocaleDetector` import to `../../detector.types.js`
|
|
- [x] 5.2 Author `src/detectors/user-preference/{user-preference.ts,user-preference.types.ts,index.ts}` — port from react-i18n; replace `ConsoleLogger` dep with `options.logger?: LoggerLike` (default no-op); thread `ctx.signal` on every fetch
|
|
- [x] 5.3 Author `src/detectors/cookie/{cookie.ts,cookie.types.ts,index.ts}` — verbatim port from react-i18n
|
|
- [x] 5.4 Author `src/detectors/local-storage/{local-storage.ts,local-storage.types.ts,index.ts}` — verbatim port from react-i18n
|
|
- [x] 5.5 Author `src/detectors/html-lang/{html-lang.ts,index.ts}` — verbatim port from react-i18n
|
|
- [x] 5.6 Author `src/detectors/accept-language/{accept-language.ts,accept-language.types.ts,index.ts}` — verbatim port from react-i18n
|
|
- [x] 5.7 Author `src/detectors/navigator/{navigator.ts,index.ts}` — verbatim port from react-i18n
|
|
- [x] 5.8 Author `src/detectors/cloudflare-ip-geo/{cloudflare-ip-geo.ts,cloudflare-ip-geo.types.ts,index.ts}` — verbatim port from react-i18n; `getAllCountries` import already targets `@suaveplan/locale-data`
|
|
- [x] 5.9 Author `src/detectors/fallback/{fallback.ts,index.ts}` — port; replace `throw new Error(...)` with `throw new I18nError(I18N_CODES.INVALID_LOCALE, ...)`
|
|
|
|
## 6. Implementation — default chain factory
|
|
|
|
- [x] 6.1 Author `src/default-chain/{default-chain.ts,default-chain.types.ts,index.ts}` — port `defaultDetectorChain` + `DefaultDetectorChainOptions` from react-i18n; rewire detector imports to sibling folders
|
|
|
|
## 7. Per-detector OTel instrumentation
|
|
|
|
- [x] 7.1 Add `i18n.detector.<name>.duration_ms` histogram to every async detector's `detectAsync` body (start/stop wall-time)
|
|
- [x] 7.2 Add `i18n.detector.<name>.detected_total{locale=<resolved>}` counter increment to every detector's success path (sync `detect` + async `detectAsync`)
|
|
- [x] 7.3 Add `i18n.detector.chain.exhausted_total` counter to chain orchestrator's sentinel-return branch
|
|
- [x] 7.4 Verify `getDetectorMetrics()` helper from observability lives in this package (port the metrics module from monolith `src/observability/detector-metrics.ts`); rename to `src/observability/metrics.ts` co-located with the chain
|
|
|
|
## 8. Root barrel
|
|
|
|
- [x] 8.1 Author `src/index.ts` — explicit named re-exports across `chain/`, `direction/`, `detectors/`, `default-chain/`, and `detector.types.ts` (NO `export *` per CLAUDE.md §10)
|
|
|
|
## 9. Documentation (sibling `.md` per implementation file)
|
|
|
|
- [x] 9.1 `src/chain/chain.md` (≥200 words, basic + advanced examples; covers sync vs async chain, environment gating, AbortSignal, throw-on-exhausted)
|
|
- [x] 9.2 `src/chain/bcp47-negotiate.md` (basic + advanced; covers default vs injected negotiator)
|
|
- [x] 9.3 `src/direction/direction.md` (basic + advanced; covers `getTextDirection` + `isRtl`)
|
|
- [x] 9.4 `src/detectors/querystring/querystring.md`
|
|
- [x] 9.5 `src/detectors/user-preference/user-preference.md`
|
|
- [x] 9.6 `src/detectors/cookie/cookie.md`
|
|
- [x] 9.7 `src/detectors/local-storage/local-storage.md`
|
|
- [x] 9.8 `src/detectors/html-lang/html-lang.md`
|
|
- [x] 9.9 `src/detectors/accept-language/accept-language.md`
|
|
- [x] 9.10 `src/detectors/navigator/navigator.md`
|
|
- [x] 9.11 `src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md`
|
|
- [x] 9.12 `src/detectors/fallback/fallback.md`
|
|
- [x] 9.13 `src/default-chain/default-chain.md` (lists the canonical 8/9-entry order; explains userPreference opt-in)
|
|
- [x] 9.14 `src/observability/metrics.md`
|
|
- [x] 9.15 `README.md` `## Modules` section links every `.md`
|
|
|
|
## 10. Tests (Vitest, runner-neutral imports per CLAUDE.md §11)
|
|
|
|
- [x] 10.1 `src/chain/chain.test.ts` — sync chain registration order; async chain race-to-first; environment gating skips browser detectors on server (and vice versa); `cache()` walks every persisting detector; AbortSignal threaded into every async call; `throwOnExhausted: true` raises `I18nError(I18N.DETECTOR_CHAIN_EXHAUSTED)`; default `bcp47Negotiate` wired
|
|
- [x] 10.2 `src/chain/bcp47-negotiate.test.ts` — exact-match path; language-subtag fallback (`zh-Hant-TW` → `zh`); short-code expansion via `LOCALE_BCP47_MAP` (`en` → `en-GB`); `null` on miss
|
|
- [x] 10.3 `src/direction/direction.test.ts` — `getTextDirection("ar")` is `"rtl"`; `getTextDirection("ar-SA")` is `"rtl"` (subtag extraction); `getTextDirection("en-GB")` is `"ltr"`; `isRtl` mirrors
|
|
- [x] 10.4 `src/detectors/querystring/querystring.test.ts` — server `?lang=fr` resolves; browser `globalThis.location.search` resolves; missing param returns `null`; both contexts absent returns `null`; custom `paramName` honoured
|
|
- [x] 10.5 `src/detectors/user-preference/user-preference.test.ts` — happy GET round-trip; non-2xx logged + `null`; rejected fetch logged + `null`; AbortSignal threaded; pre-aborted signal returns `null`; cache POST round-trip; cache failure non-fatal; `extractAuth` invoked
|
|
- [x] 10.6 `src/detectors/cookie/cookie.test.ts` — server reads inbound Cookie header; server writes Set-Cookie on responseHeaders; browser reads/writes `document.cookie`; URL-encoding round-trip; SameSite=`lax`/`strict`/`none` formatting; missing both contexts returns `null`
|
|
- [x] 10.7 `src/detectors/local-storage/local-storage.test.ts` — explicit storage option; `globalThis.localStorage` fallback; missing storage returns `null`; quota / private-mode error caught; cache failure non-fatal
|
|
- [x] 10.8 `src/detectors/html-lang/html-lang.test.ts` — `ctx.document` lookup; `globalThis.document` fallback; missing root returns `null`; empty `lang` returns `null`; whitespace-only `lang` returns `null`
|
|
- [x] 10.9 `src/detectors/accept-language/accept-language.test.ts` — q-value sort desc; stable order on ties; `*` wildcard skipped; malformed q skipped; q out of range skipped; missing header returns `null`; empty header returns `null`
|
|
- [x] 10.10 `src/detectors/navigator/navigator.test.ts` — `navigator.languages` preferred over `navigator.language`; whitespace-only entries skipped; missing navigator returns `null`
|
|
- [x] 10.11 `src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.test.ts` — `AE` → `ar-AE` via locale-data; override map wins over locale-data; missing header returns `null`; unknown country returns `null`; lowercase header value upper-cased
|
|
- [x] 10.12 `src/detectors/fallback/fallback.test.ts` — returns configured locale; empty locale throws `I18nError(I18N.INVALID_LOCALE)`; whitespace-only locale throws
|
|
- [x] 10.13 `src/default-chain/default-chain.test.ts` — order matches spec exactly; `userPreference` only when endpoint set; fresh array per call; fallback always last; environment values match
|
|
- [x] 10.14 `src/index.test.ts` — every documented public export resolves through the root barrel; every documented subpath export resolves
|
|
- [x] 10.15 Every `it(...)` carries `{ timeout: 5000 }`
|
|
- [x] 10.16 All test imports go through `@suaveplan/testing/runner` (CLAUDE.md §11) — verified by `grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/i18n/i18n-detector/src/` returning zero hits
|
|
|
|
## 11. Pre-archive verification (5-gate triple)
|
|
|
|
- [x] 11.1 `bun run check` — Biome 0/0/0 across the monorepo
|
|
- [x] 11.2 `bunx turbo lint typecheck test --filter=@suaveplan/i18n-detector` — green
|
|
- [x] 11.3 `bunx turbo test:coverage --filter=@suaveplan/i18n-detector` — 100/100/100/100
|
|
- [x] 11.4 `bun scripts/openspec-conformance.ts @suaveplan/i18n-detector` — exit 0, no `error` findings
|
|
- [x] 11.5 `bunx turbo build --filter=@suaveplan/i18n-detector` — green
|
|
|
|
## 12. Pre-archive 7-row checklist (AGENTS.md Section 13)
|
|
|
|
- [x] 12.1 Generics threaded — `LocaleDetector` interface preserves the upstream shape; `NegotiateFn` typed as `(raw: string, supported: readonly string[], fallback: string) => string | null`; `CreateDetectorChainOptions` uses precise types not `any`
|
|
- [x] 12.2 Zod schemas wired at every public boundary — `DetectorContextSchema`, `DefaultDetectorChainOptionsSchema`, `QuerystringDetectorOptionsSchema`, `CookieDetectorOptionsSchema`, `LocalStorageDetectorOptionsSchema`, `UserPreferenceDetectorOptionsSchema`, `CloudflareIpGeoDetectorOptionsSchema`, `CreateDetectorChainOptionsSchema`
|
|
- [x] 12.3 Every error code thrown by at least one production path: `I18N.INVALID_LOCALE` (fallback detector), `I18N.DETECTOR_CHAIN_EXHAUSTED` (chain when `throwOnExhausted: true`)
|
|
- [x] 12.4 Strict-mode tested — every browser-only detector's "missing global" branch covered; every server-only detector's "missing request" branch covered; AbortSignal-aborted detectAsync returns `null` deterministically
|
|
- [x] 12.5 OTel — every async detector emits `i18n.detector.<name>.duration_ms`; every detector emits `i18n.detector.<name>.detected_total{locale=<resolved>}` on success; chain emits `i18n.detector.chain.exhausted_total` on sentinel return; `i18n.detect` span sets `resolved_locale` and `winning_detector` attributes
|
|
- [x] 12.6 Lifecycle dedup — N/A (detectors are pure factories returning fresh `LocaleDetector` per call; chain is stateless)
|
|
- [x] 12.7 No retroactive tick — every box reflects work done in this change
|
|
|
|
## 13. Manifest + ecosystem awareness
|
|
|
|
- [x] 13.1 Add `@suaveplan/i18n-detector` entry to root `CLAUDE.md` package listing under the i18n cluster (one line, mentions universal tier + 8 detectors + chain + direction helper)
|
|
- [x] 13.2 Run `bun scripts/folder-reorg/generate-manifest.ts` to refresh `MANIFEST.md`
|
|
- [x] 13.3 Append `@suaveplan/i18n-detector` to `STATUS.md` Completed list
|
|
|
|
## 14. Archive
|
|
|
|
- [x] 14.1 Fold spec delta into `openspec/specs/i18n-detector/spec.md` (NEW capability spec — first delta becomes canonical)
|
|
- [x] 14.2 Move `openspec/changes/i18n-detector/` → `openspec/archive/<yyyy-mm-dd>-i18n-detector/`
|