Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View 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.

View 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"
]
}

View File

@@ -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
>;

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -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).

View File

@@ -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");
}
});
});

View File

@@ -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";

View File

@@ -0,0 +1,12 @@
export type {
ListFormatStyle,
ListFormatType,
ListFormatterCacheKey,
ListFormatterOptions,
} from "./formatter.js";
export {
clearListFormatterCache,
formatList,
ListFormatter,
ListFormatterOptionsSchema,
} from "./formatter.js";

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -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 });
}

View File

@@ -0,0 +1,4 @@
export {
formatConjunction,
formatDisjunction,
} from "./helpers.js";

View File

@@ -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);
}
});
});

View 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";

View File

@@ -0,0 +1,10 @@
export type {
FormatMetricSample,
ListFormatMetricsState,
} from "./observability.js";
export {
getListFormatMetricsState,
recordListCacheHit,
recordListFormat,
resetListFormatMetricsState,
} from "./observability.js";

View File

@@ -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.*`.

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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"]
}

View File

@@ -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:/,
],
},
},
});

View File

@@ -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,
},
});