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,104 @@
# @suaveplan/i18n-formatter-collator - Intl.Collator wrapper for the @suaveplan/i18n family — locale-aware string comparison and sorting (sensitivity, numeric-aware natural sort, case-first, ignorePunctuation, custom collation). Per-(locale, options-hash) cache, Zod-validated options, OTel histogram + cache-hit counter, structured I18nError mapping. Brand-new formatter — closes the gap from the original i18n-formatters change scope. Universal leaf package.
## Summary
Wrapper around `Intl.Collator` for the `@suaveplan/i18n` family —
locale-aware string comparison and sorting (sensitivity, numeric-aware
natural sort, case-first, ignorePunctuation, custom collation).
This is a **brand-new formatter** in the i18n family — `Intl.Collator`
was listed in the original `i18n-formatters` change scope but was
never implemented in the monolith. This package closes that gap.
<!-- begin:auto-docs -->
## Documentation tree
- [formatter](./src/formatter/formatter.md) — `CollatorFormatter` and the `compareStrings` / `formatCollation` standalone helpers expose `Intl.Collator` for the `@su…
- [formatter](./src/formatter/formatter.md) — `CollatorFormatter` and the `compareStrings` / `formatCollation` standalone helpers expose `Intl.Collator` for the `@su…
- [helpers](./src/helpers/helpers.md) — The three ergonomic helpers (`compareCaseInsensitive`, `compareNumericAware`, `sortStrings`) pre-bind `compareStrings`…
- [helpers](./src/helpers/helpers.md) — The three ergonomic helpers (`compareCaseInsensitive`, `compareNumericAware`, `sortStrings`) pre-bind `compareStrings`…
- [observability](./src/observability/observability.md) — The Collator formatter exports two OpenTelemetry instruments and a small in-memory state ring so spec-conformance tests…
- [observability](./src/observability/observability.md) — The Collator formatter exports two OpenTelemetry instruments and a small in-memory state ring so spec-conformance tests…
<!-- end:auto-docs -->
## Why this exists
The 14-package i18n modular split (cross-cutting design
`2026-05-05-i18n-modular-split.md`) lifts each `Intl.*` wrapper into
its own sibling package and adds the two formatters
(`Collator` + `Segmenter`) that the original change scope listed but
never shipped. Locale-aware comparison is the right primitive for
sorting names and substring search, and is required by downstream
consumers like `@suaveplan/dedup` and `@suaveplan/react-data-table`.
## Install
```bash
bun add @suaveplan/i18n-formatter-collator
```
## Quick Start
```ts
import {
CollatorFormatter,
compareStrings,
formatCollation,
sortStrings,
compareCaseInsensitive,
compareNumericAware,
} from "@suaveplan/i18n-formatter-collator";
compareStrings("apple", "banana", "en-GB"); // -1
sortStrings(["c", "a", "b"], "en-GB"); // ["a", "b", "c"]
compareCaseInsensitive("a", "A", "en-GB"); // 0
compareNumericAware("file2", "file10", "en-GB"); // -1
const f = new CollatorFormatter({ numeric: true });
f.sort(["log10.txt", "log2.txt", "log1.txt"], "en-GB");
// ["log1.txt", "log2.txt", "log10.txt"]
```
## Modules
| Subpath | Description |
|---|---|
| `@suaveplan/i18n-formatter-collator` | Root barrel — class + standalones + helpers + observability |
| `@suaveplan/i18n-formatter-collator/formatter` | `CollatorFormatter`, `compareStrings`, `formatCollation`, types, schema |
| `@suaveplan/i18n-formatter-collator/helpers` | `compareCaseInsensitive`, `compareNumericAware`, `sortStrings` |
## Modules Documentation
- [formatter.md](src/formatter.md) — `CollatorFormatter`, `compareStrings`, `formatCollation`, `clearCollatorCache`
- [helpers.md](src/helpers.md) — three ergonomic helpers
- [observability.md](src/observability.md) — OTel histogram + cache-hit counter
## Public Surface (snapshot)
| Export | Kind | Origin |
|---|---|---|
| `CollatorFormatter` | class | `formatter.ts` |
| `compareStrings` | function | `formatter.ts` |
| `formatCollation` | function | `formatter.ts` |
| `clearCollatorCache` | function | `formatter.ts` |
| `CollatorFormatterOptionsSchema` | Zod schema | `formatter.schemas.ts` |
| `CollatorFormatterOptions` | type | `formatter.types.ts` |
| `CollatorUsage` | type | `formatter.types.ts` |
| `CollatorSensitivity` | type | `formatter.types.ts` |
| `CollatorCaseFirst` | type | `formatter.types.ts` |
| `CollatorNumeric` | type | `formatter.types.ts` |
| `CollatorFormatterCacheKey` | type | `formatter.types.ts` |
| `compareCaseInsensitive` | function | `helpers.ts` |
| `compareNumericAware` | function | `helpers.ts` |
| `sortStrings` | function | `helpers.ts` |
| `recordCollatorFormat` | function | `observability.ts` |
| `recordCollatorCacheHit` | function | `observability.ts` |
| `getCollatorMetricsState` | function | `observability.ts` |
| `resetCollatorMetricsState` | function | `observability.ts` |
## Versioning
Part of the 14-package i18n modular-split rollout. Stays at `0.1.0`
until the slim core (Wave 6) cuts the family `1.0.0` release.

View File

@@ -0,0 +1,76 @@
{
"name": "@suaveplan/i18n-formatter-collator",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Intl.Collator wrapper for the @suaveplan/i18n family — locale-aware string comparison and sorting (sensitivity, numeric-aware natural sort, case-first, ignorePunctuation, custom collation). Per-(locale, options-hash) cache, Zod-validated options, OTel histogram + cache-hit counter, structured I18nError mapping. Brand-new formatter — closes the gap from the original i18n-formatters change scope. Universal leaf package.",
"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:^",
"@suaveplan/typescript-config": "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,21 @@
import z from "zod";
/**
* Zod schema for `CollatorFormatterOptions`. Every field validates
* against the canonical `Intl.Collator` enum. `collation` accepts any
* non-empty string identifier (e.g. `"phonebk"`, `"trad"`,
* `"emoji"`); the runtime rejects unknown identifiers via `RangeError`,
* which the formatter maps to `UNSUPPORTED_LOCALE`.
*/
export const CollatorFormatterOptionsSchema = z.object({
usage: z.enum(["sort", "search"]).optional(),
sensitivity: z.enum(["base", "accent", "case", "variant"]).optional(),
ignorePunctuation: z.boolean().optional(),
numeric: z.boolean().optional(),
caseFirst: z.enum(["upper", "lower", "false"]).optional(),
collation: z.string().min(1).optional(),
});
export type CollatorFormatterOptionsInput = z.input<
typeof CollatorFormatterOptionsSchema
>;

View File

@@ -0,0 +1,151 @@
/**
* Spec-conformance pins for CollatorFormatter — 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 {
CollatorFormatter,
clearCollatorCache,
compareStrings,
} from "./formatter/formatter.js";
import type {
CollatorFormatterOptions,
CollatorUsage,
} from "./formatter.types.js";
import {
getCollatorMetricsState,
resetCollatorMetricsState,
} from "./observability/observability.js";
describe("CollatorFormatter spec-conformance", () => {
it("rejects non-object options shape (no path → field='options')", {
timeout: 5000,
}, () => {
try {
// @ts-expect-error wrong type for safeParse
compareStrings("a", "b", "en-GB", "not-an-object");
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 usage with INVALID_FORMATTER_OPTIONS", {
timeout: 5000,
}, () => {
try {
new CollatorFormatter({ usage: "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: "usage",
});
}
});
it("rejects unknown sensitivity with INVALID_FORMATTER_OPTIONS", {
timeout: 5000,
}, () => {
try {
compareStrings("a", "b", "en-GB", {
sensitivity: "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: "sensitivity",
});
}
});
it("maps Intl RangeError to UNSUPPORTED_LOCALE", {
timeout: 5000,
}, () => {
clearCollatorCache();
try {
compareStrings("a", "b", "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.Collator on identical key", {
timeout: 5000,
}, () => {
clearCollatorCache();
resetCollatorMetricsState();
compareStrings("a", "b", "en-GB");
compareStrings("a", "b", "en-GB");
const state = getCollatorMetricsState();
expect(state.cacheHitSamples).toHaveLength(1);
expect(state.cacheHitSamples[0]?.attributes.formatter).toBe("collator");
});
it("does not reuse cache across distinct options", {
timeout: 5000,
}, () => {
clearCollatorCache();
resetCollatorMetricsState();
compareStrings("a", "b", "en-GB", { sensitivity: "base" });
compareStrings("a", "b", "en-GB", { sensitivity: "accent" });
const state = getCollatorMetricsState();
expect(state.cacheHitSamples).toHaveLength(0);
});
it("records a duration_ms sample per call", {
timeout: 5000,
}, () => {
clearCollatorCache();
resetCollatorMetricsState();
compareStrings("a", "b", "en-GB", {
usage: "sort",
sensitivity: "base",
});
const state = getCollatorMetricsState();
expect(state.durationSamples).toHaveLength(1);
expect(state.durationSamples[0]?.attributes).toMatchObject({
locale: "en-GB",
usage: "sort",
sensitivity: "base",
});
});
it("defaults usage='sort' and sensitivity='variant' when omitted", {
timeout: 5000,
}, () => {
clearCollatorCache();
resetCollatorMetricsState();
compareStrings("a", "b", "en-GB");
const state = getCollatorMetricsState();
expect(state.durationSamples[0]?.attributes).toMatchObject({
usage: "sort",
sensitivity: "variant",
});
});
it("usage field of CollatorFormatterOptions matches CollatorUsage", {
timeout: 5000,
}, () => {
expectTypeOf<CollatorFormatterOptions["usage"]>().toEqualTypeOf<
CollatorUsage | undefined
>();
});
});

View File

@@ -0,0 +1,30 @@
/** Collator formatter type definitions per ECMA-402 Intl.Collator. */
/** Comparison usage — sort or substring search. */
export type CollatorUsage = "sort" | "search";
/** Sensitivity ladder — base ⊂ accent ⊂ case ⊂ variant. */
export type CollatorSensitivity = "base" | "accent" | "case" | "variant";
/** Case ordering — upper-first, lower-first, or false (locale default). */
export type CollatorCaseFirst = "upper" | "lower" | "false";
/** Boolean toggle for natural-sort numeric awareness. */
export type CollatorNumeric = boolean;
/**
* Public options shape consumed by `CollatorFormatter`,
* `compareStrings`, and `formatCollation`. Every field is optional;
* defaults match `Intl.Collator` defaults for the given locale.
*/
export interface CollatorFormatterOptions {
readonly usage?: CollatorUsage;
readonly sensitivity?: CollatorSensitivity;
readonly ignorePunctuation?: boolean;
readonly numeric?: CollatorNumeric;
readonly caseFirst?: CollatorCaseFirst;
readonly collation?: string;
}
/** Cache key shape: `${locale}|${jsonOptions}`. */
export type CollatorFormatterCacheKey = string;

View File

@@ -0,0 +1,121 @@
# CollatorFormatter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`CollatorFormatter` and the `compareStrings` / `formatCollation`
standalone helpers expose `Intl.Collator` for the `@suaveplan/i18n`
family at the canonical Wave-3 formatter surface. Locale-aware string
comparison is the right primitive for everything from sorting names
in a locale-correct order to powering substring search that ignores
diacritics.
This is a brand-new formatter — `Intl.Collator` was listed in the
original `i18n-formatters` change scope but never implemented in the
monolith. The split package closes that gap with the canonical Wave-3
shape.
## Features
- `CollatorFormatter` class with `compare(a, b, locale)` and `sort(values, locale)` methods
- `compareStrings(a, b, locale, options?)` standalone — returns `-1 | 0 | 1`
- `formatCollation(values, locale, options?)` standalone — returns a NEW sorted array (input never mutated)
- All `Intl.Collator` options supported: `usage`, `sensitivity`, `ignorePunctuation`, `numeric`, `caseFirst`, `collation`
- Construction-time Zod validation with `INVALID_FORMATTER_OPTIONS` mapping
- `Intl.Collator` `RangeError` mapped to `UNSUPPORTED_LOCALE` with original error attached as `cause`
- Per-(locale, options-hash) cache — repeated calls reuse the same `Intl.Collator`
- `clearCollatorCache()` test-only helper
- Compare result normalised to `-1 | 0 | 1` (matches familiar `Array.sort` callback shape)
## Usage: Basic Example
```ts
import {
compareStrings,
formatCollation,
} from "@suaveplan/i18n-formatter-collator";
compareStrings("apple", "banana", "en-GB"); // -1
formatCollation(["c", "a", "b"], "en-GB"); // ["a", "b", "c"]
// Numeric-aware natural sort.
formatCollation(
["file10", "file2", "file1"],
"en-GB",
{ numeric: true }
); // ["file1", "file2", "file10"]
```
## Usage: Advanced Example
```ts
import {
CollatorFormatter,
type CollatorFormatterOptions,
} from "@suaveplan/i18n-formatter-collator";
// Substring-search collator — accent- and case-insensitive.
const search: CollatorFormatterOptions = {
usage: "search",
sensitivity: "base",
};
const f = new CollatorFormatter(search);
// Search "café" matches "cafe" / "Cafe" / "CAFE".
function matches(needle: string, haystack: string, locale: string) {
// For substring search the collator's compare function
// returns 0 for matching items.
return f.compare(needle, haystack, locale) === 0;
}
matches("café", "cafe", "en-GB"); // true
// Phonebook-style sort for German.
const phonebook = new CollatorFormatter({ collation: "phonebk" });
phonebook.sort(["Müller", "Schmidt", "Mueller"], "de-DE");
```
## API Reference
### `compareStrings(a, b, locale, options?): -1 | 0 | 1`
Standalone helper. Validates options via Zod, looks up or constructs
the cached `Intl.Collator`, and returns the normalised compare result.
Throws `I18nError(INVALID_FORMATTER_OPTIONS)` on Zod failure or
`I18nError(UNSUPPORTED_LOCALE)` on `Intl.Collator` `RangeError`.
### `formatCollation(values, locale, options?): string[]`
Standalone helper. Returns a NEW array sorted via the configured
collator. Input is never mutated.
### `class CollatorFormatter`
- `constructor(options?: CollatorFormatterOptions)` — Zod-validates options
- `compare(a, b, locale): -1 | 0 | 1`
- `sort(values: readonly string[], locale): string[]` — returns a NEW sorted array
### `clearCollatorCache(): void`
Test-only helper that empties the per-(locale, options-hash) cache.
## Implementation Notes
- The cache key is `${locale}|${JSON.stringify(options)}`. JSON
serialisation is stable for the option shape — every field is a
string, boolean, or undefined.
- `compareStrings` normalises the raw `Intl.Collator.compare` return
value (any signed number) to the `-1 | 0 | 1` triple so callers can
use it directly as an `Array.sort` callback or as a switch
discriminant.
- `formatCollation` uses `[...values].sort(collator.compare)` — the
spread copies the input, then `Array.sort` mutates the copy in
place. Result is referentially distinct from the input.
- `compareStrings` and `formatCollation` BOTH go through the same
cache. A single collator can serve both compare and sort patterns.
- `Intl.Collator` is supported in Node 14+ and every current browser.
The package does not provide a polyfill — Node 14 is below the
family's documented support floor anyway.

View File

@@ -0,0 +1,184 @@
import { I18nError } from "@suaveplan/i18n-shared";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CollatorFormatter,
clearCollatorCache,
compareStrings,
formatCollation,
} from "./formatter.js";
describe("compareStrings", () => {
it("compareStrings compares apple and banana", { timeout: 5000 }, () => {
clearCollatorCache();
expect(compareStrings("apple", "banana", "en-GB")).toBe(-1);
});
it("returns 0 for equal strings", { timeout: 5000 }, () => {
clearCollatorCache();
expect(compareStrings("a", "a", "en-GB")).toBe(0);
});
it("returns 1 for descending pairs", { timeout: 5000 }, () => {
clearCollatorCache();
expect(compareStrings("z", "a", "en-GB")).toBe(1);
});
it("case-sensitive compare distinguishes a and A", {
timeout: 5000,
}, () => {
clearCollatorCache();
const out = compareStrings("a", "A", "en-GB", {
sensitivity: "case",
});
expect(out).not.toBe(0);
});
it("case-insensitive compare treats a and A equal", {
timeout: 5000,
}, () => {
clearCollatorCache();
expect(
compareStrings("a", "A", "en-GB", { sensitivity: "accent" })
).toBe(0);
});
it("numeric-aware orders file2 before file10", { timeout: 5000 }, () => {
clearCollatorCache();
expect(
compareStrings("file2", "file10", "en-GB", { numeric: true })
).toBe(-1);
});
it("non-numeric default orders file10 before file2 lexicographically", {
timeout: 5000,
}, () => {
clearCollatorCache();
expect(compareStrings("file10", "file2", "en-GB")).toBe(-1);
});
it("compareStrings output twins CollatorFormatter.compare output", {
timeout: 5000,
}, () => {
clearCollatorCache();
const opts = { sensitivity: "base" as const };
const standalone = compareStrings("Apple", "apple", "en-GB", opts);
const klass = new CollatorFormatter(opts).compare(
"Apple",
"apple",
"en-GB"
);
expect(standalone).toBe(klass);
});
});
describe("CollatorFormatter class", () => {
it("CollatorFormatter compares with default options", {
timeout: 5000,
}, () => {
clearCollatorCache();
const f = new CollatorFormatter();
expect(f.compare("a", "b", "en-GB")).toBe(-1);
});
it("CollatorFormatter rejects invalid options with INVALID_FORMATTER_OPTIONS", {
timeout: 5000,
}, () => {
try {
new CollatorFormatter({ usage: "invalid" as never });
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe(
"I18N.INVALID_FORMATTER_OPTIONS"
);
}
});
it("CollatorFormatter.sort sorts via stored options", {
timeout: 5000,
}, () => {
clearCollatorCache();
const f = new CollatorFormatter({ numeric: true });
const out = f.sort(["file10", "file2", "file1"], "en-GB");
expect(out).toEqual(["file1", "file2", "file10"]);
});
});
describe("formatCollation", () => {
it("formatCollation sorts ascending", { timeout: 5000 }, () => {
clearCollatorCache();
const out = formatCollation(["c", "a", "b"], "en-GB");
expect(out).toEqual(["a", "b", "c"]);
});
it("formatCollation does not mutate input array", { timeout: 5000 }, () => {
clearCollatorCache();
const input = ["c", "a", "b"];
formatCollation(input, "en-GB");
expect(input).toEqual(["c", "a", "b"]);
});
it("formatCollation handles empty array", { timeout: 5000 }, () => {
clearCollatorCache();
expect(formatCollation([], "en-GB")).toEqual([]);
});
it("formatCollation natural-sort with numeric option", {
timeout: 5000,
}, () => {
clearCollatorCache();
const out = formatCollation(["file10", "file2", "file1"], "en-GB", {
numeric: true,
});
expect(out).toEqual(["file1", "file2", "file10"]);
});
});
describe("Intl.Collator RangeError mapping", () => {
it("unsupported locale throws UNSUPPORTED_LOCALE with cause", {
timeout: 5000,
}, () => {
clearCollatorCache();
try {
compareStrings("a", "b", "xx-bogus-tag-!!!");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(I18nError);
expect((err as I18nError).code).toBe("I18N.UNSUPPORTED_LOCALE");
expect((err as I18nError).cause).toBeInstanceOf(RangeError);
}
});
});
describe("Per-(locale, options-hash) cache", () => {
it("identical options reuse cached Intl.Collator", {
timeout: 5000,
}, () => {
clearCollatorCache();
// Two equivalent calls should not throw and should return same value.
const a = compareStrings("a", "b", "en-GB");
const b = compareStrings("a", "b", "en-GB");
expect(a).toBe(b);
});
it("distinct options create distinct cache entries", {
timeout: 5000,
}, () => {
clearCollatorCache();
// numeric: true vs default differ → distinct entries.
const x = compareStrings("file2", "file10", "en-GB");
const y = compareStrings("file2", "file10", "en-GB", {
numeric: true,
});
expect(x).toBe(1); // lexicographic
expect(y).toBe(-1); // numeric-aware
});
it("clearCollatorCache drops cached entries", { timeout: 5000 }, () => {
clearCollatorCache();
compareStrings("a", "b", "en-GB");
clearCollatorCache();
// Subsequent call still works.
expect(compareStrings("a", "b", "en-GB")).toBe(-1);
});
});

View File

@@ -0,0 +1,150 @@
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
import { CollatorFormatterOptionsSchema } from "../formatter.schemas.js";
import type {
CollatorFormatterCacheKey,
CollatorFormatterOptions,
} from "../formatter.types.js";
import {
recordCollatorCacheHit,
recordCollatorFormat,
} from "../observability/observability.js";
const cache = new Map<CollatorFormatterCacheKey, Intl.Collator>();
function buildKey(
locale: string,
options: CollatorFormatterOptions | undefined
): CollatorFormatterCacheKey {
return `${locale}|${JSON.stringify(options ?? {})}`;
}
function validateOptions(options: CollatorFormatterOptions | undefined): void {
if (options === undefined) return;
const parsed = CollatorFormatterOptionsSchema.safeParse(options);
if (parsed.success) return;
const field = String(parsed.error.issues[0]?.path[0] ?? "options");
throw new I18nError(
I18N_CODES.INVALID_FORMATTER_OPTIONS,
`CollatorFormatter: invalid options (field: ${field})`,
{ context: { field, issues: parsed.error.issues } }
);
}
function buildIntlCollator(
locale: string,
options: CollatorFormatterOptions | undefined
): Intl.Collator {
try {
// CollatorFormatterOptions accepts an open `collation: string`
// for forward-compat with future ICU collation identifiers;
// the lib.dom Intl.CollatorOptions type pins `collation` to a
// closed enum. The runtime accepts every valid string and
// throws RangeError on unknowns (mapped to UNSUPPORTED_LOCALE
// by the catch below).
return new Intl.Collator(
locale,
options as Intl.CollatorOptions | undefined
);
} catch (cause) {
throw new I18nError(
I18N_CODES.UNSUPPORTED_LOCALE,
`CollatorFormatter: unsupported locale '${locale}'`,
{ cause, context: { locale } }
);
}
}
function getOrCreate(
locale: string,
options: CollatorFormatterOptions | undefined
): { collator: Intl.Collator; cached: boolean } {
const key = buildKey(locale, options);
const existing = cache.get(key);
if (existing) return { collator: existing, cached: true };
const collator = buildIntlCollator(locale, options);
cache.set(key, collator);
return { collator, cached: false };
}
function normaliseCompareResult(raw: number): -1 | 0 | 1 {
if (raw < 0) return -1;
if (raw > 0) return 1;
return 0;
}
function emit(
locale: string,
options: CollatorFormatterOptions | undefined,
cachedHit: boolean,
t0: number
): void {
if (cachedHit) recordCollatorCacheHit();
recordCollatorFormat(
locale,
options?.usage ?? "sort",
options?.sensitivity ?? "variant",
performance.now() - t0
);
}
/**
* Locale-aware string comparison. Returns `-1`, `0`, or `1` per
* `Intl.Collator.compare` semantics. Validates options via Zod;
* caches the underlying `Intl.Collator` per `(locale, options-hash)`.
*/
export function compareStrings(
a: string,
b: string,
locale: string,
options?: CollatorFormatterOptions
): -1 | 0 | 1 {
validateOptions(options);
const t0 = performance.now();
const { collator, cached: hit } = getOrCreate(locale, options);
const out = normaliseCompareResult(collator.compare(a, b));
emit(locale, options, hit, t0);
return out;
}
/**
* Locale-aware sort. Returns a NEW array sorted via the collator —
* the input array is never mutated.
*/
export function formatCollation(
values: readonly string[],
locale: string,
options?: CollatorFormatterOptions
): string[] {
validateOptions(options);
const t0 = performance.now();
const { collator, cached: hit } = getOrCreate(locale, options);
const out = [...values].sort(collator.compare);
emit(locale, options, hit, t0);
return out;
}
/**
* Class wrapper for `compareStrings` and `formatCollation` with
* construction-time option validation and stored defaults.
*/
export class CollatorFormatter {
private readonly options: CollatorFormatterOptions | undefined;
public constructor(options?: CollatorFormatterOptions) {
validateOptions(options);
this.options = options;
}
public compare(a: string, b: string, locale: string): -1 | 0 | 1 {
return compareStrings(a, b, locale, this.options);
}
public sort(values: readonly string[], locale: string): string[] {
return formatCollation(values, locale, this.options);
}
}
/** Test-only: clear the per-(locale, options-hash) cache. */
export function clearCollatorCache(): void {
cache.clear();
}

View File

@@ -0,0 +1,6 @@
export {
CollatorFormatter,
clearCollatorCache,
compareStrings,
formatCollation,
} from "./formatter.js";

View File

@@ -0,0 +1,107 @@
# Collator ergonomic helpers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The three ergonomic helpers (`compareCaseInsensitive`,
`compareNumericAware`, `sortStrings`) pre-bind `compareStrings` and
`formatCollation` with the most common option configurations so
consumers don't have to remember which `sensitivity` value matches
case-insensitive comparison or which boolean flag enables natural
sort.
## Features
- `compareCaseInsensitive(a, b, locale)` — pre-binds `sensitivity: "accent"` (case-insensitive but accent-aware)
- `compareNumericAware(a, b, locale)` — pre-binds `numeric: true` (natural sort: `"file2" < "file10"`)
- `sortStrings(values, locale, options?)` — alias for `formatCollation` with a more familiar name
## Usage: Basic Example
```ts
import {
compareCaseInsensitive,
compareNumericAware,
sortStrings,
} from "@suaveplan/i18n-formatter-collator";
compareCaseInsensitive("apple", "Apple", "en-GB"); // 0
compareNumericAware("file2", "file10", "en-GB"); // -1
sortStrings(["c", "a", "b"], "en-GB"); // ["a", "b", "c"]
sortStrings(["file10", "file2"], "en-GB", { numeric: true });
// ["file2", "file10"]
```
## Usage: Advanced Example
```ts
import {
compareCaseInsensitive,
compareNumericAware,
sortStrings,
} from "@suaveplan/i18n-formatter-collator";
// Build a case-insensitive search filter.
function findItems(needle: string, items: string[], locale: string) {
return items.filter(
(item) => compareCaseInsensitive(needle, item, locale) === 0
);
}
// Sort a list of file names naturally.
const files = ["log10.txt", "log2.txt", "log1.txt"];
const sorted = sortStrings(files, "en-GB", { numeric: true });
// ["log1.txt", "log2.txt", "log10.txt"]
// Sort then group names case-insensitively.
function groupBy<T>(
items: readonly T[],
keyFn: (item: T) => string,
locale: string
): Record<string, T[]> {
const sortedItems = [...items].sort((x, y) =>
compareCaseInsensitive(keyFn(x), keyFn(y), locale)
);
const out: Record<string, T[]> = {};
for (const item of sortedItems) {
const key = keyFn(item);
const bucket = out[key] ?? [];
bucket.push(item);
out[key] = bucket;
}
return out;
}
```
## API Reference
### `compareCaseInsensitive(a, b, locale): -1 | 0 | 1`
Pre-binds `sensitivity: "accent"`. Treats `"a"` and `"A"` as equal
(case-insensitive) but distinguishes `"a"` from `"á"`
(accent-sensitive). For full base-letter comparison (treating `"a"`
and `"á"` equal as well), call `compareStrings` directly with
`sensitivity: "base"`.
### `compareNumericAware(a, b, locale): -1 | 0 | 1`
Pre-binds `numeric: true`. Embedded numbers sort by their numeric
value, not their lexicographic order: `"file2"` sorts before
`"file10"` (lexicographic would put `"file10"` first).
### `sortStrings(values, locale, options?): string[]`
Alias for `formatCollation` with a familiar name. Returns a NEW
sorted array; the input is never mutated. All options forwarded.
## Implementation Notes
- Each helper is a one-line wrapper around `compareStrings` /
`formatCollation`. The underlying per-(locale, options-hash) cache
applies — calling `compareCaseInsensitive` twice with the same
locale reuses one `Intl.Collator`.
- The helpers are pure functions with no shared state.

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { clearCollatorCache } from "../formatter/formatter.js";
import {
compareCaseInsensitive,
compareNumericAware,
sortStrings,
} from "./helpers.js";
describe("ergonomic collator helpers", () => {
it("compareCaseInsensitive treats a and A as equal", {
timeout: 5000,
}, () => {
clearCollatorCache();
expect(compareCaseInsensitive("a", "A", "en-GB")).toBe(0);
});
it("compareCaseInsensitive distinguishes a and b", {
timeout: 5000,
}, () => {
clearCollatorCache();
expect(compareCaseInsensitive("a", "b", "en-GB")).toBe(-1);
});
it("compareNumericAware orders file2 before file10", {
timeout: 5000,
}, () => {
clearCollatorCache();
expect(compareNumericAware("file2", "file10", "en-GB")).toBe(-1);
});
it("compareNumericAware returns 0 for identical strings", {
timeout: 5000,
}, () => {
clearCollatorCache();
expect(compareNumericAware("file2", "file2", "en-GB")).toBe(0);
});
it("sortStrings sorts and returns immutable copy", {
timeout: 5000,
}, () => {
clearCollatorCache();
const input = ["c", "a", "b"];
const out = sortStrings(input, "en-GB");
expect(out).toEqual(["a", "b", "c"]);
expect(input).toEqual(["c", "a", "b"]);
});
it("sortStrings forwards options", { timeout: 5000 }, () => {
clearCollatorCache();
const out = sortStrings(["file10", "file2", "file1"], "en-GB", {
numeric: true,
});
expect(out).toEqual(["file1", "file2", "file10"]);
});
});

View File

@@ -0,0 +1,41 @@
import { compareStrings, formatCollation } from "../formatter/formatter.js";
import type { CollatorFormatterOptions } from "../formatter.types.js";
/**
* Compare two strings ignoring case differences. Pre-binds
* `compareStrings` with `sensitivity: "accent"` (treats `"a"` and
* `"A"` as equal but distinguishes `"a"` from `"á"`).
*/
export function compareCaseInsensitive(
a: string,
b: string,
locale: string
): -1 | 0 | 1 {
return compareStrings(a, b, locale, { sensitivity: "accent" });
}
/**
* Compare two strings with numeric awareness so embedded numbers sort
* naturally (`"file2"` before `"file10"`). Pre-binds `compareStrings`
* with `numeric: true`.
*/
export function compareNumericAware(
a: string,
b: string,
locale: string
): -1 | 0 | 1 {
return compareStrings(a, b, locale, { numeric: true });
}
/**
* Sort a string array via the configured collator. Returns a NEW
* sorted array; the input is never mutated. Defaults match
* `compareStrings` defaults; pass `options` to override.
*/
export function sortStrings(
values: readonly string[],
locale: string,
options?: CollatorFormatterOptions
): string[] {
return formatCollation(values, locale, options);
}

View File

@@ -0,0 +1,5 @@
export {
compareCaseInsensitive,
compareNumericAware,
sortStrings,
} from "./helpers.js";

View File

@@ -0,0 +1,89 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as root from "./index.js";
describe("@suaveplan/i18n-formatter-collator root barrel", () => {
it("re-exports CollatorFormatter", { timeout: 5000 }, () => {
expect(root.CollatorFormatter).toBeDefined();
});
it("re-exports compareStrings + formatCollation", { timeout: 5000 }, () => {
expect(typeof root.compareStrings).toBe("function");
expect(typeof root.formatCollation).toBe("function");
});
it("re-exports clearCollatorCache + schema", { timeout: 5000 }, () => {
expect(typeof root.clearCollatorCache).toBe("function");
expect(root.CollatorFormatterOptionsSchema).toBeDefined();
});
it("re-exports three ergonomic helpers", { timeout: 5000 }, () => {
expect(typeof root.compareCaseInsensitive).toBe("function");
expect(typeof root.compareNumericAware).toBe("function");
expect(typeof root.sortStrings).toBe("function");
});
it("re-exports observability surface", { timeout: 5000 }, () => {
expect(typeof root.recordCollatorFormat).toBe("function");
expect(typeof root.recordCollatorCacheHit).toBe("function");
expect(typeof root.getCollatorMetricsState).toBe("function");
expect(typeof root.resetCollatorMetricsState).toBe("function");
});
it("every documented public export resolves", { timeout: 5000 }, () => {
const expected = [
"CollatorFormatter",
"compareStrings",
"formatCollation",
"clearCollatorCache",
"CollatorFormatterOptionsSchema",
"compareCaseInsensitive",
"compareNumericAware",
"sortStrings",
"recordCollatorFormat",
"recordCollatorCacheHit",
"getCollatorMetricsState",
"resetCollatorMetricsState",
];
const exported = Object.keys(root);
for (const name of expected) {
expect(exported).toContain(name);
}
});
});
describe("@suaveplan/i18n-formatter-collator subpath imports", () => {
it("every documented subpath export resolves", {
timeout: 5000,
}, async () => {
const formatter = await import("./formatter/formatter.js");
expect(formatter.CollatorFormatter).toBeDefined();
const helpers = await import("./helpers/helpers.js");
expect(typeof helpers.compareCaseInsensitive).toBe("function");
});
});
const PACKAGE_JSON_PATH = fileURLToPath(
new URL("../package.json", import.meta.url)
);
describe("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("package.json declares the three documented subpath exports", {
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,31 @@
// Root barrel for @suaveplan/i18n-formatter-collator. Explicit named
// re-exports only — no `export *` per CLAUDE.md §10.
export {
CollatorFormatter,
clearCollatorCache,
compareStrings,
formatCollation,
} from "./formatter/index.js";
export { CollatorFormatterOptionsSchema } from "./formatter.schemas.js";
export type {
CollatorCaseFirst,
CollatorFormatterCacheKey,
CollatorFormatterOptions,
CollatorNumeric,
CollatorSensitivity,
CollatorUsage,
} from "./formatter.types.js";
export {
compareCaseInsensitive,
compareNumericAware,
sortStrings,
} from "./helpers/index.js";
export {
type CollatorMetricSample,
type CollatorMetricsState,
getCollatorMetricsState,
recordCollatorCacheHit,
recordCollatorFormat,
resetCollatorMetricsState,
} from "./observability/index.js";

View File

@@ -0,0 +1,10 @@
export type {
CollatorMetricSample,
CollatorMetricsState,
} from "./observability.js";
export {
getCollatorMetricsState,
recordCollatorCacheHit,
recordCollatorFormat,
resetCollatorMetricsState,
} from "./observability.js";

View File

@@ -0,0 +1,100 @@
# Collator observability
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The Collator formatter exports two OpenTelemetry instruments and a
small in-memory state ring so spec-conformance tests can assert exact
attribute payloads without standing up a full SDK exporter. The
instrument names match the canonical Wave-3 formatter contract —
every sibling formatter emits the same two metric names so dashboards
and alerts compose cleanly across the family.
## Features
- Histogram `i18n.format.collator.duration_ms` (unit `ms`) — per-compare/-sort wall-clock time
- Counter `i18n.format.cache_hit{formatter="collator"}` — increments on cache reuse only
- In-memory state ring (`getCollatorMetricsState()`) for spec-conformance
- `resetCollatorMetricsState()` test-only helper
## Usage: Basic Example
```ts
import { metrics } from "@opentelemetry/api";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
const provider = new MeterProvider({ readers: [/* reader */] });
metrics.setGlobalMeterProvider(provider);
import { compareStrings } from "@suaveplan/i18n-formatter-collator";
compareStrings("a", "b", "en-GB");
// emits via your reader
```
## Usage: Advanced Example
```ts
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
clearCollatorCache,
compareStrings,
} from "@suaveplan/i18n-formatter-collator";
import {
getCollatorMetricsState,
resetCollatorMetricsState,
} from "@suaveplan/i18n-formatter-collator";
it("emits histogram + cache-hit counter", () => {
clearCollatorCache();
resetCollatorMetricsState();
compareStrings("a", "b", "en-GB", { sensitivity: "base", usage: "sort" });
compareStrings("a", "b", "en-GB", { sensitivity: "base", usage: "sort" });
const state = getCollatorMetricsState();
expect(state.durationSamples).toHaveLength(2);
expect(state.cacheHitSamples).toHaveLength(1);
expect(state.durationSamples[0]?.attributes).toMatchObject({
locale: "en-GB",
usage: "sort",
sensitivity: "base",
});
});
```
## API Reference
### `recordCollatorFormat(locale, usage, sensitivity, durationMs): void`
Records the duration histogram sample. Tags: `locale`, `usage`,
`sensitivity`. Called internally by `compareStrings` /
`formatCollation`.
### `recordCollatorCacheHit(): void`
Increments the cache-hit counter with the constant tag
`{ formatter: "collator" }`.
### `getCollatorMetricsState(): CollatorMetricsState`
Returns the in-memory ring of `durationSamples` and
`cacheHitSamples`. Used by spec-conformance tests.
### `resetCollatorMetricsState(): void`
Clears both ring buffers. Test-only helper.
## Implementation Notes
- The OTel meter is named `suaveplan.i18n.formatter.collator`. Every
formatter in the family has its own meter so OTel resource
attributes can be tied per-package if a consumer chooses.
- Defaults supplied to the histogram tag when caller omits options:
`usage = "sort"`, `sensitivity = "variant"` (matching `Intl.Collator`
defaults). Every sample carries non-null values for all three tags.
- Sample dashboard query (PromQL/Grafana over OTLP):
`histogram_quantile(0.99, sum(rate(i18n_format_collator_duration_ms_bucket[5m])) by (le, locale, usage))`
- The in-memory ring is intentionally unbounded — tests are short and
call `resetCollatorMetricsState()` between cases. A long-running
test loop should call reset to avoid memory growth.

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
clearCollatorCache,
compareStrings,
formatCollation,
} from "../formatter/formatter.js";
import {
getCollatorMetricsState,
resetCollatorMetricsState,
} from "./observability.js";
describe("Collator observability", () => {
it("emits i18n.format.collator.duration_ms histogram on every call", {
timeout: 5000,
}, () => {
clearCollatorCache();
resetCollatorMetricsState();
compareStrings("a", "b", "en-GB", {
usage: "sort",
sensitivity: "base",
});
const state = getCollatorMetricsState();
expect(state.durationSamples).toHaveLength(1);
expect(state.durationSamples[0]?.attributes).toMatchObject({
locale: "en-GB",
usage: "sort",
sensitivity: "base",
});
});
it("cache-hit counter increments only on second call with same options", {
timeout: 5000,
}, () => {
clearCollatorCache();
resetCollatorMetricsState();
compareStrings("a", "b", "en-GB");
expect(getCollatorMetricsState().cacheHitSamples).toHaveLength(0);
compareStrings("a", "b", "en-GB");
const state = getCollatorMetricsState();
expect(state.cacheHitSamples).toHaveLength(1);
expect(state.cacheHitSamples[0]?.attributes.formatter).toBe("collator");
});
it("formatCollation also emits histogram + cache-hit", {
timeout: 5000,
}, () => {
clearCollatorCache();
resetCollatorMetricsState();
formatCollation(["c", "a", "b"], "en-GB");
formatCollation(["x", "y", "z"], "en-GB");
const state = getCollatorMetricsState();
expect(state.durationSamples).toHaveLength(2);
expect(state.cacheHitSamples).toHaveLength(1);
});
});

View File

@@ -0,0 +1,79 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
/**
* OTel instruments for the Collator formatter.
*
* Histogram records per-compare/-sort wall-clock duration; the counter
* increments on every cache reuse so consumers can observe cache
* effectiveness.
*
* 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 CollatorMetricSample {
readonly attributes: Record<string, string | number>;
}
export interface CollatorMetricsState {
readonly durationSamples: CollatorMetricSample[];
readonly cacheHitSamples: CollatorMetricSample[];
}
let cached:
| {
readonly durationMs: Histogram;
readonly cacheHit: Counter;
}
| undefined;
const state: CollatorMetricsState = {
durationSamples: [],
cacheHitSamples: [],
};
function ensureInstruments() {
if (cached) return cached;
const meter = metrics.getMeter("suaveplan.i18n.formatter.collator");
cached = {
durationMs: meter.createHistogram("i18n.format.collator.duration_ms", {
description:
"Duration in ms of CollatorFormatter.compare / sort; labels: locale, usage, sensitivity.",
unit: "ms",
}),
cacheHit: meter.createCounter("i18n.format.cache_hit", {
description:
"Format-time cache reuse counter; labels: formatter (collator).",
}),
};
return cached;
}
export function recordCollatorFormat(
locale: string,
usage: string,
sensitivity: string,
durationMs: number
): void {
const ins = ensureInstruments();
const attributes = { locale, usage, sensitivity };
ins.durationMs.record(durationMs, attributes);
state.durationSamples.push({ attributes });
}
export function recordCollatorCacheHit(): void {
const ins = ensureInstruments();
const attributes = { formatter: "collator" };
ins.cacheHit.add(1, attributes);
state.cacheHitSamples.push({ attributes });
}
export function getCollatorMetricsState(): CollatorMetricsState {
return state;
}
export function resetCollatorMetricsState(): void {
state.durationSamples.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-collator",
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,
},
});