Adding Or Updating A Locale
May 14, 2026 ยท View on GitHub
This document is the contributor guide for Humanizer's localization pipeline.
Together with Locale YAML How-To, this document is intended to be sufficient for contributors to understand what a locale file may contain, how locale data flows through the generator, and what must be verified before locale work is complete. Contributors should not need to inspect C# source files just to discover the allowed locale shape or the required parity workflow.
Use Locale YAML Reference as the field-by-field inventory after you understand the contract described here.
Design Goals
The localization system is intentionally opinionated.
- Locale-owned data lives in exactly one checked-in YAML file per locale under
src/Humanizer/Locales. - Shared algorithms live in runtime C# kernels under
src/Humanizer/Localisation. - The source generator turns locale YAML into typed runtime registrations and typed profile objects.
- There is no runtime YAML parsing and no runtime JSON parsing on hot paths.
- Locale-specific leaf converters are a last resort, not the default implementation strategy.
- Shipped locales are expected to resolve every canonical surface intentionally; there is no shipped-locale exemption list in this repo.
Repository Map
These are the files and directories you usually need to understand:
src/Humanizer/Locales/<locale>.ymlThis is the source of truth for locale-owned generated behavior.src/Humanizer.SourceGenerators/Common/CanonicalLocaleAuthoring.csDefines the canonical locale YAML surface: allowed top-level keys, canonical surface names, and nested canonical members such asnumber.words,number.parse,number.formatting,ordinal.numeric,ordinal.date,ordinal.dateOnly,calendar.months,calendar.monthsGenitive, andcalendar.hijriMonths.src/Humanizer.SourceGenerators/Common/LocaleYamlCatalog.csParses locale YAML, resolves inheritance, validates feature blocks, and exposes a resolved per-locale view to the rest of the generator.src/Humanizer.SourceGenerators/Common/EngineContractCatalog.csDescribes how a structural engine maps locale data to a typed runtime constructor call.src/Humanizer.SourceGenerators/Generators/ProfileCatalogs/*Build the generated profile catalogs and tables for number-to-words, words-to-number, ordinalizers, date-to-ordinal, formatters, locale phrases, compass headings, and clock notation.src/Humanizer.SourceGenerators/Generators/LocaleRegistryInput.csEmits the locale-to-implementation wiring.src/Humanizer/Localisation/*Shared runtime kernels.tests/Humanizer.SourceGenerators.TestsVerifies generator behavior and generated source structure.tests/Humanizer.TestsVerifies runtime behavior for real cultures.
The High-Level Flow
The pipeline works like this:
- A locale YAML file declares
locale, optionalvariantOf, and the locale'ssurfaces. LocaleYamlCatalogreads every locale file and resolvesvariantOfinheritance.- Each feature block selects either:
- a generated profile path
- a token-map generation path
- or (rarely) a handwritten runtime path when the locale behavior is still genuinely procedural
EngineContractCatalogtells the generator how to map structured locale data into constructor arguments for a shared runtime kernel.- The profile catalog generators emit typed cached profile instances.
LocaleRegistryInputemits the locale-to-implementation registrations.- At runtime, Humanizer resolves the locale through generated registries and shared kernels with no data parsing on the hot path.
What Goes In The Locale YAML
A locale YAML file owns locale-specific words, switches, lists, mappings, and feature selection.
Canonical top-level keys are:
localevariantOfsurfaces
Supported canonical surfaces are:
listformatterphrasesnumberordinalclockcompasscalendar
Every locale file does not need to author every surface directly. If a surfaces.<surface> block is missing, that surface must still resolve intentionally through same-language inheritance with proof. If it does not, the locale is incomplete.
Do not keep a surface block only to indicate default behavior. If a locale does not need locale-specific data for a surface or nested block, omit it entirely.
If a locale claims parity, it must explicitly account for both number.words and number.parse, either locale-owned or same-language inherited with proof.
Canonical Locale Contract
The canonical locale shape is exact. A locale file contains:
locale- optional
variantOf surfaces(required for non-variant locales; optional for variant locales with no overrides)
Under surfaces, the canonical members are exactly:
listformatterphrasesnumberordinalclockcompasscalendar
The nested canonical members are:
number.wordsnumber.parsenumber.formattingordinal.numericordinal.dateordinal.dateOnlycalendar.monthscalendar.monthsGenitivecalendar.hijriMonths
The phrases surface is also structured, with these canonical members:
relativeDatedurationdataUnitstimeUnits
If you are authoring or reviewing a locale, treat that as the full allowed locale contract. Do not invent alternate top-level keys, alternate surface names, or alternate nested names.
A locale is not complete unless every canonical surface is explicitly accounted for as locale-owned or same-language inherited with proof. There is no shipped-locale exemption list in this repo.
Canonical Surface Responsibilities
This is what each canonical surface owns:
| Surface | Owns |
|---|---|
list | Collection conjunction/delimiter behavior only |
formatter | Formatter grammar/resource-selection metadata such as detectors, gender metadata, preposition rules, and fallback transforms |
phrases | Relative date phrases, duration phrases, data-unit phrases, and time-unit phrases |
number.words | Render-side number composition data |
number.parse | Parse-side lexicons and normalization behavior |
ordinal.numeric | Numeric ordinalization |
ordinal.date | DateTime.ToOrdinalWords day placement/day rendering rules |
ordinal.dateOnly | DateOnly.ToOrdinalWords day placement/day rendering rules |
clock | TimeOnly.ToClockNotation phrase templates or clock engine selection |
compass | Full and abbreviated heading/compass labels |
calendar | Month-name overrides (nominative, genitive, and Hijri) for stable cross-platform date output |
number.formatting | Decimal separator, negative sign, and group separator overrides for stable cross-platform numeric output |
Two boundaries matter:
formatterandphrasesare separate surfaces. Formatter metadata is not a substitute for authored phrase tables.clockis the canonical locale surface name even though generator and runtime code often refer to the emitted runtime feature astimeOnlyToClockNotation.
Locale File Example
This is the shape of a typical locale file:
locale: 'en-US'
variantOf: 'en'
surfaces:
list:
engine: 'oxford'
number:
words:
engine: 'conjunctional-scale'
minusWord: 'minus'
andWord: 'and'
unitsMap:
- 'zero'
- 'one'
- 'two'
parse:
engine: 'token-map'
normalizationProfile: 'LowercaseRemovePeriods'
cardinalMap:
one: 1
two: 2
hundred: 100
Rules for authoring:
- Put locale words in YAML, not in the generator.
- Prefer inheritance over duplication.
- Prefer explicit structural engine names over generic flags.
- Keep the block structural. If the values describe reusable rules, that locale likely belongs on a shared kernel.
- If you find yourself wanting imperative hooks, stop and decide whether the locale truly needs a residual leaf.
Canonical Locale Skeleton
Use this as the complete structural skeleton for a locale file. Every block is optional except locale, and surfaces is required for non-variant locales. Variant locales (those with variantOf) may omit surfaces entirely when they have no overrides. No other top-level or surface names are valid.
locale: '<locale>'
variantOf: '<parent-locale>'
surfaces:
list:
engine: '<list-engine>'
formatter:
engine: 'profiled'
phrases:
relativeDate:
now: '<text>'
never: '<text>'
past: {}
future: {}
duration:
zero: '<text>'
age:
template: '{value}'
dataUnits: {}
timeUnits: {}
number:
words:
engine: '<number-to-words-engine>'
parse:
engine: '<words-to-number-engine>'
formatting:
decimalSeparator: '<separator>'
negativeSign: '<sign>'
groupSeparator: '<separator>'
ordinal:
numeric:
engine: '<ordinal-engine>'
date:
pattern: '<pattern-with-{day}>'
dayMode: '<day-mode>'
dateOnly:
pattern: '<pattern-with-{day}>'
dayMode: '<day-mode>'
clock:
engine: '<clock-engine>'
calendar:
months:
- '<January>'
- '<February>'
- '<March>'
- '<April>'
- '<May>'
- '<June>'
- '<July>'
- '<August>'
- '<September>'
- '<October>'
- '<November>'
- '<December>'
monthsGenitive:
- '<January-genitive>'
- '<February-genitive>'
- '<March-genitive>'
- '<April-genitive>'
- '<May-genitive>'
- '<June-genitive>'
- '<July-genitive>'
- '<August-genitive>'
- '<September-genitive>'
- '<October-genitive>'
- '<November-genitive>'
- '<December-genitive>'
hijriMonths:
- '<Muharram>'
- '<Safar>'
- '<Rabi-ul-Awwal>'
- '<Rabi-ul-Thani>'
- '<Jumada-ul-Awwal>'
- '<Jumada-ul-Thani>'
- '<Rajab>'
- '<Shaban>'
- '<Ramadan>'
- '<Shawwal>'
- '<Dhul-Qadah>'
- '<Dhul-Hijjah>'
compass:
full: []
short: []
A no-delta variant locale (one that inherits everything from its parent with no overrides) is just two lines:
locale: '<locale>'
variantOf: '<parent-locale>'
Inheritance Rules
Inheritance is resolved per locale file, not per feature file, because there is only one file per locale.
Rules:
variantOfpoints to the parent locale.- Omitting a
surfaces.<surface>block inherits it unchanged from the parent locale. - Scalar overrides replace the inherited scalar.
- Sequence overrides replace the inherited sequence.
- Mapping overrides merge recursively with the inherited mapping.
- Changing
engineinside a child mapping replaces that mapping instead of merging it. - Regional variants should usually inherit from the neutral locale unless the repo already uses a different established parent.
- Locale YAML should not reference internal generated profile identifiers. Use locale-facing values like
selfwhen the generator supports them. - Inheritance is not self-proving. A parity claim still needs at least one locale-specific proving assertion for every inherited canonical surface, and the proof must record the full inheritance chain to the terminal owner.
Examples:
en-USusesvariantOf: 'en'fr-BEusesvariantOf: 'fr'de-CHusesvariantOf: 'de'
When To Reuse An Existing Engine
Reuse an existing engine contract when:
- The runtime algorithm already matches the new locale's composition rules.
- The differences are lexical, list-based, or strategy-based.
- The existing runtime kernel does not need locale-specific hard-coded branches to support the new locale.
When To Add A New Engine Contract
Add a new engine contract only when:
- The behavior is structural and reusable.
- It clearly applies to at least two locales, or to one locale plus an obvious second target already exists.
- The runtime kernel can stay generic once the locale-owned words and switches are passed in.
- The data shape is still coherent and not an exception bucket.
When you add a new engine contract, you usually touch:
src/Humanizer.SourceGenerators/Common/EngineContractCatalog.cs- the relevant profile catalog generator in
src/Humanizer.SourceGenerators/Generators/ProfileCatalogs - a shared runtime kernel under
src/Humanizer/Localisation - source-generator tests
- runtime tests
- benchmark coverage if the runtime path is hot
When A Residual Locale Leaf Is Acceptable
A residual locale-specific converter is acceptable only when at least one of these is true:
- The logic is still genuinely procedural rather than declarative.
- The morphology would force imperative hooks into a shared kernel.
- There is no second locale that can share the abstraction yet.
- Generalizing it would add more complexity than value.
If a runtime kernel still hard-codes language-specific behavior, do not pretend it is generic. Either make it structurally generic for real or keep the locale name.
Note: in the current checked-in locale set, no clock residual leaves remain. All shipped locales resolve clock notation through the unified phrase-clock engine, either directly or through locale inheritance.
Step-By-Step: Add A Brand New Locale
- Decide whether the locale is neutral or regional.
- Choose a parent locale if the locale is a regional variant.
- Produce a preflight gap report covering every canonical surface.
- Create
src/Humanizer/Locales/<locale>.yml. - Add
variantOfif needed. - Add or prove every canonical surface through locale ownership or same-language inheritance with proof.
- Reuse existing structural engines wherever possible.
- Add runtime tests under
tests/Humanizer.Tests/Localisation/<culture>. - Add source-generator assertions if the change alters generated profile wiring.
- Maintain a parity map artifact until the unresolved set is empty.
- Run the validation commands in this document.
Step-By-Step: Add A Regional Variant
- Create
src/Humanizer/Locales/<locale>.yml. - Set
variantOfto the parent locale. - Add only the blocks that differ.
- Avoid copy-pasting the full parent locale.
- Add proving assertions for every inherited canonical surface, not just the fields that differ.
- Record the full inheritance chain to the terminal owner in the parity artifact.
Step-By-Step: Migrate A Locale Off A Leaf Converter
- Study the current converter and isolate the actual rule family.
- Check whether another locale already shares that behavior.
- Move the locale-owned words, switches, and mappings into the locale YAML.
- Add or extend the shared runtime kernel if necessary.
- Add or extend the engine contract in
EngineContractCatalog.cs. - Update the profile generator to emit the shared kernel path.
- Remove the locale leaf only after tests and benchmarks prove parity.
Testing Requirements
Every functional localization change should include:
- generator coverage in
tests/Humanizer.SourceGenerators.Tests - runtime behavior coverage in
tests/Humanizer.Tests dotnet packverification for the main package- benchmark coverage when you touch runtime-sensitive shared kernels or registry dispatch
Mandatory Parity Workflow
If the task is locale parity work, the workflow is stricter than "edit YAML and run tests."
You must:
- produce a preflight gap report for every canonical surface
- maintain a parity map artifact under
artifacts/(local-only; the directory is gitignored โ do not commit it) - keep an effective-gap summary and drive it to empty
- record a before/after parity delta and drive the final unresolved set to empty
- add a closeout line for every canonical surface with ownership path and proof
If you cannot produce an empty unresolved set for the locale, you must report parity not complete.
Recommended commands:
dotnet test tests/Humanizer.SourceGenerators.Tests/Humanizer.SourceGenerators.Tests.csproj --framework net10.0
dotnet test tests/Humanizer.Tests/Humanizer.Tests.csproj --framework net10.0
dotnet test tests/Humanizer.Tests/Humanizer.Tests.csproj --framework net8.0
dotnet pack src/Humanizer/Humanizer.csproj -c Release -o artifacts/plan-validation
When you change hot-path shared kernels, also run the relevant benchmark filters in src/Benchmarks.
Performance Rules
These are non-negotiable:
- No runtime YAML parsing.
- No runtime JSON parsing.
- No reflection-based locale dispatch.
- Generated registries should resolve directly to cached objects or shared kernels.
- New shared kernels must benchmark at parity or better against the baseline they replace.
Contributor Checklist
Before you call the work done, verify all of these:
- the locale is represented by exactly one YAML file
- inheritance is correct
- no locale-owned words were left hard-coded in the generator
- any new shared kernel uses a structural name
- any remaining locale leaf is explicitly justified
- source-generator tests pass
- runtime tests pass on
net10.0andnet8.0 - registry completeness tests pass (
LocaleRegistrySweepTestsandLocaleTheoryMatrixCompletenessTests) dotnet packpasses- benchmark comparisons show no regression versus the chosen base
- verify that
DateTime.ToOrdinalWords(),DateOnly.ToOrdinalWords(),ByteSize,Ordinalize, andMetricNumeralExtensionsoutput for your locale is byte-identical across platforms and target frameworks (including net48/NLS vs net8+/ICU on Windows); if platform globalization data (month names, decimal separators, negative signs, group separators) disagrees, author explicit overrides incalendar:and/ornumber.formatting:rather than relying onCultureInfo