Files
2026-05-27 23:29:53 +01:00

9.3 KiB

Capability Spec: i18n-formatter-segmenter (i18n-formatter-segmenter)

Package: @suaveplan/i18n-formatter-segmenter Status: ACTIVE

Every requirement below carries a **Verified by:** line at authoring time. (See openspec/AGENTS.md Section 13.)

NEW formatter — no source in current monolith. Closes the gap from the original i18n-formatters change scope.

Requirements

Requirement: SegmenterFormatter class wrapping Intl.Segmenter

The package SHALL export a SegmenterFormatter class that wraps Intl.Segmenter with construction-time Zod validation of options and segment(input: string, locale: string): string[] and segmentWithMetadata(input: string, locale: string): SegmentData[] methods. Construction with malformed options SHALL throw I18nError(I18N.INVALID_FORMATTER_OPTIONS).

Scenario: segment with default options (grapheme)

  • GIVEN const f = new SegmenterFormatter()
  • WHEN f.segment("ab", "en-GB") is called
  • THEN the result equals ["a", "b"]

Scenario: invalid options throw INVALID_FORMATTER_OPTIONS

  • WHEN new SegmenterFormatter({ granularity: "invalid" as never }) is called
  • THEN the call throws I18nError with code "I18N.INVALID_FORMATTER_OPTIONS"

Verified by: packages/i18n/i18n-formatter-segmenter/src/formatter.test.ts::"SegmenterFormatter segments with default options" AND packages/i18n/i18n-formatter-segmenter/src/formatter.test.ts::"SegmenterFormatter rejects invalid options with INVALID_FORMATTER_OPTIONS"

Requirement: segmentText standalone helper returns string[]

The package SHALL export a segmentText(input, locale, options?) standalone helper that returns the segments as a plain string[].

Scenario: word granularity returns word segments

  • WHEN segmentText("hello world", "en-GB", { granularity: "word" }) is called
  • THEN the result includes both "hello" and "world" as segments

Scenario: standalone twins class output

  • WHEN both segmentText(input, l, opts) and new SegmenterFormatter(opts).segment(input, l) are called for the same (input, l, opts)
  • THEN the results are identical

Verified by: packages/i18n/i18n-formatter-segmenter/src/formatter.test.ts::"segmentText word granularity returns word segments" AND packages/i18n/i18n-formatter-segmenter/src/formatter.test.ts::"segmentText output twins SegmenterFormatter.segment output"

Requirement: formatSegments standalone helper returns SegmentData[]

The package SHALL export a formatSegments(input, locale, options?) standalone helper that returns SegmentData[] with each entry preserving { segment, index, isWordLike } metadata per Intl.SegmentData.

Scenario: formatSegments preserves index and isWordLike

  • WHEN formatSegments("hi!", "en-GB", { granularity: "word" }) is called
  • THEN the first entry has segment: "hi", index: 0, isWordLike: true AND a subsequent entry has segment: "!", isWordLike: false

Verified by: packages/i18n/i18n-formatter-segmenter/src/formatter.test.ts::"formatSegments preserves index and isWordLike metadata"

Requirement: Grapheme granularity treats compound emoji as a single segment

The package SHALL respect ECMA-402 Intl.Segmenter semantics: a compound emoji (e.g. "👨‍👩‍👧", the family-of-three ZWJ sequence) SHALL be returned as a single segment under granularity: "grapheme".

Scenario: family emoji is a single grapheme

  • WHEN segmentText("👨‍👩‍👧", "en-GB", { granularity: "grapheme" }) is called
  • THEN the result has length 1

Verified by: packages/i18n/i18n-formatter-segmenter/src/formatter.test.ts::"compound emoji is a single grapheme segment"

Requirement: Intl.Segmenter RangeError maps to UNSUPPORTED_LOCALE

The package SHALL catch every RangeError thrown by the Intl.Segmenter constructor and re-throw it as I18nError(I18N.UNSUPPORTED_LOCALE) with the original error attached as cause and the locale string in context.

Scenario: bogus locale throws UNSUPPORTED_LOCALE

  • WHEN segmentText("a", "xx-bogus-tag-!!!") is called
  • THEN the call throws I18nError with code "I18N.UNSUPPORTED_LOCALE" AND the error's cause is the original RangeError

Verified by: packages/i18n/i18n-formatter-segmenter/src/formatter.test.ts::"unsupported locale throws UNSUPPORTED_LOCALE with cause"

Requirement: Per-(locale, granularity) cache

The package SHALL maintain an internal Map<string, Intl.Segmenter> keyed by ${locale}|${granularity}. Repeated calls with identical (locale, granularity) SHALL reuse the same Intl.Segmenter instance. The package SHALL export a clearSegmenterCache() test-only helper.

Scenario: identical key reuses the same Intl.Segmenter

  • GIVEN the cache is empty
  • WHEN segmentText("a", "en-GB") is called twice in sequence
  • THEN only one Intl.Segmenter constructor invocation occurs

Scenario: distinct granularity creates distinct cache entries

  • WHEN segmentText("a", "en-GB", { granularity: "grapheme" }) and segmentText("a", "en-GB", { granularity: "word" }) are called
  • THEN two distinct Intl.Segmenter instances are created and cached

Verified by: packages/i18n/i18n-formatter-segmenter/src/formatter.test.ts::"identical key reuses cached Intl.Segmenter" AND packages/i18n/i18n-formatter-segmenter/src/formatter.test.ts::"distinct granularity creates distinct cache entries"

Requirement: OTel histogram i18n.format.segmenter.duration_ms

The package SHALL emit an OTel histogram named exactly i18n.format.segmenter.duration_ms (unit ms) on every successful segmentText or formatSegments call, tagged with locale and granularity.

Scenario: histogram emitted on every call

  • GIVEN an OTel test exporter capturing histogram observations
  • WHEN segmentText("hello", "en-GB", { granularity: "word" }) is called
  • THEN exactly one observation appears under i18n.format.segmenter.duration_ms with locale and granularity attributes

Verified by: packages/i18n/i18n-formatter-segmenter/src/observability.test.ts::"emits i18n.format.segmenter.duration_ms histogram on every call"

Requirement: OTel cache-hit counter i18n.format.cache_hit

The package SHALL emit an OTel counter i18n.format.cache_hit tagged with formatter: "segmenter" on every cache hit. The counter SHALL NOT increment on the first (cache-miss) call.

Scenario: cache-hit counter increments only on second call

  • GIVEN an OTel test exporter capturing counter observations and an empty cache
  • WHEN segmentText("a", "en-GB") is called twice in sequence
  • THEN the counter i18n.format.cache_hit{formatter="segmenter"} shows exactly one increment

Verified by: packages/i18n/i18n-formatter-segmenter/src/observability.test.ts::"cache-hit counter increments only on second call with same options"

Requirement: Ergonomic helpers — countGraphemes, splitWords, splitSentences

The package SHALL export three ergonomic helpers:

  • countGraphemes(input, locale) — returns the grapheme count (emoji-aware)
  • splitWords(input, locale) — returns word-segments only (filters isWordLike: true)
  • splitSentences(input, locale) — returns sentence-granularity segments

Scenario: countGraphemes treats compound emoji as one

  • WHEN countGraphemes("👨‍👩‍👧", "en-GB") is called
  • THEN the result equals 1

Scenario: countGraphemes counts ASCII characters

  • WHEN countGraphemes("hello", "en-GB") is called
  • THEN the result equals 5

Scenario: splitWords filters non-word-like segments

  • WHEN splitWords("hello, world!", "en-GB") is called
  • THEN the result equals ["hello", "world"]

Scenario: splitSentences returns sentence segments

  • WHEN splitSentences("Hi. How are you?", "en-GB") is called
  • THEN the result has length 2

Verified by: packages/i18n/i18n-formatter-segmenter/src/helpers.test.ts::"countGraphemes treats compound emoji as one" AND packages/i18n/i18n-formatter-segmenter/src/helpers.test.ts::"countGraphemes counts ASCII characters" AND packages/i18n/i18n-formatter-segmenter/src/helpers.test.ts::"splitWords filters non-word-like segments" AND packages/i18n/i18n-formatter-segmenter/src/helpers.test.ts::"splitSentences returns sentence segments"

Requirement: Subpath exports

The package SHALL declare subpath exports in package.json:

  • @suaveplan/i18n-formatter-segmenter — root barrel
  • @suaveplan/i18n-formatter-segmenter/formatter — SegmenterFormatter class + segmentText + formatSegments + types + schema only
  • @suaveplan/i18n-formatter-segmenter/helpers — three ergonomic helpers only

Scenario: every documented subpath resolves

  • WHEN every documented subpath is imported in a consumer test fixture
  • THEN every import resolves with no Module not found errors

Verified by: packages/i18n/i18n-formatter-segmenter/src/index.test.ts::"every documented subpath export resolves"

Requirement: Side-effect-free package

The package SHALL declare "sideEffects": false in package.json.

Scenario: package.json declares sideEffects false

  • WHEN package.json is parsed
  • THEN the sideEffects field equals false

Verified by: packages/i18n/i18n-formatter-segmenter/src/index.test.ts::"package.json declares sideEffects false"