Files
2026-05-27 23:29:53 +01:00

13 KiB

Tasks — @suaveplan/i18n-detector (locale detection chain)

Every checkbox MUST reflect work actually done in this change. Do not retroactively tick.

1. Spec authoring

  • 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)
  • 1.2 Read openspec/AGENTS.md Sections 0, 0a, 6a, 7, 9, 13
  • 1.3 Read every source file listed in ## Source mapping table in proposal.md (chain orchestrator + 8 detectors + default-chain + utils.ts direction helpers)
  • 1.4 Draft proposal.md from openspec/templates/proposal.md
  • 1.5 Fill ## Source mapping table with line ranges from the actual source files
  • 1.6 Draft specs/i18n-detector/spec.md from openspec/templates/spec-delta.md
  • 1.7 Every requirement carries a **Verified by:** line naming concrete it(...) titles
  • 1.8 openspec change validate i18n-detector exits with no errors

2. Package scaffold

  • 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}
  • 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:.
  • 2.3 Author tsconfig.json extending @suaveplan/typescript-config/library.json
  • 2.4 Author vite.config.ts using createTestConfig() from @suaveplan/testing/config
  • 2.5 Author README.md (≥200 words, includes Modules table linking to each .md, dual-tier strategy callout, default-chain example)
  • 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

  • 3.1 Author src/detector.types.ts — port DetectorStorageLike, DetectorContext, DetectorEnvironment, LocaleDetector, DetectorChainResult from monolith verbatim
  • 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
  • 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
  • 3.4 Author src/chain/chain.types.ts — NegotiateFn, CreateDetectorChainOptions
  • 3.5 Author src/chain/index.ts — explicit named re-exports

4. Implementation — direction helper

  • 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)
  • 4.2 Author src/direction/index.ts — explicit named re-exports

5. Implementation — individual detectors

  • 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
  • 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
  • 5.3 Author src/detectors/cookie/{cookie.ts,cookie.types.ts,index.ts} — verbatim port from react-i18n
  • 5.4 Author src/detectors/local-storage/{local-storage.ts,local-storage.types.ts,index.ts} — verbatim port from react-i18n
  • 5.5 Author src/detectors/html-lang/{html-lang.ts,index.ts} — verbatim port from react-i18n
  • 5.6 Author src/detectors/accept-language/{accept-language.ts,accept-language.types.ts,index.ts} — verbatim port from react-i18n
  • 5.7 Author src/detectors/navigator/{navigator.ts,index.ts} — verbatim port from react-i18n
  • 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
  • 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

  • 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

  • 7.1 Add i18n.detector.<name>.duration_ms histogram to every async detector's detectAsync body (start/stop wall-time)
  • 7.2 Add i18n.detector.<name>.detected_total{locale=<resolved>} counter increment to every detector's success path (sync detect + async detectAsync)
  • 7.3 Add i18n.detector.chain.exhausted_total counter to chain orchestrator's sentinel-return branch
  • 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

  • 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)

  • 9.1 src/chain/chain.md (≥200 words, basic + advanced examples; covers sync vs async chain, environment gating, AbortSignal, throw-on-exhausted)
  • 9.2 src/chain/bcp47-negotiate.md (basic + advanced; covers default vs injected negotiator)
  • 9.3 src/direction/direction.md (basic + advanced; covers getTextDirection + isRtl)
  • 9.4 src/detectors/querystring/querystring.md
  • 9.5 src/detectors/user-preference/user-preference.md
  • 9.6 src/detectors/cookie/cookie.md
  • 9.7 src/detectors/local-storage/local-storage.md
  • 9.8 src/detectors/html-lang/html-lang.md
  • 9.9 src/detectors/accept-language/accept-language.md
  • 9.10 src/detectors/navigator/navigator.md
  • 9.11 src/detectors/cloudflare-ip-geo/cloudflare-ip-geo.md
  • 9.12 src/detectors/fallback/fallback.md
  • 9.13 src/default-chain/default-chain.md (lists the canonical 8/9-entry order; explains userPreference opt-in)
  • 9.14 src/observability/metrics.md
  • 9.15 README.md ## Modules section links every .md

10. Tests (Vitest, runner-neutral imports per CLAUDE.md §11)

  • 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
  • 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
  • 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
  • 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
  • 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
  • 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
  • 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
  • 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
  • 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
  • 10.10 src/detectors/navigator/navigator.test.ts — navigator.languages preferred over navigator.language; whitespace-only entries skipped; missing navigator returns null
  • 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
  • 10.12 src/detectors/fallback/fallback.test.ts — returns configured locale; empty locale throws I18nError(I18N.INVALID_LOCALE); whitespace-only locale throws
  • 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
  • 10.14 src/index.test.ts — every documented public export resolves through the root barrel; every documented subpath export resolves
  • 10.15 Every it(...) carries { timeout: 5000 }
  • 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)

  • 11.1 bun run check — Biome 0/0/0 across the monorepo
  • 11.2 bunx turbo lint typecheck test --filter=@suaveplan/i18n-detector — green
  • 11.3 bunx turbo test:coverage --filter=@suaveplan/i18n-detector — 100/100/100/100
  • 11.4 bun scripts/openspec-conformance.ts @suaveplan/i18n-detector — exit 0, no error findings
  • 11.5 bunx turbo build --filter=@suaveplan/i18n-detector — green

12. Pre-archive 7-row checklist (AGENTS.md Section 13)

  • 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
  • 12.2 Zod schemas wired at every public boundary — DetectorContextSchema, DefaultDetectorChainOptionsSchema, QuerystringDetectorOptionsSchema, CookieDetectorOptionsSchema, LocalStorageDetectorOptionsSchema, UserPreferenceDetectorOptionsSchema, CloudflareIpGeoDetectorOptionsSchema, CreateDetectorChainOptionsSchema
  • 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)
  • 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
  • 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
  • 12.6 Lifecycle dedup — N/A (detectors are pure factories returning fresh LocaleDetector per call; chain is stateless)
  • 12.7 No retroactive tick — every box reflects work done in this change

13. Manifest + ecosystem awareness

  • 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)
  • 13.2 Run bun scripts/folder-reorg/generate-manifest.ts to refresh MANIFEST.md
  • 13.3 Append @suaveplan/i18n-detector to STATUS.md Completed list

14. Archive

  • 14.1 Fold spec delta into openspec/specs/i18n-detector/spec.md (NEW capability spec — first delta becomes canonical)
  • 14.2 Move openspec/changes/i18n-detector/ → openspec/archive/<yyyy-mm-dd>-i18n-detector/