Initial Commit
This commit is contained in:
104
packages/localization/i18n/i18n-formatter-collator/README.md
Normal file
104
packages/localization/i18n/i18n-formatter-collator/README.md
Normal 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.
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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
|
||||
>();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
CollatorFormatter,
|
||||
clearCollatorCache,
|
||||
compareStrings,
|
||||
formatCollation,
|
||||
} from "./formatter.js";
|
||||
@@ -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.
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
compareCaseInsensitive,
|
||||
compareNumericAware,
|
||||
sortStrings,
|
||||
} from "./helpers.js";
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
CollatorMetricSample,
|
||||
CollatorMetricsState,
|
||||
} from "./observability.js";
|
||||
export {
|
||||
getCollatorMetricsState,
|
||||
recordCollatorCacheHit,
|
||||
recordCollatorFormat,
|
||||
resetCollatorMetricsState,
|
||||
} from "./observability.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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-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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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