Initial Commit
This commit is contained in:
107
packages/localization/i18n/i18n-formatter-list/README.md
Normal file
107
packages/localization/i18n/i18n-formatter-list/README.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# @suaveplan/i18n-formatter-list - Locale-aware list joining backed by Intl.ListFormat. Class wrapper + standalone helper + ergonomic helpers (formatConjunction, formatDisjunction), Zod-validated options, per-(locale, style, type) cache, OTel histogram + cache-hit counter. Wave 3 of the @suaveplan/i18n modular split.
|
||||
|
||||
## Summary
|
||||
|
||||
Locale-aware list joining backed by `Intl.ListFormat`. Wave 3 of the
|
||||
14-package i18n modular split. Provides both a class
|
||||
(`ListFormatter`) and a standalone helper (`formatList`) plus
|
||||
ergonomic shortcuts (`formatConjunction`, `formatDisjunction`) on
|
||||
top of a per-`(locale, style, type)` 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 list joining backed by `Intl.ListFormat`. Joins an array of strings with the locale-correct connector (`"a…
|
||||
- [formatter](./src/formatter/formatter.md) — Locale-aware list joining backed by `Intl.ListFormat`. Joins an array of strings with the locale-correct connector (`"a…
|
||||
- [helpers](./src/helpers/helpers.md) — Pre-bound shortcuts over `formatList` for the two most common list-formatting shapes: conjunction joining ("a, b, and c…
|
||||
- [helpers](./src/helpers/helpers.md) — Pre-bound shortcuts over `formatList` for the two most common list-formatting shapes: conjunction joining ("a, b, and c…
|
||||
- [observability](./src/observability/observability.md) — Per-package OpenTelemetry instruments for `@suaveplan/i18n-formatter-list`. Exposes a histogram (`i18n.format.list.dura…
|
||||
- [observability](./src/observability/observability.md) — Per-package OpenTelemetry instruments for `@suaveplan/i18n-formatter-list`. Exposes a histogram (`i18n.format.list.dura…
|
||||
<!-- 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
|
||||
list-formatter slice of Wave 3. The monolith ships only a thin
|
||||
standalone helper and per-cache module — this package brings the
|
||||
canonical Wave 3 shape on top: class wrapper for symmetry with
|
||||
`NumberFormatter`, Zod schema validating options at the boundary,
|
||||
mapping of Zod failure to `INVALID_FORMATTER_OPTIONS`, and OTel
|
||||
emission for parity with the rest of the family.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/i18n-formatter-list
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import {
|
||||
formatConjunction,
|
||||
formatDisjunction,
|
||||
formatList,
|
||||
ListFormatter,
|
||||
} from "@suaveplan/i18n-formatter-list";
|
||||
|
||||
formatList(["apples", "oranges", "bananas"], "en-US");
|
||||
// → "apples, oranges, and bananas"
|
||||
|
||||
formatList(["apples", "oranges"], "en-US", { type: "disjunction" });
|
||||
// → "apples or oranges"
|
||||
|
||||
formatConjunction(["a", "b", "c"], "en-GB"); // → "a, b, and c"
|
||||
formatDisjunction(["x", "y"], "en-GB"); // → "x or y"
|
||||
|
||||
const compact = new ListFormatter({ style: "short", type: "unit" });
|
||||
compact.format(["mon", "tue", "wed"], "en-US"); // → "mon, tue, wed"
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Subpath | Description |
|
||||
|---|---|
|
||||
| `@suaveplan/i18n-formatter-list` | Root barrel — class + standalone + helpers + observability |
|
||||
| `@suaveplan/i18n-formatter-list/formatter` | `ListFormatter`, `formatList`, `clearListFormatterCache`, types, schema |
|
||||
| `@suaveplan/i18n-formatter-list/helpers` | `formatConjunction`, `formatDisjunction` only |
|
||||
|
||||
## Modules Documentation
|
||||
|
||||
- [formatter.md](src/formatter.md) — `ListFormatter` class + `formatList`
|
||||
- [helpers.md](src/helpers.md) — `formatConjunction`, `formatDisjunction`
|
||||
- [observability.md](src/observability.md) — OTel histogram + cache-hit counter
|
||||
|
||||
## Public Surface (snapshot)
|
||||
|
||||
| Export | Kind | Origin |
|
||||
|---|---|---|
|
||||
| `ListFormatter` | class | `formatter.ts` |
|
||||
| `formatList` | function | `formatter.ts` |
|
||||
| `clearListFormatterCache` | function | `formatter.ts` |
|
||||
| `ListFormatterOptionsSchema` | const | `formatter.ts` |
|
||||
| `ListFormatStyle` | type | `formatter.types.ts` |
|
||||
| `ListFormatType` | type | `formatter.types.ts` |
|
||||
| `ListFormatterOptions` | type | `formatter.types.ts` |
|
||||
| `ListFormatterCacheKey` | type | `formatter.types.ts` |
|
||||
| `formatConjunction` | function | `helpers.ts` |
|
||||
| `formatDisjunction` | function | `helpers.ts` |
|
||||
| `recordListFormat` | function | `observability.ts` |
|
||||
| `recordListCacheHit` | function | `observability.ts` |
|
||||
| `getListFormatMetricsState` | function | `observability.ts` |
|
||||
| `resetListFormatMetricsState` | 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/list/` is preserved
|
||||
in the monolith until Wave 6 deletes it.
|
||||
|
||||
75
packages/localization/i18n/i18n-formatter-list/package.json
Normal file
75
packages/localization/i18n/i18n-formatter-list/package.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "@suaveplan/i18n-formatter-list",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Locale-aware list joining backed by Intl.ListFormat. Class wrapper + standalone helper + ergonomic helpers (formatConjunction, formatDisjunction), Zod-validated options, per-(locale, style, type) 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,10 @@
|
||||
import z from "zod";
|
||||
|
||||
export const ListFormatterOptionsSchema = z.object({
|
||||
style: z.enum(["long", "short", "narrow"]).optional(),
|
||||
type: z.enum(["conjunction", "disjunction", "unit"]).optional(),
|
||||
});
|
||||
|
||||
export type ListFormatterOptionsInput = z.input<
|
||||
typeof ListFormatterOptionsSchema
|
||||
>;
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Spec-conformance pins for ListFormatter — 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 {
|
||||
ListFormatStyle,
|
||||
ListFormatType,
|
||||
ListFormatterCacheKey,
|
||||
ListFormatterOptions,
|
||||
} from "./formatter/formatter.js";
|
||||
import {
|
||||
clearListFormatterCache,
|
||||
formatList,
|
||||
ListFormatter,
|
||||
ListFormatterOptionsSchema,
|
||||
} from "./formatter/formatter.js";
|
||||
import {
|
||||
getListFormatMetricsState,
|
||||
resetListFormatMetricsState,
|
||||
} from "./observability/observability.js";
|
||||
|
||||
describe("ListFormatter spec-conformance", () => {
|
||||
it("rejects non-object options shape (no path → field='options')", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
formatList(["a"], "en-GB", "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 ListFormatter({ 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 unknown type with INVALID_FORMATTER_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
formatList(["a"], "en-GB", { type: "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: "type",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("maps Intl RangeError to UNSUPPORTED_LOCALE", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
clearListFormatterCache();
|
||||
try {
|
||||
formatList(["a"], "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.ListFormat on identical key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
clearListFormatterCache();
|
||||
resetListFormatMetricsState();
|
||||
formatList(["a"], "en-GB");
|
||||
formatList(["a"], "en-GB");
|
||||
const state = getListFormatMetricsState();
|
||||
expect(state.cacheHitSamples).toHaveLength(1);
|
||||
expect(state.cacheHitSamples[0]?.attributes.formatter).toBe("list");
|
||||
});
|
||||
|
||||
it("does not reuse cache across distinct types", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
clearListFormatterCache();
|
||||
resetListFormatMetricsState();
|
||||
formatList(["a"], "en-GB", { type: "conjunction" });
|
||||
formatList(["a"], "en-GB", { type: "disjunction" });
|
||||
const state = getListFormatMetricsState();
|
||||
expect(state.cacheHitSamples).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("records a duration_ms sample per call", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
clearListFormatterCache();
|
||||
resetListFormatMetricsState();
|
||||
formatList(["a"], "en-GB");
|
||||
const state = getListFormatMetricsState();
|
||||
expect(state.listDurationSamples).toHaveLength(1);
|
||||
expect(state.listDurationSamples[0]?.attributes).toMatchObject({
|
||||
locale: "en-GB",
|
||||
style: "long",
|
||||
type: "conjunction",
|
||||
});
|
||||
});
|
||||
|
||||
it("type assertions pin the public surface", { timeout: 5000 }, () => {
|
||||
expectTypeOf<ListFormatStyle>().toEqualTypeOf<
|
||||
"long" | "short" | "narrow"
|
||||
>();
|
||||
expectTypeOf<ListFormatType>().toEqualTypeOf<
|
||||
"conjunction" | "disjunction" | "unit"
|
||||
>();
|
||||
expectTypeOf<ListFormatterCacheKey>().toEqualTypeOf<string>();
|
||||
expectTypeOf<ListFormatterOptions>().toMatchTypeOf<{
|
||||
readonly style?: ListFormatStyle;
|
||||
}>();
|
||||
});
|
||||
|
||||
it("schema accepts a well-formed options bag", { timeout: 5000 }, () => {
|
||||
const parsed = ListFormatterOptionsSchema.safeParse({
|
||||
style: "long",
|
||||
type: "conjunction",
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
/** List formatter type definitions. */
|
||||
|
||||
export type ListFormatStyle = "long" | "short" | "narrow";
|
||||
export type ListFormatType = "conjunction" | "disjunction" | "unit";
|
||||
|
||||
export interface ListFormatterOptions {
|
||||
readonly style?: ListFormatStyle;
|
||||
readonly type?: ListFormatType;
|
||||
}
|
||||
|
||||
/** Cache key shape: `${locale}|${style}|${type}`. */
|
||||
export type ListFormatterCacheKey = string;
|
||||
@@ -0,0 +1,82 @@
|
||||
# ListFormatter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Locale-aware list joining backed by `Intl.ListFormat`. Joins an array of strings with the locale-correct connector (`"apples, oranges, and bananas"` in `en-US`). Supports conjunction (`and`), disjunction (`or`), and unit list types, plus three styles (`long`, `short`, `narrow`).
|
||||
|
||||
This package is the Wave 3 list-formatter slice — the monolith ships only the standalone helper and per-cache module; this package adds the canonical Wave 3 shape on top: a `ListFormatter` class wrapper for symmetry with `NumberFormatter` / `RelativeTimeFormatter` / `DateFormatter`, a Zod schema validating options at the boundary, mapping of `Intl` `RangeError` to `I18N.UNSUPPORTED_LOCALE`, mapping of Zod failure to `I18N.INVALID_FORMATTER_OPTIONS`, and an OTel histogram + cache-hit counter for per-formatter parity.
|
||||
|
||||
The package caches `Intl.ListFormat` per `(locale, style, type)` so dashboards rendering many comma-separated lists do not pay the construction cost on every call. Cache reuse is opaque to callers and increments `i18n.format.cache_hit{formatter="list"}`.
|
||||
|
||||
## Features
|
||||
|
||||
- Class form (`ListFormatter`) for fixed-options reuse + standalone helper (`formatList`) for one-off calls
|
||||
- `style: 'long' | 'short' | 'narrow'` (default `'long'`)
|
||||
- `type: 'conjunction' | 'disjunction' | 'unit'` (default `'conjunction'`)
|
||||
- Zod-validated options at construction; unknown style/type rejected with structured `INVALID_FORMATTER_OPTIONS`
|
||||
- Stable error codes: `I18N.INVALID_FORMATTER_OPTIONS`, `I18N.UNSUPPORTED_LOCALE`
|
||||
- Process-wide `Intl.ListFormat` cache; `clearListFormatterCache()` for tests
|
||||
- OTel instrumentation: `i18n.format.list.duration_ms` histogram, `i18n.format.cache_hit{formatter="list"}` counter
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { formatList, ListFormatter } from "@suaveplan/i18n-formatter-list";
|
||||
|
||||
formatList(["apples", "oranges", "bananas"], "en-US");
|
||||
// → "apples, oranges, and bananas"
|
||||
|
||||
formatList(["apples", "oranges"], "en-US", { type: "disjunction" });
|
||||
// → "apples or oranges"
|
||||
|
||||
const compact = new ListFormatter({ style: "short", type: "unit" });
|
||||
compact.format(["mon", "tue", "wed"], "en-US");
|
||||
// → "mon, tue, wed"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example — Building Tag Lists
|
||||
|
||||
```typescript
|
||||
import { ListFormatter } from "@suaveplan/i18n-formatter-list";
|
||||
|
||||
const tagList = new ListFormatter({ type: "unit", style: "short" });
|
||||
function summariseTags(items, locale) {
|
||||
const tags = items.map((item) => item.label);
|
||||
return `Tagged: ${tagList.format(tags, locale)}`;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `formatList(values, locale, options?): string`
|
||||
|
||||
Joins `values` per `locale` and `options`. Caches the underlying `Intl.ListFormat` per `(locale, style, type)`. Validates options through Zod, throws `I18nError(I18N.INVALID_FORMATTER_OPTIONS)` on failure. Maps `Intl.ListFormat`'s `RangeError` for unsupported locales to `I18nError(I18N.UNSUPPORTED_LOCALE)`.
|
||||
|
||||
### `class ListFormatter`
|
||||
|
||||
- `constructor(options?: ListFormatterOptions)` — validates options at construction
|
||||
- `format(values: readonly string[], locale: string): string` — formats with stored options
|
||||
|
||||
### `clearListFormatterCache(): void`
|
||||
|
||||
Test-only.
|
||||
|
||||
### `ListFormatterOptionsSchema`
|
||||
|
||||
Zod schema validating the `ListFormatterOptions` surface.
|
||||
|
||||
### Types
|
||||
|
||||
`ListFormatStyle`, `ListFormatType`, `ListFormatterOptions`, `ListFormatterCacheKey`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`Intl.ListFormat` accepts a readonly array but the type signature on Node 22+ requires a mutable array; we cast at the boundary. The cast is safe because `Intl.ListFormat.format` reads but does not mutate the input.
|
||||
|
||||
`type: 'unit'` is intended for units of measurement (e.g. measurement parts in `formatNumber({ style: 'unit' })`). For free-form lists prefer `'conjunction'` or `'disjunction'`.
|
||||
|
||||
The cache key is `${locale}|${style}|${type}` rather than a JSON-stringified options bag because `Intl.ListFormat` has only three knobs. This is symmetric with the `RelativeTimeFormatter` cache strategy (and distinct from the `NumberFormatter` cache strategy which uses JSON-stringified options because the option surface is much wider).
|
||||
@@ -0,0 +1,150 @@
|
||||
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
clearListFormatterCache,
|
||||
formatList,
|
||||
ListFormatter,
|
||||
} from "./formatter.js";
|
||||
|
||||
describe("formatList", () => {
|
||||
it("ListFormatter formats with default options", { timeout: 5000 }, () => {
|
||||
const f = new ListFormatter();
|
||||
// en-US uses the Oxford comma ("a, b, and c"); en-GB does not.
|
||||
expect(f.format(["a", "b", "c"], "en-US")).toBe("a, b, and c");
|
||||
});
|
||||
|
||||
it("conjunction joins with locale-specific connector", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(formatList(["apples", "oranges", "bananas"], "en-US")).toBe(
|
||||
"apples, oranges, and bananas"
|
||||
);
|
||||
});
|
||||
|
||||
it("formatList formats disjunction", { timeout: 5000 }, () => {
|
||||
expect(formatList(["x", "y"], "en-GB", { type: "disjunction" })).toBe(
|
||||
"x or y"
|
||||
);
|
||||
});
|
||||
|
||||
it("disjunction joins with 'or'", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
formatList(["apples", "oranges"], "en-US", { type: "disjunction" })
|
||||
).toBe("apples or oranges");
|
||||
});
|
||||
|
||||
it("formatList output twins ListFormatter.format output", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const opts = { type: "disjunction" as const, style: "short" as const };
|
||||
const standalone = formatList(["a", "b"], "en-GB", opts);
|
||||
const klass = new ListFormatter(opts).format(["a", "b"], "en-GB");
|
||||
expect(standalone).toBe(klass);
|
||||
});
|
||||
|
||||
it("short style uses '&' connector", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
formatList(["apples", "oranges"], "en-US", { style: "short" })
|
||||
).toMatch(/apples,? & oranges|apples, & oranges/);
|
||||
});
|
||||
|
||||
it("two-element list", { timeout: 5000 }, () => {
|
||||
expect(formatList(["a", "b"], "en-US")).toBe("a and b");
|
||||
});
|
||||
|
||||
it("single-element list", { timeout: 5000 }, () => {
|
||||
expect(formatList(["only"], "en-US")).toBe("only");
|
||||
});
|
||||
|
||||
it("ListFormatter rejects invalid options with INVALID_FORMATTER_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
new ListFormatter({ type: "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,
|
||||
}, () => {
|
||||
clearListFormatterCache();
|
||||
try {
|
||||
formatList(["a"], "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 key reuses cached Intl.ListFormat", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
clearListFormatterCache();
|
||||
formatList(["a"], "en-GB");
|
||||
const original = Intl.ListFormat;
|
||||
let constructed = 0;
|
||||
const spy = function (
|
||||
this: unknown,
|
||||
...args: ConstructorParameters<typeof Intl.ListFormat>
|
||||
): Intl.ListFormat {
|
||||
constructed += 1;
|
||||
return new original(...args);
|
||||
} as unknown as typeof Intl.ListFormat;
|
||||
Object.defineProperty(globalThis.Intl, "ListFormat", {
|
||||
value: spy,
|
||||
configurable: true,
|
||||
});
|
||||
try {
|
||||
formatList(["a"], "en-GB");
|
||||
} finally {
|
||||
Object.defineProperty(globalThis.Intl, "ListFormat", {
|
||||
value: original,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
expect(constructed).toBe(0);
|
||||
});
|
||||
|
||||
it("distinct type creates distinct cache entries", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
clearListFormatterCache();
|
||||
const a = formatList(["a", "b"], "en-GB", { type: "conjunction" });
|
||||
const b = formatList(["a", "b"], "en-GB", { type: "disjunction" });
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("clearListFormatterCache drops cached entries", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
clearListFormatterCache();
|
||||
formatList(["a", "b"], "en-US");
|
||||
clearListFormatterCache();
|
||||
// Subsequent call must not throw (re-creates formatter).
|
||||
expect(formatList(["a", "b"], "en-US")).toBe("a and b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ListFormatter class", () => {
|
||||
it("constructs with no options (defaults to {})", { timeout: 5000 }, () => {
|
||||
const f = new ListFormatter();
|
||||
expect(f.format(["only"], "en-US")).toBe("only");
|
||||
});
|
||||
|
||||
it("validates options at construction", { timeout: 5000 }, () => {
|
||||
try {
|
||||
new ListFormatter({ style: "magic" as never });
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as Error).message).toContain("style");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
|
||||
import { ListFormatterOptionsSchema } from "../formatter.schemas.js";
|
||||
import type {
|
||||
ListFormatStyle,
|
||||
ListFormatType,
|
||||
ListFormatterCacheKey,
|
||||
ListFormatterOptions,
|
||||
} from "../formatter.types.js";
|
||||
import {
|
||||
recordListCacheHit,
|
||||
recordListFormat,
|
||||
} from "../observability/observability.js";
|
||||
|
||||
const cache = new Map<ListFormatterCacheKey, Intl.ListFormat>();
|
||||
|
||||
function buildKey(
|
||||
locale: string,
|
||||
style: ListFormatStyle,
|
||||
type: ListFormatType
|
||||
): ListFormatterCacheKey {
|
||||
return `${locale}|${style}|${type}`;
|
||||
}
|
||||
|
||||
function validateOptions(options: ListFormatterOptions): void {
|
||||
const parsed = ListFormatterOptionsSchema.safeParse(options);
|
||||
if (parsed.success) return;
|
||||
const field = String(parsed.error.issues[0]?.path[0] ?? "options");
|
||||
throw new I18nError(
|
||||
I18N_CODES.INVALID_FORMATTER_OPTIONS,
|
||||
`ListFormatter: invalid options (field: ${field})`,
|
||||
{ context: { field, issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
|
||||
function buildIntl(
|
||||
locale: string,
|
||||
style: ListFormatStyle,
|
||||
type: ListFormatType
|
||||
): Intl.ListFormat {
|
||||
try {
|
||||
return new Intl.ListFormat(locale, { style, type });
|
||||
} catch (cause) {
|
||||
throw new I18nError(
|
||||
I18N_CODES.UNSUPPORTED_LOCALE,
|
||||
`ListFormatter: unsupported locale '${locale}'`,
|
||||
{ cause, context: { locale } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getOrCreate(
|
||||
locale: string,
|
||||
style: ListFormatStyle,
|
||||
type: ListFormatType
|
||||
): { formatter: Intl.ListFormat; cached: boolean } {
|
||||
const key = buildKey(locale, style, type);
|
||||
const existing = cache.get(key);
|
||||
if (existing) return { formatter: existing, cached: true };
|
||||
const formatter = buildIntl(locale, style, type);
|
||||
cache.set(key, formatter);
|
||||
return { formatter, cached: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone list-formatting helper. Joins `values` per `locale` with the
|
||||
* locale-correct connector. Validates options through Zod, caches the
|
||||
* underlying `Intl.ListFormat` per `(locale, style, type)`, and records
|
||||
* an OTel histogram + cache-hit counter.
|
||||
*/
|
||||
export function formatList(
|
||||
values: readonly string[],
|
||||
locale: string,
|
||||
options: ListFormatterOptions = {}
|
||||
): string {
|
||||
validateOptions(options);
|
||||
const t0 = performance.now();
|
||||
const style = options.style ?? "long";
|
||||
const type = options.type ?? "conjunction";
|
||||
const { formatter, cached: hit } = getOrCreate(locale, style, type);
|
||||
// Intl.ListFormat.format reads but does not mutate the input; the
|
||||
// signature requires a mutable array on Node 22+ so we cast at the
|
||||
// boundary.
|
||||
const out = formatter.format(values as string[]);
|
||||
if (hit) recordListCacheHit();
|
||||
recordListFormat(locale, style, type, performance.now() - t0);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class wrapper for `formatList` with construction-time option validation
|
||||
* and stable defaults. Symmetric with `NumberFormatter` /
|
||||
* `RelativeTimeFormatter` / `DateFormatter`.
|
||||
*/
|
||||
export class ListFormatter {
|
||||
private readonly options: ListFormatterOptions;
|
||||
|
||||
public constructor(options: ListFormatterOptions = {}) {
|
||||
validateOptions(options);
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
public format(values: readonly string[], locale: string): string {
|
||||
return formatList(values, locale, this.options);
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: clear the per-(locale, style, type) cache. */
|
||||
export function clearListFormatterCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
export { ListFormatterOptionsSchema } from "../formatter.schemas.js";
|
||||
export type {
|
||||
ListFormatStyle,
|
||||
ListFormatType,
|
||||
ListFormatterCacheKey,
|
||||
ListFormatterOptions,
|
||||
} from "../formatter.types.js";
|
||||
@@ -0,0 +1,12 @@
|
||||
export type {
|
||||
ListFormatStyle,
|
||||
ListFormatType,
|
||||
ListFormatterCacheKey,
|
||||
ListFormatterOptions,
|
||||
} from "./formatter.js";
|
||||
export {
|
||||
clearListFormatterCache,
|
||||
formatList,
|
||||
ListFormatter,
|
||||
ListFormatterOptionsSchema,
|
||||
} from "./formatter.js";
|
||||
@@ -0,0 +1,60 @@
|
||||
# List Helper Formatters
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Pre-bound shortcuts over `formatList` for the two most common list-formatting shapes: conjunction joining ("a, b, and c") and disjunction joining ("a, b, or c"). These are net-new ergonomic helpers introduced by the Wave-3 split — symmetric with `formatPercent` / `formatCompact` for the number formatter.
|
||||
|
||||
The helpers route every call through `formatList`, so they inherit Zod option validation, the per-`(locale, style, type)` cache, and the OTel histogram + cache-hit counter from the formatter module.
|
||||
|
||||
## Features
|
||||
|
||||
- `formatConjunction(values, locale, style?)` — pre-bound `formatList({ type: 'conjunction', style })`
|
||||
- `formatDisjunction(values, locale, style?)` — pre-bound `formatList({ type: 'disjunction', style })`
|
||||
- Style argument defaults to `'long'`; pass `'short'` or `'narrow'` for compact rendering
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
formatConjunction,
|
||||
formatDisjunction,
|
||||
} from "@suaveplan/i18n-formatter-list/helpers";
|
||||
|
||||
formatConjunction(["a", "b", "c"], "en-GB"); // → "a, b, and c"
|
||||
formatConjunction(["x", "y"], "en-GB", "short"); // → "x, & y" or "x & y" (locale-dependent)
|
||||
|
||||
formatDisjunction(["red", "green", "blue"], "en-GB"); // → "red, green, or blue"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example — Permission summary
|
||||
|
||||
```typescript
|
||||
import { formatConjunction } from "@suaveplan/i18n-formatter-list/helpers";
|
||||
|
||||
function summarisePermissions(permissions, locale) {
|
||||
const labels = permissions.map((p) => p.label);
|
||||
return labels.length === 0
|
||||
? "No permissions"
|
||||
: `Permissions: ${formatConjunction(labels, locale)}`;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `formatConjunction(values, locale, style?)`
|
||||
|
||||
Pre-bound `formatList({ type: 'conjunction', style })`. Style defaults to `'long'`. Inherits all error mapping (`INVALID_FORMATTER_OPTIONS`, `UNSUPPORTED_LOCALE`) and observability (histogram + cache-hit counter) from `formatList`.
|
||||
|
||||
### `formatDisjunction(values, locale, style?)`
|
||||
|
||||
Pre-bound `formatList({ type: 'disjunction', style })`. Style defaults to `'long'`. Same inheritance as `formatConjunction`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The helpers take `style` as a positional third argument rather than an options bag because `formatList`'s only meaningful per-call option (apart from `type`, which is fixed by the helper) is `style`. Keeping the helper signature lean makes the call site read as "what kind of join, in which language, in which style" — a natural sentence shape.
|
||||
|
||||
The two helpers are intentionally simple wrappers because the slim core (Wave 6) wires `formatList` (not the helpers) into the default `FormatBridge` for ICU `{values, list, type=conjunction}` placeholder rendering. Adding more transformation logic in the helpers would create drift between what the slim core renders and what direct callers see.
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { formatConjunction, formatDisjunction } from "./helpers.js";
|
||||
|
||||
describe("formatConjunction", () => {
|
||||
it("formatConjunction renders a, b, and c", { timeout: 5000 }, () => {
|
||||
// en-US uses the Oxford comma; en-GB renders "a, b and c".
|
||||
expect(formatConjunction(["a", "b", "c"], "en-US")).toBe("a, b, and c");
|
||||
});
|
||||
|
||||
it("respects style override", { timeout: 5000 }, () => {
|
||||
// Short style in en-US uses "&" connector; en-GB renders "and".
|
||||
const result = formatConjunction(["a", "b"], "en-US", "short");
|
||||
expect(result).toMatch(/a,? & b/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDisjunction", () => {
|
||||
it("formatDisjunction renders x or y", { timeout: 5000 }, () => {
|
||||
expect(formatDisjunction(["x", "y"], "en-GB")).toBe("x or y");
|
||||
});
|
||||
|
||||
it("supports narrow style", { timeout: 5000 }, () => {
|
||||
const result = formatDisjunction(["x", "y"], "en-GB", "narrow");
|
||||
// Narrow may render "x or y" — assert presence of "or" substring.
|
||||
expect(result).toContain("or");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { formatList } from "../formatter/formatter.js";
|
||||
import type { ListFormatStyle } from "../formatter.types.js";
|
||||
|
||||
/**
|
||||
* Pre-bound `formatList({ type: 'conjunction' })` shortcut. Renders
|
||||
* `["a", "b", "c"]` as `"a, b, and c"` in en-GB.
|
||||
*/
|
||||
export function formatConjunction(
|
||||
values: readonly string[],
|
||||
locale: string,
|
||||
style: ListFormatStyle = "long"
|
||||
): string {
|
||||
return formatList(values, locale, { type: "conjunction", style });
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-bound `formatList({ type: 'disjunction' })` shortcut. Renders
|
||||
* `["x", "y"]` as `"x or y"` in en-GB.
|
||||
*/
|
||||
export function formatDisjunction(
|
||||
values: readonly string[],
|
||||
locale: string,
|
||||
style: ListFormatStyle = "long"
|
||||
): string {
|
||||
return formatList(values, locale, { type: "disjunction", style });
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
formatConjunction,
|
||||
formatDisjunction,
|
||||
} from "./helpers.js";
|
||||
@@ -0,0 +1,60 @@
|
||||
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-list root barrel", () => {
|
||||
it("re-exports ListFormatter class", { timeout: 5000 }, () => {
|
||||
expect(typeof root.ListFormatter).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports formatList standalone", { timeout: 5000 }, () => {
|
||||
expect(typeof root.formatList).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports clearListFormatterCache", { timeout: 5000 }, () => {
|
||||
expect(typeof root.clearListFormatterCache).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports ListFormatterOptionsSchema", { timeout: 5000 }, () => {
|
||||
expect(root.ListFormatterOptionsSchema).toBeDefined();
|
||||
});
|
||||
|
||||
it("re-exports formatConjunction / formatDisjunction", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(typeof root.formatConjunction).toBe("function");
|
||||
expect(typeof root.formatDisjunction).toBe("function");
|
||||
});
|
||||
|
||||
it("re-exports observability surface", { timeout: 5000 }, () => {
|
||||
expect(typeof root.getListFormatMetricsState).toBe("function");
|
||||
expect(typeof root.resetListFormatMetricsState).toBe("function");
|
||||
expect(typeof root.recordListFormat).toBe("function");
|
||||
expect(typeof root.recordListCacheHit).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("@suaveplan/i18n-formatter-list 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
24
packages/localization/i18n/i18n-formatter-list/src/index.ts
Normal file
24
packages/localization/i18n/i18n-formatter-list/src/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// Root barrel for @suaveplan/i18n-formatter-list. Explicit named
|
||||
// re-exports only — no `export *` per CLAUDE.md §10.
|
||||
|
||||
export type {
|
||||
ListFormatStyle,
|
||||
ListFormatType,
|
||||
ListFormatterCacheKey,
|
||||
ListFormatterOptions,
|
||||
} from "./formatter/index.js";
|
||||
export {
|
||||
clearListFormatterCache,
|
||||
formatList,
|
||||
ListFormatter,
|
||||
ListFormatterOptionsSchema,
|
||||
} from "./formatter/index.js";
|
||||
export { formatConjunction, formatDisjunction } from "./helpers/index.js";
|
||||
export {
|
||||
type FormatMetricSample,
|
||||
getListFormatMetricsState,
|
||||
type ListFormatMetricsState,
|
||||
recordListCacheHit,
|
||||
recordListFormat,
|
||||
resetListFormatMetricsState,
|
||||
} from "./observability/index.js";
|
||||
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
FormatMetricSample,
|
||||
ListFormatMetricsState,
|
||||
} from "./observability.js";
|
||||
export {
|
||||
getListFormatMetricsState,
|
||||
recordListCacheHit,
|
||||
recordListFormat,
|
||||
resetListFormatMetricsState,
|
||||
} from "./observability.js";
|
||||
@@ -0,0 +1,91 @@
|
||||
# ListFormatter Observability
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Per-package OpenTelemetry instruments for `@suaveplan/i18n-formatter-list`. Exposes a histogram (`i18n.format.list.duration_ms`) recording wall-clock duration of every `formatList` call (tagged with `locale`, `style`, and `type`) and a counter (`i18n.format.cache_hit`) tagged with `formatter: "list"` that increments on every cache reuse.
|
||||
|
||||
The list formatter introduces these instruments as part of the Wave 3 uplift — the monolith currently emits no OTel for list formatting. The two instruments are constructed once via `metrics.getMeter` and are lazily initialised on first record so cold-start latency stays low.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy instrument construction
|
||||
- Stable histogram name `i18n.format.list.duration_ms` with attributes `{ locale, style, type }`
|
||||
- Stable counter name `i18n.format.cache_hit` with attributes `{ formatter: "list" }`
|
||||
- In-memory sample ring (`listDurationSamples`, `cacheHitSamples`)
|
||||
- `resetListFormatMetricsState()` clears both arrays so tests stay isolated
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
recordListFormat,
|
||||
recordListCacheHit,
|
||||
getListFormatMetricsState,
|
||||
resetListFormatMetricsState,
|
||||
} from "@suaveplan/i18n-formatter-list/observability";
|
||||
|
||||
resetListFormatMetricsState();
|
||||
recordListFormat("en-GB", "long", "conjunction", 0.4);
|
||||
recordListCacheHit();
|
||||
const state = getListFormatMetricsState();
|
||||
state.listDurationSamples.length; // 1
|
||||
state.cacheHitSamples.length; // 1
|
||||
```
|
||||
|
||||
## Usage: Advanced Example — Asserting OTel emission in a test
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
formatList,
|
||||
clearListFormatterCache,
|
||||
} from "@suaveplan/i18n-formatter-list";
|
||||
import {
|
||||
getListFormatMetricsState,
|
||||
resetListFormatMetricsState,
|
||||
} from "@suaveplan/i18n-formatter-list/observability";
|
||||
|
||||
describe("metrics emission", () => {
|
||||
it("emits duration histogram and cache-hit counter", { timeout: 5000 }, () => {
|
||||
clearListFormatterCache();
|
||||
resetListFormatMetricsState();
|
||||
formatList(["a", "b"], "en-GB");
|
||||
formatList(["a", "b"], "en-GB");
|
||||
const state = getListFormatMetricsState();
|
||||
expect(state.listDurationSamples).toHaveLength(2);
|
||||
expect(state.cacheHitSamples).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `recordListFormat(locale, style, type, durationMs): void`
|
||||
|
||||
Records one observation on the `i18n.format.list.duration_ms` histogram and pushes a `{ attributes: { locale, style, type } }` entry into the in-memory sample ring.
|
||||
|
||||
### `recordListCacheHit(): void`
|
||||
|
||||
Adds 1 to the `i18n.format.cache_hit` counter with `formatter: "list"` and pushes a matching entry into the in-memory sample ring.
|
||||
|
||||
### `getListFormatMetricsState(): ListFormatMetricsState`
|
||||
|
||||
Returns the in-memory sample ring (live reference — do not mutate).
|
||||
|
||||
### `resetListFormatMetricsState(): void`
|
||||
|
||||
Empties both sample arrays in place.
|
||||
|
||||
### `ListFormatMetricsState`
|
||||
|
||||
`{ listDurationSamples: FormatMetricSample[]; cacheHitSamples: FormatMetricSample[] }` where `FormatMetricSample = { attributes: Record<string, string | number> }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The instruments are built lazily so consumers who never call `formatList` 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 `resetListFormatMetricsState` calls.
|
||||
|
||||
This module is one of two Wave 3 changes that introduce a *new* OTel surface — the monolith's `formatting/list/` module emits nothing, while `formatting/number/` and `formatting/relative-time/` already emit through the shared `observability/format-metrics.ts` module. The Wave 3 split brings every formatter package up to the same canonical observability shape so the slim core (Wave 6) can dashboard the entire family with a single regex on `i18n.format.*`.
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { clearListFormatterCache, formatList } from "../formatter/formatter.js";
|
||||
import {
|
||||
getListFormatMetricsState,
|
||||
recordListCacheHit,
|
||||
recordListFormat,
|
||||
resetListFormatMetricsState,
|
||||
} from "./observability.js";
|
||||
|
||||
describe("ListFormatter observability", () => {
|
||||
it("emits i18n.format.list.duration_ms histogram on every call", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
clearListFormatterCache();
|
||||
resetListFormatMetricsState();
|
||||
formatList(["a", "b"], "en-GB", {
|
||||
style: "long",
|
||||
type: "conjunction",
|
||||
});
|
||||
const state = getListFormatMetricsState();
|
||||
expect(state.listDurationSamples).toHaveLength(1);
|
||||
expect(state.listDurationSamples[0]?.attributes).toMatchObject({
|
||||
locale: "en-GB",
|
||||
style: "long",
|
||||
type: "conjunction",
|
||||
});
|
||||
});
|
||||
|
||||
it("cache-hit counter increments only on second call with same options", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
clearListFormatterCache();
|
||||
resetListFormatMetricsState();
|
||||
formatList(["a"], "en-GB");
|
||||
expect(getListFormatMetricsState().cacheHitSamples).toHaveLength(0);
|
||||
formatList(["a"], "en-GB");
|
||||
const state = getListFormatMetricsState();
|
||||
expect(state.cacheHitSamples).toHaveLength(1);
|
||||
expect(state.cacheHitSamples[0]?.attributes.formatter).toBe("list");
|
||||
});
|
||||
|
||||
it("recordListFormat exposed for direct use", { timeout: 5000 }, () => {
|
||||
resetListFormatMetricsState();
|
||||
recordListFormat("fr-FR", "short", "disjunction", 0.1);
|
||||
const state = getListFormatMetricsState();
|
||||
expect(state.listDurationSamples).toHaveLength(1);
|
||||
expect(state.listDurationSamples[0]?.attributes).toMatchObject({
|
||||
locale: "fr-FR",
|
||||
style: "short",
|
||||
type: "disjunction",
|
||||
});
|
||||
});
|
||||
|
||||
it("recordListCacheHit can be invoked directly", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetListFormatMetricsState();
|
||||
recordListCacheHit();
|
||||
recordListCacheHit();
|
||||
const state = getListFormatMetricsState();
|
||||
expect(state.cacheHitSamples).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("instruments are reused across resets (no double-construction)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetListFormatMetricsState();
|
||||
recordListFormat("en-GB", "long", "conjunction", 0.1);
|
||||
recordListFormat("en-GB", "long", "conjunction", 0.2);
|
||||
const state = getListFormatMetricsState();
|
||||
expect(state.listDurationSamples).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* OTel instruments for the list formatter.
|
||||
*
|
||||
* Histogram records per-format wall-clock duration tagged with
|
||||
* `locale`, `style`, and `type`; the counter increments on every
|
||||
* cache reuse so consumers can observe cache effectiveness.
|
||||
*
|
||||
* The list formatter introduces these instruments as part of the
|
||||
* Wave 3 uplift — the monolith currently emits no OTel for list
|
||||
* formatting.
|
||||
*/
|
||||
|
||||
export interface FormatMetricSample {
|
||||
readonly attributes: Record<string, string | number>;
|
||||
}
|
||||
|
||||
export interface ListFormatMetricsState {
|
||||
readonly listDurationSamples: FormatMetricSample[];
|
||||
readonly cacheHitSamples: FormatMetricSample[];
|
||||
}
|
||||
|
||||
let cached:
|
||||
| {
|
||||
readonly listDurationMs: Histogram;
|
||||
readonly cacheHit: Counter;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const state: ListFormatMetricsState = {
|
||||
listDurationSamples: [],
|
||||
cacheHitSamples: [],
|
||||
};
|
||||
|
||||
function ensureInstruments() {
|
||||
if (cached) return cached;
|
||||
const meter = metrics.getMeter("suaveplan.i18n-formatter-list");
|
||||
cached = {
|
||||
listDurationMs: meter.createHistogram("i18n.format.list.duration_ms", {
|
||||
description:
|
||||
"Duration in ms of ListFormatter.format; labels: locale, style, type.",
|
||||
unit: "ms",
|
||||
}),
|
||||
cacheHit: meter.createCounter("i18n.format.cache_hit", {
|
||||
description:
|
||||
'Format-time cache reuse counter; labels: formatter ("list").',
|
||||
}),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function recordListFormat(
|
||||
locale: string,
|
||||
style: string,
|
||||
type: string,
|
||||
durationMs: number
|
||||
): void {
|
||||
const ins = ensureInstruments();
|
||||
const attributes = { locale, style, type };
|
||||
ins.listDurationMs.record(durationMs, attributes);
|
||||
state.listDurationSamples.push({ attributes });
|
||||
}
|
||||
|
||||
export function recordListCacheHit(): void {
|
||||
const ins = ensureInstruments();
|
||||
const attributes = { formatter: "list" };
|
||||
ins.cacheHit.add(1, attributes);
|
||||
state.cacheHitSamples.push({ attributes });
|
||||
}
|
||||
|
||||
export function getListFormatMetricsState(): ListFormatMetricsState {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function resetListFormatMetricsState(): void {
|
||||
state.listDurationSamples.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-list",
|
||||
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