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.
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.
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 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.
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.
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.
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.
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: 100Rules 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.
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 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'
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.
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
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.
- 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.
- 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.
- 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.
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
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-validationWhen you change hot-path shared kernels, also run the relevant benchmark filters in src/Benchmarks.
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.
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