Initial Commit
This commit is contained in:
113
packages/localization/i18n/i18n-formatter-number/README.md
Normal file
113
packages/localization/i18n/i18n-formatter-number/README.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# @suaveplan/i18n-formatter-number - Locale-aware number formatting backed by Intl.NumberFormat. Class wrapper + standalone helper + ergonomic helpers (formatPercent, formatCompact, formatUnit), Zod-validated options, per-(locale, options-hash) cache, OTel histogram + cache-hit counter. Wave 3 of the @suaveplan/i18n modular split.
|
||||
|
||||
## Summary
|
||||
|
||||
Locale-aware number formatting backed by `Intl.NumberFormat`. Wave 3
|
||||
of the 14-package i18n modular split. Provides both a class
|
||||
(`NumberFormatter`) and a standalone helper (`formatNumber`) plus
|
||||
ergonomic shortcuts (`formatPercent`, `formatCompact`, `formatUnit`)
|
||||
on top of a per-`(locale, options-hash)` cache, Zod-validated options,
|
||||
and the canonical OTel histogram + cache-hit counter that every Wave 3
|
||||
formatter package emits.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [formatter](./src/formatter/formatter.md) — Locale-aware number formatting backed by `Intl.NumberFormat`. The package exposes both a class (`NumberFormatter` — con…
|
||||
- [formatter](./src/formatter/formatter.md) — Locale-aware number formatting backed by `Intl.NumberFormat`. The package exposes both a class (`NumberFormatter` — con…
|
||||
- [helpers](./src/helpers/helpers.md) — Pre-bound shortcuts over `formatNumber` and `Intl.NumberFormat` for the three most-asked number-formatting shapes: perc…
|
||||
- [helpers](./src/helpers/helpers.md) — Pre-bound shortcuts over `formatNumber` and `Intl.NumberFormat` for the three most-asked number-formatting shapes: perc…
|
||||
- [observability](./src/observability/observability.md) — Per-package OpenTelemetry instruments for `@suaveplan/i18n-formatter-number`. Exposes a histogram (`i18n.format.number.…
|
||||
- [observability](./src/observability/observability.md) — Per-package OpenTelemetry instruments for `@suaveplan/i18n-formatter-number`. Exposes a histogram (`i18n.format.number.…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Why this exists
|
||||
|
||||
Each `Intl.*` wrapper lives in its own sibling package so consumers
|
||||
can pull only the formatters they actually use. This package is the
|
||||
canonical Wave 3 template — every other formatter package mirrors its
|
||||
layout (per-(locale, options-hash) cache, Zod schema, OTel metrics,
|
||||
helper + class twin surface). `@suaveplan/i18n-formatter-currency`
|
||||
declares a workspace dep on this package because currency formatting
|
||||
is `Intl.NumberFormat({ style: "currency", ... })`.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/i18n-formatter-number
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import {
|
||||
formatCompact,
|
||||
formatNumber,
|
||||
formatPercent,
|
||||
NumberFormatter,
|
||||
} from "@suaveplan/i18n-formatter-number";
|
||||
|
||||
formatNumber(1234567.89, "en-GB", {
|
||||
style: "decimal",
|
||||
maximumFractionDigits: 2,
|
||||
}); // → "1,234,567.89"
|
||||
|
||||
formatPercent(0.5, "en-GB"); // → "50%"
|
||||
formatCompact(1500, "en-US"); // → "1.5K"
|
||||
|
||||
const usd = new NumberFormatter({
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
});
|
||||
usd.format(42.5, "en-US"); // → "$42.50"
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Subpath | Description |
|
||||
|---|---|
|
||||
| `@suaveplan/i18n-formatter-number` | Root barrel — class + standalone + helpers + observability |
|
||||
| `@suaveplan/i18n-formatter-number/formatter` | `NumberFormatter`, `formatNumber`, `clearNumberFormatterCache`, types, schema |
|
||||
| `@suaveplan/i18n-formatter-number/helpers` | `formatPercent`, `formatCompact`, `formatUnit` only |
|
||||
|
||||
## Modules Documentation
|
||||
|
||||
- [formatter.md](src/formatter.md) — `NumberFormatter` class + `formatNumber`
|
||||
- [helpers.md](src/helpers.md) — `formatPercent`, `formatCompact`, `formatUnit`
|
||||
- [observability.md](src/observability.md) — OTel histogram + cache-hit counter
|
||||
|
||||
## Public Surface (snapshot)
|
||||
|
||||
| Export | Kind | Origin |
|
||||
|---|---|---|
|
||||
| `NumberFormatter` | class | `formatter.ts` |
|
||||
| `formatNumber` | function | `formatter.ts` |
|
||||
| `clearNumberFormatterCache` | function | `formatter.ts` |
|
||||
| `NumberFormatterOptionsSchema` | const | `formatter.ts` |
|
||||
| `NumberFormatterStyle` | type | `formatter.types.ts` |
|
||||
| `NumberFormatterNotation` | type | `formatter.types.ts` |
|
||||
| `NumberFormatterCurrencyDisplay` | type | `formatter.types.ts` |
|
||||
| `NumberFormatterSignDisplay` | type | `formatter.types.ts` |
|
||||
| `NumberFormatterOptions` | type | `formatter.types.ts` |
|
||||
| `NumberFormatterCacheKey` | type | `formatter.types.ts` |
|
||||
| `formatPercent` | function | `helpers.ts` |
|
||||
| `formatCompact` | function | `helpers.ts` |
|
||||
| `formatUnit` | function | `helpers.ts` |
|
||||
| `recordNumberFormat` | function | `observability.ts` |
|
||||
| `recordNumberCacheHit` | function | `observability.ts` |
|
||||
| `getNumberFormatMetricsState` | function | `observability.ts` |
|
||||
| `resetNumberFormatMetricsState` | function | `observability.ts` |
|
||||
|
||||
## Versioning
|
||||
|
||||
This package is part of the i18n modular-split rollout. Stays at
|
||||
`0.1.0` until the slim core (Wave 6) cuts the family `1.0.0` release.
|
||||
|
||||
## Wave 3 Scope
|
||||
|
||||
This package is one of eight Wave 3 formatter packages. Pure
|
||||
addition — `packages/i18n/i18n/src/formatting/number/` is preserved
|
||||
in the monolith until Wave 6 deletes it. Both copies coexist during
|
||||
Waves 3–5; downstream consumers continue to import from the monolith
|
||||
and observe no behaviour change.
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "@suaveplan/i18n-formatter-number",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Locale-aware number formatting backed by Intl.NumberFormat. Class wrapper + standalone helper + ergonomic helpers (formatPercent, formatCompact, formatUnit), Zod-validated options, per-(locale, options-hash) cache, OTel histogram + cache-hit counter. Wave 3 of the @suaveplan/i18n modular split.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./formatter": {
|
||||
"import": {
|
||||
"types": "./dist/formatter/index.d.ts",
|
||||
"default": "./dist/formatter/index.js"
|
||||
}
|
||||
},
|
||||
"./helpers": {
|
||||
"import": {
|
||||
"types": "./dist/helpers/index.d.ts",
|
||||
"default": "./dist/helpers/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/i18n-shared": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import z from "zod";
|
||||
|
||||
export const NumberFormatterOptionsSchema = z
|
||||
.object({
|
||||
style: z.enum(["decimal", "percent", "currency"]).optional(),
|
||||
currency: z.string().min(3).max(3).optional(),
|
||||
currencyDisplay: z
|
||||
.enum(["symbol", "code", "name", "narrowSymbol"])
|
||||
.optional(),
|
||||
minimumFractionDigits: z.number().int().min(0).max(20).optional(),
|
||||
maximumFractionDigits: z.number().int().min(0).max(20).optional(),
|
||||
minimumIntegerDigits: z.number().int().min(1).max(21).optional(),
|
||||
minimumSignificantDigits: z.number().int().min(1).max(21).optional(),
|
||||
maximumSignificantDigits: z.number().int().min(1).max(21).optional(),
|
||||
useGrouping: z.boolean().optional(),
|
||||
notation: z
|
||||
.enum(["standard", "scientific", "engineering", "compact"])
|
||||
.optional(),
|
||||
compactDisplay: z.enum(["short", "long"]).optional(),
|
||||
signDisplay: z
|
||||
.enum(["auto", "always", "exceptZero", "never"])
|
||||
.optional(),
|
||||
})
|
||||
.superRefine((opts, ctx) => {
|
||||
if (
|
||||
opts.minimumFractionDigits !== undefined &&
|
||||
opts.maximumFractionDigits !== undefined &&
|
||||
opts.minimumFractionDigits > opts.maximumFractionDigits
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["fractionDigits"],
|
||||
message:
|
||||
"minimumFractionDigits must be <= maximumFractionDigits",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type NumberFormatterOptionsInput = z.input<
|
||||
typeof NumberFormatterOptionsSchema
|
||||
>;
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Spec-conformance pins for NumberFormatter — Zod rejection paths,
|
||||
* Intl.RangeError mapping, OTel emission, and cache reuse semantics.
|
||||
*/
|
||||
|
||||
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import type {
|
||||
NumberFormatterCacheKey,
|
||||
NumberFormatterOptions,
|
||||
NumberFormatterStyle,
|
||||
} from "./formatter/formatter.js";
|
||||
import {
|
||||
clearNumberFormatterCache,
|
||||
formatNumber,
|
||||
NumberFormatter,
|
||||
NumberFormatterOptionsSchema,
|
||||
} from "./formatter/formatter.js";
|
||||
import {
|
||||
getNumberFormatMetricsState,
|
||||
resetNumberFormatMetricsState,
|
||||
} from "./observability/observability.js";
|
||||
|
||||
describe("NumberFormatter spec-conformance", () => {
|
||||
it("rejects non-object options shape (no path → field='options')", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
formatNumber(1, "en-US", "not-an-object" as never);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(
|
||||
I18N_CODES.INVALID_FORMATTER_OPTIONS
|
||||
);
|
||||
expect((err as I18nError).context).toMatchObject({
|
||||
field: "options",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown style with INVALID_FORMATTER_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new NumberFormatter({ style: "magic" as never });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(
|
||||
I18N_CODES.INVALID_FORMATTER_OPTIONS
|
||||
);
|
||||
expect((err as I18nError).context).toMatchObject({
|
||||
field: "style",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects fraction-digit inversion", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
formatNumber(1, "en-US", {
|
||||
minimumFractionDigits: 4,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(
|
||||
I18N_CODES.INVALID_FORMATTER_OPTIONS
|
||||
);
|
||||
expect((err as I18nError).context).toMatchObject({
|
||||
field: "fractionDigits",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("maps Intl RangeError to UNSUPPORTED_LOCALE", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
// Syntactically-invalid locale (space) triggers RangeError.
|
||||
formatNumber(1, "en US");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(I18N_CODES.UNSUPPORTED_LOCALE);
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses cached Intl.NumberFormat on identical key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
clearNumberFormatterCache();
|
||||
resetNumberFormatMetricsState();
|
||||
formatNumber(1, "en-US", { style: "decimal" });
|
||||
formatNumber(1, "en-US", { style: "decimal" });
|
||||
const state = getNumberFormatMetricsState();
|
||||
expect(state.cacheHitSamples).toHaveLength(1);
|
||||
expect(state.cacheHitSamples[0]?.attributes.formatter).toBe("number");
|
||||
});
|
||||
|
||||
it("does not reuse cache across distinct options", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
clearNumberFormatterCache();
|
||||
resetNumberFormatMetricsState();
|
||||
formatNumber(1, "en-US", { style: "decimal" });
|
||||
formatNumber(1, "en-US", { style: "percent" });
|
||||
const state = getNumberFormatMetricsState();
|
||||
expect(state.cacheHitSamples).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("records a duration_ms sample per call", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
clearNumberFormatterCache();
|
||||
resetNumberFormatMetricsState();
|
||||
formatNumber(1, "en-US", { style: "decimal" });
|
||||
const state = getNumberFormatMetricsState();
|
||||
expect(state.numberDurationSamples).toHaveLength(1);
|
||||
expect(state.numberDurationSamples[0]?.attributes).toMatchObject({
|
||||
locale: "en-US",
|
||||
style: "decimal",
|
||||
});
|
||||
});
|
||||
|
||||
it("type assertions pin the public surface", { timeout: 5000 }, () => {
|
||||
expectTypeOf<NumberFormatterStyle>().toEqualTypeOf<
|
||||
"decimal" | "percent" | "currency"
|
||||
>();
|
||||
expectTypeOf<NumberFormatterCacheKey>().toEqualTypeOf<string>();
|
||||
expectTypeOf<NumberFormatterOptions>().toMatchTypeOf<{
|
||||
readonly style?: NumberFormatterStyle;
|
||||
}>();
|
||||
});
|
||||
|
||||
it("schema accepts a well-formed options bag", { timeout: 5000 }, () => {
|
||||
const parsed = NumberFormatterOptionsSchema.safeParse({
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/** Number formatter type definitions. */
|
||||
|
||||
export type NumberFormatterStyle = "decimal" | "percent" | "currency";
|
||||
|
||||
export type NumberFormatterNotation =
|
||||
| "standard"
|
||||
| "scientific"
|
||||
| "engineering"
|
||||
| "compact";
|
||||
|
||||
export type NumberFormatterCurrencyDisplay =
|
||||
| "symbol"
|
||||
| "code"
|
||||
| "name"
|
||||
| "narrowSymbol";
|
||||
|
||||
export type NumberFormatterSignDisplay =
|
||||
| "auto"
|
||||
| "always"
|
||||
| "exceptZero"
|
||||
| "never";
|
||||
|
||||
export interface NumberFormatterOptions {
|
||||
readonly style?: NumberFormatterStyle;
|
||||
/** Required when `style === 'currency'`. ISO 4217 code. */
|
||||
readonly currency?: string;
|
||||
readonly currencyDisplay?: NumberFormatterCurrencyDisplay;
|
||||
readonly minimumFractionDigits?: number;
|
||||
readonly maximumFractionDigits?: number;
|
||||
readonly minimumIntegerDigits?: number;
|
||||
readonly minimumSignificantDigits?: number;
|
||||
readonly maximumSignificantDigits?: number;
|
||||
readonly useGrouping?: boolean;
|
||||
readonly notation?: NumberFormatterNotation;
|
||||
readonly compactDisplay?: "short" | "long";
|
||||
readonly signDisplay?: NumberFormatterSignDisplay;
|
||||
}
|
||||
|
||||
/** Cache key shape: `${locale}|${jsonOptions}`. */
|
||||
export type NumberFormatterCacheKey = string;
|
||||
@@ -0,0 +1,86 @@
|
||||
# NumberFormatter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Locale-aware number formatting backed by `Intl.NumberFormat`. The package exposes both a class (`NumberFormatter` — construct once with stable options, reuse) and a standalone helper (`formatNumber(value, locale, options?)`). Every call validates options through `NumberFormatterOptionsSchema` (Zod) at the boundary, maps `Intl` `RangeError`s for unsupported locales to `I18N.UNSUPPORTED_LOCALE`, and emits a wall-clock duration sample on the `i18n.format.number.duration_ms` histogram.
|
||||
|
||||
A per-`(locale, options-hash)` cache for `Intl.NumberFormat` instances amortises construction cost — important on dashboards rendering hundreds of formatted values per second. Cache reuse increments the `i18n.format.cache_hit{formatter="number"}` counter so consumers can measure cache effectiveness.
|
||||
|
||||
## Features
|
||||
|
||||
- Class form (`NumberFormatter`) for fixed-options reuse + standalone helper (`formatNumber`) for one-off calls
|
||||
- Full `Intl.NumberFormat` option surface: `style: 'decimal' | 'percent' | 'currency'`, `currency`, `currencyDisplay`, fraction digits, integer digits, significant digits, `useGrouping`, `notation` (`standard | scientific | engineering | compact`), `compactDisplay`, `signDisplay`
|
||||
- Zod-validated options at construction; fraction-digit inversion (`min > max`) rejected with `field === 'fractionDigits'` context
|
||||
- Stable error codes: `I18N.INVALID_FORMATTER_OPTIONS`, `I18N.UNSUPPORTED_LOCALE`
|
||||
- Process-wide `Intl.NumberFormat` cache keyed on `(locale, options-hash)`; `clearNumberFormatterCache()` for tests
|
||||
- OTel instrumentation: `i18n.format.number.duration_ms` histogram, `i18n.format.cache_hit{formatter="number"}` counter
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { formatNumber } from "@suaveplan/i18n-formatter-number";
|
||||
|
||||
formatNumber(1234567.89, "en-US", { style: "decimal", maximumFractionDigits: 2 });
|
||||
// → "1,234,567.89"
|
||||
|
||||
formatNumber(0.255, "en-US", { style: "percent", maximumFractionDigits: 1 });
|
||||
// → "25.5%"
|
||||
|
||||
formatNumber(42, "en-GB", {
|
||||
style: "currency",
|
||||
currency: "GBP",
|
||||
currencyDisplay: "code",
|
||||
});
|
||||
// → "GBP 42.00"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example — Class Form for Repeated Use
|
||||
|
||||
```typescript
|
||||
import { NumberFormatter } from "@suaveplan/i18n-formatter-number";
|
||||
|
||||
const usd = new NumberFormatter({
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
// Many call sites, single options validation:
|
||||
const out = prices.map((price) => usd.format(price, "en-US"));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `formatNumber(value, locale, options?): string`
|
||||
|
||||
Formats `value` per `locale` and `options`. Validates options through Zod, throws `I18nError(I18N.INVALID_FORMATTER_OPTIONS)` on failure. Maps `Intl.NumberFormat`'s `RangeError` for unsupported locales to `I18nError(I18N.UNSUPPORTED_LOCALE)`.
|
||||
|
||||
### `class NumberFormatter`
|
||||
|
||||
- `constructor(options?: NumberFormatterOptions)` — validates options at construction
|
||||
- `format(value: number, locale: string): string` — formats with stored options
|
||||
|
||||
### `clearNumberFormatterCache(): void`
|
||||
|
||||
Test-only. Drops the process-wide cache so subsequent calls re-construct `Intl.NumberFormat`.
|
||||
|
||||
### Types
|
||||
|
||||
`NumberFormatterStyle`, `NumberFormatterNotation`, `NumberFormatterCurrencyDisplay`, `NumberFormatterSignDisplay`, `NumberFormatterOptions`, `NumberFormatterCacheKey`.
|
||||
|
||||
### `NumberFormatterOptionsSchema`
|
||||
|
||||
Zod schema validating the `NumberFormatterOptions` surface. Rejects unknown enum values, out-of-range fraction-digit counts, and the inverted-fraction-digit case (`minimumFractionDigits > maximumFractionDigits`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The cache key is `${locale}|${JSON.stringify(options)}`. JSON.stringify sorts keys alphabetically when given an object literal in modern engines, so two callers passing the same options in different order share a cache entry. Order-sensitive object construction would defeat the cache; if a consumer needs deterministic ordering, normalise the options shape in the call site.
|
||||
|
||||
`style: 'currency'` requires `currency` to be a 3-letter ISO 4217 code. The Zod schema enforces this; bad currency codes either fail Zod (`field === 'currency'`) or fall through to `Intl.NumberFormat` which throws `RangeError` mapped to `UNSUPPORTED_LOCALE`. Most production usage hits the Zod path because callers know their currency codes at compile time.
|
||||
|
||||
The `signDisplay: 'negative'` option is part of the ECMA-402 spec and Node 19+ / Bun runtime; older runtimes may reject it via `RangeError`. The error mapping path produces `UNSUPPORTED_LOCALE` even though the failure is option-related — this is the correct user-facing signal because the runtime is what's missing the support.
|
||||
@@ -0,0 +1,227 @@
|
||||
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
clearNumberFormatterCache,
|
||||
formatNumber,
|
||||
NumberFormatter,
|
||||
} from "./formatter.js";
|
||||
|
||||
describe("formatNumber", () => {
|
||||
it("NumberFormatter formats with default options", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const f = new NumberFormatter();
|
||||
expect(f.format(1234.5, "en-GB")).toBe("1,234.5");
|
||||
});
|
||||
|
||||
it("formats decimals with grouping in en-US", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = formatNumber(1234567.89, "en-US", {
|
||||
style: "decimal",
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
expect(result).toBe("1,234,567.89");
|
||||
});
|
||||
|
||||
it("formats currency with currencyDisplay code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = formatNumber(42, "en-GB", {
|
||||
style: "currency",
|
||||
currency: "GBP",
|
||||
currencyDisplay: "code",
|
||||
});
|
||||
expect(result).toContain("GBP");
|
||||
expect(result).not.toContain("£");
|
||||
});
|
||||
|
||||
it("formats percent by multiplying by 100", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = formatNumber(0.255, "en-US", {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
expect(result).toBe("25.5%");
|
||||
});
|
||||
|
||||
it("formatNumber formats percent style", { timeout: 5000 }, () => {
|
||||
expect(formatNumber(0.5, "en-GB", { style: "percent" })).toBe("50%");
|
||||
});
|
||||
|
||||
it("formatNumber output twins NumberFormatter.format output", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const opts = {
|
||||
style: "currency" as const,
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
};
|
||||
const standalone = formatNumber(42.5, "en-US", opts);
|
||||
const klass = new NumberFormatter(opts).format(42.5, "en-US");
|
||||
expect(standalone).toBe(klass);
|
||||
});
|
||||
|
||||
it("supports compact notation", { timeout: 5000 }, () => {
|
||||
const result = formatNumber(1234, "en-US", {
|
||||
notation: "compact",
|
||||
});
|
||||
expect(result).toMatch(/1\.2K/);
|
||||
});
|
||||
|
||||
it("supports scientific notation", { timeout: 5000 }, () => {
|
||||
const result = formatNumber(123456, "en-US", {
|
||||
notation: "scientific",
|
||||
});
|
||||
expect(result).toMatch(/E5/i);
|
||||
});
|
||||
|
||||
it("supports engineering notation", { timeout: 5000 }, () => {
|
||||
const result = formatNumber(123456, "en-US", {
|
||||
notation: "engineering",
|
||||
});
|
||||
// Engineering: like "123.456E3"
|
||||
expect(result).toContain("E");
|
||||
});
|
||||
|
||||
it("supports useGrouping=false", { timeout: 5000 }, () => {
|
||||
const result = formatNumber(1234567, "en-US", {
|
||||
useGrouping: false,
|
||||
});
|
||||
expect(result).toBe("1234567");
|
||||
});
|
||||
|
||||
it("supports signDisplay=always", { timeout: 5000 }, () => {
|
||||
const result = formatNumber(42, "en-US", {
|
||||
signDisplay: "always",
|
||||
});
|
||||
expect(result.startsWith("+")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses 'standard' style label when style is undefined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Smoke: not a Zod failure; default branch in style resolution.
|
||||
const result = formatNumber(1, "en-US");
|
||||
expect(result).toBe("1");
|
||||
});
|
||||
|
||||
it("NumberFormatter rejects invalid options with INVALID_FORMATTER_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new NumberFormatter({ style: "invalid" as never });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(
|
||||
I18N_CODES.INVALID_FORMATTER_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("unsupported locale throws UNSUPPORTED_LOCALE with cause", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
formatNumber(1, "xx-bogus-tag-!!!");
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(I18nError);
|
||||
expect((err as I18nError).code).toBe(I18N_CODES.UNSUPPORTED_LOCALE);
|
||||
expect((err as I18nError).cause).toBeInstanceOf(RangeError);
|
||||
}
|
||||
});
|
||||
|
||||
it("identical options reuse cached Intl.NumberFormat", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
clearNumberFormatterCache();
|
||||
// First call constructs and caches; second call reuses.
|
||||
formatNumber(1, "en-GB", { style: "decimal" });
|
||||
// Spy on the global to count construction calls.
|
||||
const original = Intl.NumberFormat;
|
||||
let constructed = 0;
|
||||
// biome lint default permits this construction-counter spy.
|
||||
const spy = function (
|
||||
this: unknown,
|
||||
...args: ConstructorParameters<typeof Intl.NumberFormat>
|
||||
): Intl.NumberFormat {
|
||||
constructed += 1;
|
||||
return new original(...args);
|
||||
} as unknown as typeof Intl.NumberFormat;
|
||||
Object.defineProperty(globalThis.Intl, "NumberFormat", {
|
||||
value: spy,
|
||||
configurable: true,
|
||||
});
|
||||
try {
|
||||
formatNumber(1, "en-GB", { style: "decimal" });
|
||||
} finally {
|
||||
Object.defineProperty(globalThis.Intl, "NumberFormat", {
|
||||
value: original,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
expect(constructed).toBe(0);
|
||||
});
|
||||
|
||||
it("distinct options create distinct cache entries", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
clearNumberFormatterCache();
|
||||
const a = formatNumber(1, "en-GB", { style: "decimal" });
|
||||
const b = formatNumber(1, "en-GB", { style: "percent" });
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("clearNumberFormatterCache resets cache state", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
clearNumberFormatterCache();
|
||||
formatNumber(1, "en-US", { style: "decimal" });
|
||||
formatNumber(1, "en-US", { style: "decimal" });
|
||||
clearNumberFormatterCache();
|
||||
// Subsequent call must miss the cache (no throw).
|
||||
expect(formatNumber(1, "en-US", { style: "decimal" })).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("NumberFormatter class", () => {
|
||||
it("formats via stored options", { timeout: 5000 }, () => {
|
||||
const formatter = new NumberFormatter({
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
});
|
||||
expect(formatter.format(42, "en-US")).toContain("$42");
|
||||
});
|
||||
|
||||
it("constructs with no options (defaults to {})", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const formatter = new NumberFormatter();
|
||||
expect(formatter.format(1234, "en-US")).toBe("1,234");
|
||||
});
|
||||
|
||||
it("validates options at construction", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new NumberFormatter({ style: "magic" as never });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as Error).message).toContain("style");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearNumberFormatterCache", () => {
|
||||
it("drops cached Intl.NumberFormat instances", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
clearNumberFormatterCache();
|
||||
formatNumber(1, "en-US", { style: "decimal" });
|
||||
formatNumber(1, "en-US", { style: "decimal" });
|
||||
clearNumberFormatterCache();
|
||||
// Subsequent call must miss the cache (no throw).
|
||||
expect(formatNumber(1, "en-US", { style: "decimal" })).toBe("1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
|
||||
import { NumberFormatterOptionsSchema } from "../formatter.schemas.js";
|
||||
import type {
|
||||
NumberFormatterCacheKey,
|
||||
NumberFormatterOptions,
|
||||
} from "../formatter.types.js";
|
||||
import {
|
||||
recordNumberCacheHit,
|
||||
recordNumberFormat,
|
||||
} from "../observability/observability.js";
|
||||
|
||||
const cache = new Map<NumberFormatterCacheKey, Intl.NumberFormat>();
|
||||
|
||||
function buildKey(
|
||||
locale: string,
|
||||
options: NumberFormatterOptions
|
||||
): NumberFormatterCacheKey {
|
||||
return `${locale}|${JSON.stringify(options)}`;
|
||||
}
|
||||
|
||||
function validateOptions(options: NumberFormatterOptions): void {
|
||||
const parsed = NumberFormatterOptionsSchema.safeParse(options);
|
||||
if (parsed.success) return;
|
||||
const field = String(parsed.error.issues[0]?.path[0] ?? "options");
|
||||
throw new I18nError(
|
||||
I18N_CODES.INVALID_FORMATTER_OPTIONS,
|
||||
`NumberFormatter: invalid options (field: ${field})`,
|
||||
{ context: { field, issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
|
||||
function buildIntlFormat(
|
||||
locale: string,
|
||||
options: NumberFormatterOptions
|
||||
): Intl.NumberFormat {
|
||||
try {
|
||||
return new Intl.NumberFormat(locale, options);
|
||||
} catch (cause) {
|
||||
// Intl.NumberFormat throws RangeError for unsupported locales /
|
||||
// unsupported option values across every conforming runtime.
|
||||
throw new I18nError(
|
||||
I18N_CODES.UNSUPPORTED_LOCALE,
|
||||
`NumberFormatter: unsupported locale '${locale}'`,
|
||||
{ cause, context: { locale } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getOrCreate(
|
||||
locale: string,
|
||||
options: NumberFormatterOptions
|
||||
): { formatter: Intl.NumberFormat; cached: boolean } {
|
||||
const key = buildKey(locale, options);
|
||||
const existing = cache.get(key);
|
||||
if (existing) return { formatter: existing, cached: true };
|
||||
const formatter = buildIntlFormat(locale, options);
|
||||
cache.set(key, formatter);
|
||||
return { formatter, cached: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone number-formatting helper. Wraps `Intl.NumberFormat` with
|
||||
* Zod-validated options, structured error mapping, and a per-(locale,
|
||||
* options-hash) cache.
|
||||
*/
|
||||
export function formatNumber(
|
||||
value: number,
|
||||
locale: string,
|
||||
options: NumberFormatterOptions = {}
|
||||
): string {
|
||||
validateOptions(options);
|
||||
const t0 = performance.now();
|
||||
const { formatter, cached: hit } = getOrCreate(locale, options);
|
||||
const out = formatter.format(value);
|
||||
if (hit) recordNumberCacheHit();
|
||||
recordNumberFormat(
|
||||
locale,
|
||||
options.style ?? "decimal",
|
||||
performance.now() - t0
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class wrapper for `formatNumber` with construction-time option validation
|
||||
* and a stored default-locale.
|
||||
*/
|
||||
export class NumberFormatter {
|
||||
private readonly options: NumberFormatterOptions;
|
||||
|
||||
public constructor(options: NumberFormatterOptions = {}) {
|
||||
validateOptions(options);
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
public format(value: number, locale: string): string {
|
||||
return formatNumber(value, locale, this.options);
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: clear the per-(locale, options-hash) cache. */
|
||||
export function clearNumberFormatterCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
export { NumberFormatterOptionsSchema } from "../formatter.schemas.js";
|
||||
export type {
|
||||
NumberFormatterCacheKey,
|
||||
NumberFormatterCurrencyDisplay,
|
||||
NumberFormatterNotation,
|
||||
NumberFormatterOptions,
|
||||
NumberFormatterSignDisplay,
|
||||
NumberFormatterStyle,
|
||||
} from "../formatter.types.js";
|
||||
@@ -0,0 +1,14 @@
|
||||
export type {
|
||||
NumberFormatterCacheKey,
|
||||
NumberFormatterCurrencyDisplay,
|
||||
NumberFormatterNotation,
|
||||
NumberFormatterOptions,
|
||||
NumberFormatterSignDisplay,
|
||||
NumberFormatterStyle,
|
||||
} from "./formatter.js";
|
||||
export {
|
||||
clearNumberFormatterCache,
|
||||
formatNumber,
|
||||
NumberFormatter,
|
||||
NumberFormatterOptionsSchema,
|
||||
} from "./formatter.js";
|
||||
@@ -0,0 +1,70 @@
|
||||
# Number Helper Formatters
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Pre-bound shortcuts over `formatNumber` and `Intl.NumberFormat` for the three most-asked number-formatting shapes: percentages, compact notation ("1.2K"), and units of measurement ("12 km/h"). Saves callers from spelling out the full options bag every time.
|
||||
|
||||
The percent and compact helpers route through `formatNumber`, so they benefit from its Zod validation and per-(locale, options-hash) cache. `formatUnit` calls `Intl.NumberFormat` directly because `style: 'unit'` is intentionally outside the narrow `NumberFormatterStyle` union.
|
||||
|
||||
## Features
|
||||
|
||||
- `formatPercent(value, locale, options?)` — `style: 'percent'` shortcut
|
||||
- `formatCompact(value, locale, options?)` — `notation: 'compact'` shortcut ("1.2K", "1.2M")
|
||||
- `formatUnit(value, unit, locale, options?)` — `style: 'unit'` shortcut for measurement units
|
||||
- `unit` is any `Intl.NumberFormat` unit identifier (`'kilometer-per-hour'`, `'celsius'`, `'megabyte'`, ...)
|
||||
- `formatUnit` style is `'short' | 'long' | 'narrow'` (default `'short'`)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
formatCompact,
|
||||
formatPercent,
|
||||
formatUnit,
|
||||
} from "@suaveplan/i18n-formatter-number/helpers";
|
||||
|
||||
formatPercent(0.255, "en-US", { maximumFractionDigits: 1 }); // → "25.5%"
|
||||
formatCompact(1234, "en-US"); // → "1.2K"
|
||||
formatUnit(12, "kilometer-per-hour", "en-US"); // → "12 km/h"
|
||||
formatUnit(20, "celsius", "en-US", { style: "long" }); // → "20 degrees Celsius"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example — Stat Block
|
||||
|
||||
```typescript
|
||||
import {
|
||||
formatCompact,
|
||||
formatPercent,
|
||||
formatUnit,
|
||||
} from "@suaveplan/i18n-formatter-number/helpers";
|
||||
|
||||
const stats = {
|
||||
cpuLoad: formatPercent(load, locale),
|
||||
memUsed: formatUnit(memBytes, "megabyte", locale, { style: "long" }),
|
||||
reqsPerMinute: formatCompact(reqs, locale),
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `formatPercent(value, locale, options?)`
|
||||
|
||||
Pre-bound `formatNumber({ style: 'percent', ...options })`. Note that `value` is the proportion (0.25 → "25%"), not the percentage (25).
|
||||
|
||||
### `formatCompact(value, locale, options?)`
|
||||
|
||||
Pre-bound `formatNumber({ notation: 'compact', ...options })`. Defaults to `compactDisplay: 'short'` from `Intl.NumberFormat`. Override via `options.compactDisplay`.
|
||||
|
||||
### `formatUnit(value, unit, locale, options?)`
|
||||
|
||||
Locale-aware unit formatting. The `unit` is one of `Intl.NumberFormat`'s sanctioned units; the [Intl.NumberFormat units list](https://tc39.es/ecma402/#table-sanctioned-single-unit-identifiers) is authoritative. Composite units use `-per-` joiner (`'meter-per-second'`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`formatUnit` does NOT route through `formatNumber` because adding `style: 'unit'` would require widening `NumberFormatterStyle` and `NumberFormatterOptions` to include the unit-specific options. Keeping `formatUnit` as a thin direct wrapper preserves the `formatNumber` surface's stability.
|
||||
|
||||
Compact notation uses the runtime's locale-specific compact patterns. For en-US that's `'1.2K'`, `'1.2M'`, `'1.2B'`; other locales use locale-correct abbreviations (e.g. `'1,2 万'` for ja-JP).
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { formatCompact, formatPercent, formatUnit } from "./helpers.js";
|
||||
|
||||
describe("formatPercent", () => {
|
||||
it("formatPercent renders percent", { timeout: 5000 }, () => {
|
||||
expect(formatPercent(0.5, "en-GB")).toBe("50%");
|
||||
});
|
||||
|
||||
it("formats decimal as percent with default fraction digits", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(formatPercent(0.255, "en-US")).toMatch(/^25%|^26%$/);
|
||||
});
|
||||
|
||||
it("respects maximumFractionDigits", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
formatPercent(0.255, "en-US", { maximumFractionDigits: 1 })
|
||||
).toBe("25.5%");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCompact", () => {
|
||||
it("formatCompact renders 1.5K for 1500", { timeout: 5000 }, () => {
|
||||
expect(formatCompact(1500, "en-US")).toBe("1.5K");
|
||||
});
|
||||
|
||||
it("returns 1.2K for 1234 in en-US", { timeout: 5000 }, () => {
|
||||
expect(formatCompact(1234, "en-US")).toBe("1.2K");
|
||||
});
|
||||
|
||||
it("returns 1.2M for 1_234_567 in en-US", { timeout: 5000 }, () => {
|
||||
expect(formatCompact(1_234_567, "en-US")).toBe("1.2M");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatUnit", () => {
|
||||
it("formatUnit renders unit identifier", { timeout: 5000 }, () => {
|
||||
const r = formatUnit(50, "kilometer-per-hour", "en-GB");
|
||||
expect(r).toContain("50");
|
||||
expect(r).toMatch(/km\/h/);
|
||||
});
|
||||
|
||||
it("formats kilometers-per-hour", { timeout: 5000 }, () => {
|
||||
expect(formatUnit(12, "kilometer-per-hour", "en-US")).toBe("12 km/h");
|
||||
});
|
||||
|
||||
it("formats megabytes", { timeout: 5000 }, () => {
|
||||
expect(formatUnit(5, "megabyte", "en-US")).toMatch(/^5\s?MB$/);
|
||||
});
|
||||
|
||||
it("formats celsius with long style", { timeout: 5000 }, () => {
|
||||
const result = formatUnit(20, "celsius", "en-US", { style: "long" });
|
||||
expect(result.toLowerCase()).toContain("degrees celsius");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { formatNumber } from "../formatter/formatter.js";
|
||||
import type { NumberFormatterOptions } from "../formatter.types.js";
|
||||
|
||||
/**
|
||||
* Pre-bound `formatNumber({ style: 'percent' })` shortcut. Defaults to no
|
||||
* fraction digits unless the caller overrides via `options`.
|
||||
*/
|
||||
export function formatPercent(
|
||||
value: number,
|
||||
locale: string,
|
||||
options: Omit<NumberFormatterOptions, "style"> = {}
|
||||
): string {
|
||||
return formatNumber(value, locale, { ...options, style: "percent" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-bound `formatNumber({ notation: 'compact' })` shortcut. Produces
|
||||
* "1.2K", "1.2M", … in en-US.
|
||||
*/
|
||||
export function formatCompact(
|
||||
value: number,
|
||||
locale: string,
|
||||
options: Omit<NumberFormatterOptions, "notation"> = {}
|
||||
): string {
|
||||
return formatNumber(value, locale, { ...options, notation: "compact" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-bound `formatNumber({ style: 'unit', unit })` shortcut. The `unit`
|
||||
* argument is an `Intl.NumberFormat` unit identifier (e.g.
|
||||
* `'kilometer-per-hour'`, `'celsius'`, `'megabyte'`).
|
||||
*
|
||||
* Internally we route through `Intl.NumberFormat` directly because
|
||||
* `style: 'unit'` is not part of the typed `NumberFormatterStyle` (which
|
||||
* is intentionally narrow).
|
||||
*/
|
||||
export function formatUnit(
|
||||
value: number,
|
||||
unit: string,
|
||||
locale: string,
|
||||
options?: { style?: "long" | "short" | "narrow" }
|
||||
): string {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: "unit",
|
||||
unit,
|
||||
unitDisplay: options?.style ?? "short",
|
||||
}).format(value);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
formatCompact,
|
||||
formatPercent,
|
||||
formatUnit,
|
||||
} from "./helpers.js";
|
||||
@@ -0,0 +1,61 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as root from "./index.js";
|
||||
|
||||
const PACKAGE_JSON_PATH = fileURLToPath(
|
||||
new URL("../package.json", import.meta.url)
|
||||
);
|
||||
|
||||
describe("@suaveplan/i18n-formatter-number root barrel", () => {
|
||||
it("re-exports NumberFormatter class", { timeout: 5000 }, () => {
|
||||
expect(typeof root.NumberFormatter).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports formatNumber standalone", { timeout: 5000 }, () => {
|
||||
expect(typeof root.formatNumber).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports clearNumberFormatterCache", { timeout: 5000 }, () => {
|
||||
expect(typeof root.clearNumberFormatterCache).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports NumberFormatterOptionsSchema", { timeout: 5000 }, () => {
|
||||
expect(root.NumberFormatterOptionsSchema).toBeDefined();
|
||||
});
|
||||
|
||||
it("re-exports formatPercent / formatCompact / formatUnit", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(typeof root.formatPercent).toBe("function");
|
||||
expect(typeof root.formatCompact).toBe("function");
|
||||
expect(typeof root.formatUnit).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports observability surface", { timeout: 5000 }, () => {
|
||||
expect(typeof root.getNumberFormatMetricsState).toBe("function");
|
||||
expect(typeof root.resetNumberFormatMetricsState).toBe("function");
|
||||
expect(typeof root.recordNumberFormat).toBe("function");
|
||||
expect(typeof root.recordNumberCacheHit).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/i18n-formatter-number package.json invariants", () => {
|
||||
it("package.json declares sideEffects false", { timeout: 5000 }, () => {
|
||||
const raw = readFileSync(PACKAGE_JSON_PATH, "utf8");
|
||||
const pkg = JSON.parse(raw) as { sideEffects?: boolean };
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("every documented subpath export resolves", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const raw = readFileSync(PACKAGE_JSON_PATH, "utf8");
|
||||
const pkg = JSON.parse(raw) as { exports?: Record<string, unknown> };
|
||||
const exports = pkg.exports ?? {};
|
||||
const required = [".", "./formatter", "./helpers"];
|
||||
for (const subpath of required) {
|
||||
expect(exports).toHaveProperty(subpath);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
// Root barrel for @suaveplan/i18n-formatter-number. Explicit named
|
||||
// re-exports only — no `export *` per CLAUDE.md §10.
|
||||
|
||||
export type {
|
||||
NumberFormatterCacheKey,
|
||||
NumberFormatterCurrencyDisplay,
|
||||
NumberFormatterNotation,
|
||||
NumberFormatterOptions,
|
||||
NumberFormatterSignDisplay,
|
||||
NumberFormatterStyle,
|
||||
} from "./formatter/index.js";
|
||||
export {
|
||||
clearNumberFormatterCache,
|
||||
formatNumber,
|
||||
NumberFormatter,
|
||||
NumberFormatterOptionsSchema,
|
||||
} from "./formatter/index.js";
|
||||
export { formatCompact, formatPercent, formatUnit } from "./helpers/index.js";
|
||||
export {
|
||||
type FormatMetricSample,
|
||||
getNumberFormatMetricsState,
|
||||
type NumberFormatMetricsState,
|
||||
recordNumberCacheHit,
|
||||
recordNumberFormat,
|
||||
resetNumberFormatMetricsState,
|
||||
} from "./observability/index.js";
|
||||
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
FormatMetricSample,
|
||||
NumberFormatMetricsState,
|
||||
} from "./observability.js";
|
||||
export {
|
||||
getNumberFormatMetricsState,
|
||||
recordNumberCacheHit,
|
||||
recordNumberFormat,
|
||||
resetNumberFormatMetricsState,
|
||||
} from "./observability.js";
|
||||
@@ -0,0 +1,88 @@
|
||||
# NumberFormatter Observability
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Per-package OpenTelemetry instruments for `@suaveplan/i18n-formatter-number`. Exposes a histogram (`i18n.format.number.duration_ms`) recording wall-clock duration of every `formatNumber` call and a counter (`i18n.format.cache_hit`) tagged with `formatter: "number"` that increments on every cache reuse. The two instruments are constructed once via `metrics.getMeter` and are lazily initialised on first record so cold-start latency stays low.
|
||||
|
||||
The module also retains an in-memory sample ring (`getNumberFormatMetricsState()` and `resetNumberFormatMetricsState()`) so spec-conformance tests can assert the exact attribute payloads produced by `recordNumberFormat` and `recordNumberCacheHit` without needing to wire a full SDK exporter inside every test file.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy instrument construction: the first call to `recordNumberFormat` or `recordNumberCacheHit` builds both instruments via the OTel global meter
|
||||
- Stable histogram name `i18n.format.number.duration_ms` with attributes `{ locale, style }`
|
||||
- Stable counter name `i18n.format.cache_hit` with attributes `{ formatter: "number" }`
|
||||
- In-memory sample ring (`numberDurationSamples`, `cacheHitSamples`) for spec-conformance assertions
|
||||
- `resetNumberFormatMetricsState()` clears both arrays so tests stay isolated
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
recordNumberFormat,
|
||||
recordNumberCacheHit,
|
||||
getNumberFormatMetricsState,
|
||||
resetNumberFormatMetricsState,
|
||||
} from "@suaveplan/i18n-formatter-number/observability";
|
||||
|
||||
resetNumberFormatMetricsState();
|
||||
recordNumberFormat("en-GB", "decimal", 1.23);
|
||||
recordNumberCacheHit();
|
||||
const state = getNumberFormatMetricsState();
|
||||
state.numberDurationSamples; // [{ attributes: { locale: "en-GB", style: "decimal" } }]
|
||||
state.cacheHitSamples; // [{ attributes: { formatter: "number" } }]
|
||||
```
|
||||
|
||||
## Usage: Advanced Example — Asserting OTel emission in a test
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { formatNumber, clearNumberFormatterCache } from "@suaveplan/i18n-formatter-number";
|
||||
import {
|
||||
getNumberFormatMetricsState,
|
||||
resetNumberFormatMetricsState,
|
||||
} from "@suaveplan/i18n-formatter-number/observability";
|
||||
|
||||
describe("metrics emission", () => {
|
||||
it("emits duration histogram and cache-hit counter", { timeout: 5000 }, () => {
|
||||
clearNumberFormatterCache();
|
||||
resetNumberFormatMetricsState();
|
||||
formatNumber(1, "en-GB", { style: "decimal" });
|
||||
formatNumber(1, "en-GB", { style: "decimal" });
|
||||
const state = getNumberFormatMetricsState();
|
||||
expect(state.numberDurationSamples).toHaveLength(2);
|
||||
expect(state.cacheHitSamples).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `recordNumberFormat(locale: string, style: string, durationMs: number): void`
|
||||
|
||||
Records one observation on the `i18n.format.number.duration_ms` histogram and pushes a `{ attributes: { locale, style } }` entry into the in-memory sample ring.
|
||||
|
||||
### `recordNumberCacheHit(): void`
|
||||
|
||||
Adds 1 to the `i18n.format.cache_hit` counter with `formatter: "number"` and pushes a matching entry into the in-memory sample ring.
|
||||
|
||||
### `getNumberFormatMetricsState(): NumberFormatMetricsState`
|
||||
|
||||
Returns the in-memory sample ring (live reference — do not mutate). Use `resetNumberFormatMetricsState` between tests to keep state isolated.
|
||||
|
||||
### `resetNumberFormatMetricsState(): void`
|
||||
|
||||
Empties both sample arrays in place.
|
||||
|
||||
### `NumberFormatMetricsState`
|
||||
|
||||
`{ numberDurationSamples: FormatMetricSample[]; cacheHitSamples: FormatMetricSample[] }` where `FormatMetricSample = { attributes: Record<string, string | number> }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The instruments are built lazily so that consumers who never call `formatNumber` (e.g. tree-shaken builds that import only types) do not pay the OTel meter-lookup cost. The cache check is a strict identity check against the closure-scoped `cached` slot — once built, the instruments are never rebuilt, even across `resetNumberFormatMetricsState` calls (resetting state does not invalidate the meter).
|
||||
|
||||
The module deliberately keeps the in-memory sample ring unbounded. Tests should call `resetNumberFormatMetricsState()` between cases. Long-running production processes do not retain the ring because the slim-core wave swaps the in-package observability module for the unified one in `@suaveplan/i18n` core; for now, every leaf package owns its own ring per the design doc's per-package shape.
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
clearNumberFormatterCache,
|
||||
formatNumber,
|
||||
} from "../formatter/formatter.js";
|
||||
import {
|
||||
getNumberFormatMetricsState,
|
||||
recordNumberCacheHit,
|
||||
recordNumberFormat,
|
||||
resetNumberFormatMetricsState,
|
||||
} from "./observability.js";
|
||||
|
||||
describe("NumberFormatter observability", () => {
|
||||
it("emits i18n.format.number.duration_ms histogram on every call", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
clearNumberFormatterCache();
|
||||
resetNumberFormatMetricsState();
|
||||
formatNumber(1, "en-GB", { style: "decimal" });
|
||||
const state = getNumberFormatMetricsState();
|
||||
expect(state.numberDurationSamples).toHaveLength(1);
|
||||
expect(state.numberDurationSamples[0]?.attributes).toMatchObject({
|
||||
locale: "en-GB",
|
||||
style: "decimal",
|
||||
});
|
||||
});
|
||||
|
||||
it("cache-hit counter increments only on second call with same options", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
clearNumberFormatterCache();
|
||||
resetNumberFormatMetricsState();
|
||||
formatNumber(1, "en-GB", { style: "decimal" });
|
||||
expect(getNumberFormatMetricsState().cacheHitSamples).toHaveLength(0);
|
||||
formatNumber(1, "en-GB", { style: "decimal" });
|
||||
const state = getNumberFormatMetricsState();
|
||||
expect(state.cacheHitSamples).toHaveLength(1);
|
||||
expect(state.cacheHitSamples[0]?.attributes.formatter).toBe("number");
|
||||
});
|
||||
|
||||
it("recordNumberFormat exposed for direct use stays consistent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetNumberFormatMetricsState();
|
||||
recordNumberFormat("fr-FR", "currency", 0.1);
|
||||
const state = getNumberFormatMetricsState();
|
||||
expect(state.numberDurationSamples).toHaveLength(1);
|
||||
expect(state.numberDurationSamples[0]?.attributes).toMatchObject({
|
||||
locale: "fr-FR",
|
||||
style: "currency",
|
||||
});
|
||||
});
|
||||
|
||||
it("recordNumberCacheHit can be invoked directly", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetNumberFormatMetricsState();
|
||||
recordNumberCacheHit();
|
||||
recordNumberCacheHit();
|
||||
const state = getNumberFormatMetricsState();
|
||||
expect(state.cacheHitSamples).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("instruments are reused across resets (no double-construction)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Force the lazy-init path twice to ensure the second call is a no-op.
|
||||
resetNumberFormatMetricsState();
|
||||
recordNumberFormat("en-GB", "decimal", 0.1);
|
||||
recordNumberFormat("en-GB", "decimal", 0.2);
|
||||
const state = getNumberFormatMetricsState();
|
||||
expect(state.numberDurationSamples).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* OTel instruments for the number formatter.
|
||||
*
|
||||
* Histogram records per-format wall-clock duration; the counter
|
||||
* increments on every cache reuse so consumers can observe cache
|
||||
* effectiveness.
|
||||
*
|
||||
* In addition to OTel emission, the module retains a small in-memory
|
||||
* state ring so spec-conformance tests can assert exact attribute
|
||||
* payloads without standing up a full SDK exporter.
|
||||
*/
|
||||
|
||||
export interface FormatMetricSample {
|
||||
readonly attributes: Record<string, string | number>;
|
||||
}
|
||||
|
||||
export interface NumberFormatMetricsState {
|
||||
readonly numberDurationSamples: FormatMetricSample[];
|
||||
readonly cacheHitSamples: FormatMetricSample[];
|
||||
}
|
||||
|
||||
let cached:
|
||||
| {
|
||||
readonly numberDurationMs: Histogram;
|
||||
readonly cacheHit: Counter;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const state: NumberFormatMetricsState = {
|
||||
numberDurationSamples: [],
|
||||
cacheHitSamples: [],
|
||||
};
|
||||
|
||||
function ensureInstruments() {
|
||||
if (cached) return cached;
|
||||
const meter = metrics.getMeter("suaveplan.i18n-formatter-number");
|
||||
cached = {
|
||||
numberDurationMs: meter.createHistogram(
|
||||
"i18n.format.number.duration_ms",
|
||||
{
|
||||
description:
|
||||
"Duration in ms of NumberFormatter.format; labels: locale, style.",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
cacheHit: meter.createCounter("i18n.format.cache_hit", {
|
||||
description:
|
||||
'Format-time cache reuse counter; labels: formatter ("number").',
|
||||
}),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function recordNumberFormat(
|
||||
locale: string,
|
||||
style: string,
|
||||
durationMs: number
|
||||
): void {
|
||||
const ins = ensureInstruments();
|
||||
const attributes = { locale, style };
|
||||
ins.numberDurationMs.record(durationMs, attributes);
|
||||
state.numberDurationSamples.push({ attributes });
|
||||
}
|
||||
|
||||
export function recordNumberCacheHit(): void {
|
||||
const ins = ensureInstruments();
|
||||
const attributes = { formatter: "number" };
|
||||
ins.cacheHit.add(1, attributes);
|
||||
state.cacheHitSamples.push({ attributes });
|
||||
}
|
||||
|
||||
export function getNumberFormatMetricsState(): NumberFormatMetricsState {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function resetNumberFormatMetricsState(): void {
|
||||
state.numberDurationSamples.length = 0;
|
||||
state.cacheHitSamples.length = 0;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/i18n-formatter-number",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/i18n-shared",
|
||||
"@suaveplan/types",
|
||||
"@opentelemetry/api",
|
||||
"tslib",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
include: ["src/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/index.ts",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user