@mailwoman/codex 9.4.0 → 10.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/README.md +221 -75
  2. package/lib/address/format.ts +144 -0
  3. package/lib/address/key.ts +134 -0
  4. package/lib/address/layout.ts +189 -0
  5. package/lib/address/layouts/generated.ts +1576 -0
  6. package/lib/address/layouts/index.ts +213 -0
  7. package/lib/address/render.ts +176 -0
  8. package/lib/{address-system-conventions.ts → address/system-conventions.ts} +6 -5
  9. package/lib/component.ts +96 -0
  10. package/lib/country/index.ts +3 -2
  11. package/lib/country/{reference.ts → reference/index.ts} +1 -1
  12. package/lib/country/region-languages.ts +46 -0
  13. package/lib/es/co-official-languages.ts +62 -0
  14. package/lib/es/index.ts +1 -0
  15. package/lib/gb/index.ts +2 -2
  16. package/lib/index.ts +4 -2
  17. package/lib/placetype-map.ts +157 -0
  18. package/lib/postcode/shapes.json +80 -0
  19. package/lib/postcode/shapes.ts +56 -0
  20. package/lib/{postcode-systems.ts → postcode/systems.ts} +8 -1
  21. package/lib/us/index.ts +2 -2
  22. package/lib/us/po-box.ts +37 -10
  23. package/lib/us/{street-directional.ts → street/directional.ts} +1 -1
  24. package/lib/us/{street-suffix.ts → street/suffix.ts} +1 -1
  25. package/out/address/format.d.ts +87 -0
  26. package/out/address/format.d.ts.map +1 -0
  27. package/out/address/format.js +85 -0
  28. package/out/address/format.js.map +1 -0
  29. package/out/address/key.d.ts +67 -0
  30. package/out/address/key.d.ts.map +1 -0
  31. package/out/address/key.js +91 -0
  32. package/out/address/key.js.map +1 -0
  33. package/out/address/layout.d.ts +104 -0
  34. package/out/address/layout.d.ts.map +1 -0
  35. package/out/address/layout.js +123 -0
  36. package/out/address/layout.js.map +1 -0
  37. package/out/address/layouts/generated.d.ts +21 -0
  38. package/out/address/layouts/generated.d.ts.map +1 -0
  39. package/out/address/layouts/generated.js +1380 -0
  40. package/out/address/layouts/generated.js.map +1 -0
  41. package/out/address/layouts/index.d.ts +81 -0
  42. package/out/address/layouts/index.d.ts.map +1 -0
  43. package/out/address/layouts/index.js +184 -0
  44. package/out/address/layouts/index.js.map +1 -0
  45. package/out/address/render.d.ts +64 -0
  46. package/out/address/render.d.ts.map +1 -0
  47. package/out/address/render.js +115 -0
  48. package/out/address/render.js.map +1 -0
  49. package/out/{address-system-conventions.d.ts → address/system-conventions.d.ts} +6 -5
  50. package/out/address/system-conventions.d.ts.map +1 -0
  51. package/out/{address-system-conventions.js → address/system-conventions.js} +2 -2
  52. package/out/address/system-conventions.js.map +1 -0
  53. package/out/au/delivery-service.d.ts.map +1 -1
  54. package/out/au/level-designator.d.ts.map +1 -1
  55. package/out/au/state.d.ts.map +1 -1
  56. package/out/ca/province.d.ts.map +1 -1
  57. package/out/component.d.ts +50 -0
  58. package/out/component.d.ts.map +1 -0
  59. package/out/component.js +84 -0
  60. package/out/component.js.map +1 -0
  61. package/out/country/codes.d.ts +2 -2
  62. package/out/country/codes.d.ts.map +1 -1
  63. package/out/country/display-names.d.ts.map +1 -1
  64. package/out/country/display-names.js.map +1 -1
  65. package/out/country/index.d.ts +3 -2
  66. package/out/country/index.d.ts.map +1 -1
  67. package/out/country/index.js +3 -2
  68. package/out/country/index.js.map +1 -1
  69. package/out/country/{reference-data.d.ts → reference/data.d.ts} +1 -1
  70. package/out/country/reference/data.d.ts.map +1 -0
  71. package/out/country/{reference-data.js → reference/data.js} +1 -1
  72. package/out/country/reference/data.js.map +1 -0
  73. package/out/country/{reference.d.ts → reference/index.d.ts} +1 -1
  74. package/out/country/reference/index.d.ts.map +1 -0
  75. package/out/country/{reference.js → reference/index.js} +2 -2
  76. package/out/country/reference/index.js.map +1 -0
  77. package/out/country/region-languages.d.ts +25 -0
  78. package/out/country/region-languages.d.ts.map +1 -0
  79. package/out/country/region-languages.js +41 -0
  80. package/out/country/region-languages.js.map +1 -0
  81. package/out/de/bundesland.d.ts.map +1 -1
  82. package/out/de/postleitzahl.d.ts.map +1 -1
  83. package/out/de/street-type.d.ts.map +1 -1
  84. package/out/es/co-official-languages.d.ts +37 -0
  85. package/out/es/co-official-languages.d.ts.map +1 -0
  86. package/out/es/co-official-languages.js +61 -0
  87. package/out/es/co-official-languages.js.map +1 -0
  88. package/out/es/index.d.ts +1 -0
  89. package/out/es/index.d.ts.map +1 -1
  90. package/out/es/index.js +1 -0
  91. package/out/es/index.js.map +1 -1
  92. package/out/fr/departement.d.ts.map +1 -1
  93. package/out/fr/region.d.ts.map +1 -1
  94. package/out/gb/country.d.ts.map +1 -1
  95. package/out/gb/index.d.ts +2 -2
  96. package/out/gb/index.d.ts.map +1 -1
  97. package/out/gb/index.js +2 -2
  98. package/out/gb/index.js.map +1 -1
  99. package/out/gb/{postcode-area.d.ts → postcode/area.d.ts} +1 -1
  100. package/out/gb/postcode/area.d.ts.map +1 -0
  101. package/out/gb/{postcode-area.js → postcode/area.js} +1 -1
  102. package/out/gb/postcode/area.js.map +1 -0
  103. package/out/gb/{postcode.d.ts → postcode/index.d.ts} +1 -1
  104. package/out/gb/postcode/index.d.ts.map +1 -0
  105. package/out/gb/{postcode.js → postcode/index.js} +1 -1
  106. package/out/gb/postcode/index.js.map +1 -0
  107. package/out/gb/street-type.d.ts.map +1 -1
  108. package/out/index.d.ts +4 -2
  109. package/out/index.d.ts.map +1 -1
  110. package/out/index.js +4 -2
  111. package/out/index.js.map +1 -1
  112. package/out/jp/address-unit.d.ts.map +1 -1
  113. package/out/jp/prefecture.d.ts.map +1 -1
  114. package/out/level-semantics.d.ts.map +1 -1
  115. package/out/level-semantics.js.map +1 -1
  116. package/out/nz/delivery-service.d.ts.map +1 -1
  117. package/out/placetype-map.d.ts +83 -0
  118. package/out/placetype-map.d.ts.map +1 -0
  119. package/out/placetype-map.js +130 -0
  120. package/out/placetype-map.js.map +1 -0
  121. package/out/postcode/shapes.d.ts +47 -0
  122. package/out/postcode/shapes.d.ts.map +1 -0
  123. package/out/postcode/shapes.js +38 -0
  124. package/out/postcode/shapes.js.map +1 -0
  125. package/out/postcode/shapes.json +80 -0
  126. package/out/{postcode-systems.d.ts → postcode/systems.d.ts} +8 -1
  127. package/out/postcode/systems.d.ts.map +1 -0
  128. package/out/{postcode-systems.js → postcode/systems.js} +9 -2
  129. package/out/postcode/systems.js.map +1 -0
  130. package/out/us/floor-designator.d.ts.map +1 -1
  131. package/out/us/index.d.ts +2 -2
  132. package/out/us/index.js +2 -2
  133. package/out/us/military-address.d.ts.map +1 -1
  134. package/out/us/po-box.d.ts +6 -0
  135. package/out/us/po-box.d.ts.map +1 -1
  136. package/out/us/po-box.js +35 -10
  137. package/out/us/po-box.js.map +1 -1
  138. package/out/us/state.d.ts.map +1 -1
  139. package/out/us/{street-directional.d.ts → street/directional.d.ts} +1 -1
  140. package/out/us/street/directional.d.ts.map +1 -0
  141. package/out/us/{street-directional.js → street/directional.js} +2 -2
  142. package/out/us/street/directional.js.map +1 -0
  143. package/out/us/{street-suffix.d.ts → street/suffix.d.ts} +1 -1
  144. package/out/us/street/suffix.d.ts.map +1 -0
  145. package/out/us/{street-suffix.js → street/suffix.js} +2 -2
  146. package/out/us/street/suffix.js.map +1 -0
  147. package/out/us/zipcode.d.ts +1 -1
  148. package/out/us/zipcode.d.ts.map +1 -1
  149. package/package.json +110 -30
  150. package/lib/tools/build-country-surface-lexicon.ts +0 -177
  151. package/lib/tools/export-country-surfaces.ts +0 -48
  152. package/lib/tools/generate-country-population.ts +0 -142
  153. package/lib/tools/generate-country-reference.ts +0 -168
  154. package/lib/tools/generate-official-languages.ts +0 -210
  155. package/lib/tools/index.ts +0 -12
  156. package/out/address-system-conventions.d.ts.map +0 -1
  157. package/out/address-system-conventions.js.map +0 -1
  158. package/out/country/reference-data.d.ts.map +0 -1
  159. package/out/country/reference-data.js.map +0 -1
  160. package/out/country/reference.d.ts.map +0 -1
  161. package/out/country/reference.js.map +0 -1
  162. package/out/gb/postcode-area.d.ts.map +0 -1
  163. package/out/gb/postcode-area.js.map +0 -1
  164. package/out/gb/postcode.d.ts.map +0 -1
  165. package/out/gb/postcode.js.map +0 -1
  166. package/out/postcode-systems.d.ts.map +0 -1
  167. package/out/postcode-systems.js.map +0 -1
  168. package/out/tools/build-country-surface-lexicon.d.ts +0 -43
  169. package/out/tools/build-country-surface-lexicon.d.ts.map +0 -1
  170. package/out/tools/build-country-surface-lexicon.js +0 -149
  171. package/out/tools/build-country-surface-lexicon.js.map +0 -1
  172. package/out/tools/export-country-surfaces.d.ts +0 -15
  173. package/out/tools/export-country-surfaces.d.ts.map +0 -1
  174. package/out/tools/export-country-surfaces.js +0 -34
  175. package/out/tools/export-country-surfaces.js.map +0 -1
  176. package/out/tools/generate-country-population.d.ts +0 -42
  177. package/out/tools/generate-country-population.d.ts.map +0 -1
  178. package/out/tools/generate-country-population.js +0 -106
  179. package/out/tools/generate-country-population.js.map +0 -1
  180. package/out/tools/generate-country-reference.d.ts +0 -36
  181. package/out/tools/generate-country-reference.d.ts.map +0 -1
  182. package/out/tools/generate-country-reference.js +0 -107
  183. package/out/tools/generate-country-reference.js.map +0 -1
  184. package/out/tools/generate-official-languages.d.ts +0 -50
  185. package/out/tools/generate-official-languages.d.ts.map +0 -1
  186. package/out/tools/generate-official-languages.js +0 -149
  187. package/out/tools/generate-official-languages.js.map +0 -1
  188. package/out/tools/index.d.ts +0 -12
  189. package/out/tools/index.d.ts.map +0 -1
  190. package/out/tools/index.js +0 -12
  191. package/out/tools/index.js.map +0 -1
  192. package/out/us/street-directional.d.ts.map +0 -1
  193. package/out/us/street-directional.js.map +0 -1
  194. package/out/us/street-suffix.d.ts.map +0 -1
  195. package/out/us/street-suffix.js.map +0 -1
  196. /package/lib/country/{reference-data.ts → reference/data.ts} +0 -0
  197. /package/lib/gb/{postcode-area.ts → postcode/area.ts} +0 -0
  198. /package/lib/gb/{postcode.ts → postcode/index.ts} +0 -0
package/README.md CHANGED
@@ -1,38 +1,186 @@
1
- # @mailwoman/codex
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://mailwoman.ai/img/mailwoman-seal-magenta.svg">
4
+ <img src="https://mailwoman.ai/img/mailwoman-seal-navy.svg" alt="" width="96" height="96">
5
+ </picture>
6
+ </p>
7
+
8
+ <h1 align="center">@mailwoman/codex</h1>
9
+
10
+ <p align="center"><strong>A postal address formatter, and the per-country reference data behind it.</strong></p>
11
+
12
+ <p align="center">
13
+ <a href="https://www.npmjs.com/package/@mailwoman/codex"><img alt="npm version" src="https://img.shields.io/npm/v/@mailwoman/codex?color=ff00b0&label=npm"></a>
14
+ <img alt="dependencies" src="https://img.shields.io/badge/runtime%20deps-0-339933">
15
+ <img alt="license" src="https://img.shields.io/npm/l/@mailwoman/codex?color=663399">
16
+ <img alt="node version" src="https://img.shields.io/node/v/@mailwoman/codex?color=339933">
17
+ </p>
18
+
19
+ Printing an address is not string concatenation. Germany puts the house number after the
20
+ street and the postcode before the city. Spain separates the street from the number with a
21
+ comma. Japan writes largest unit first, opens with a postal mark, and joins the whole admin
22
+ run without spaces. Get any of it wrong and you have produced a plausible address from
23
+ somewhere else.
2
24
 
3
- **Per-address-system postal reference data and branded types.**
25
+ ```ts
26
+ import { formatAddress } from "@mailwoman/codex/address-format"
27
+
28
+ formatAddress(
29
+ { house_number: "1600", street: "Pennsylvania Ave NW", locality: "Washington", region: "DC", postcode: "20500" },
30
+ "US"
31
+ )
32
+ // → "1600 Pennsylvania Ave NW\nWashington, DC 20500"
33
+
34
+ formatAddress({ street: "Willy-Brandt-Straße", house_number: "1", locality: "Berlin", postcode: "10557" }, "DE", {
35
+ singleLine: true,
36
+ })
37
+ // → "Willy-Brandt-Straße 1, 10557 Berlin"
38
+
39
+ formatAddress({ street: "Calle de Alcalá", house_number: "3", locality: "Madrid", postcode: "28014" }, "ES", {
40
+ singleLine: true,
41
+ })
42
+ // → "Calle de Alcalá, 3, 28014 Madrid"
43
+
44
+ formatAddress(
45
+ {
46
+ postcode: "100-0005",
47
+ region: "東京都",
48
+ subregion: "千代田区",
49
+ dependent_locality: "丸の内",
50
+ house_number: "1-9-1",
51
+ },
52
+ "JP",
53
+ { singleLine: true }
54
+ )
55
+ // → "〒100-0005 東京都千代田区丸の内1-9-1"
56
+ ```
57
+
58
+ **197 countries**, no dependencies, no network, no database. It runs in Node, the browser and
59
+ on an edge worker.
60
+
61
+ ## Installation
62
+
63
+ ```bash
64
+ npm install @mailwoman/codex
65
+ # or
66
+ yarn add @mailwoman/codex
67
+ ```
4
68
 
5
- Every country's postal authority (USPS, La Poste, Deutsche Post, …) has its own
6
- conventions for what a postcode, a street suffix, or a unit designator looks like.
7
- `@mailwoman/codex` is the shared, dependency-free home for that reference knowledge,
8
- kept apart from the locale-agnostic tokenizer/solver in `@mailwoman/core` and from
9
- the training pipeline in `@mailwoman/corpus`.
69
+ > [!IMPORTANT]
70
+ > Requires Node.js 24.18.0. Pure ESM.
10
71
 
11
- The parser, the resolver, and the synthesis layer all reach for the same tables
12
- instead of each carrying their own copy.
72
+ ## Formatting
73
+
74
+ ### `formatAddress(components, countryCode, options?)`
75
+
76
+ Takes a partial map of component tags and a country code. Returns the envelope form by
77
+ default; `singleLine: true` joins the lines the way that country joins them — `", "` for
78
+ most, `" "` for Japan and Korea, nothing at all for the Chinese-script systems.
79
+
80
+ A missing value never leaves a dangling separator behind it, because a connector renders
81
+ only when something rendered on both sides of it:
13
82
 
14
83
  ```ts
15
- import { us, fr, gb, de } from "@mailwoman/codex"
84
+ formatAddress({ locality: "New York", postcode: "10118" }, "US", { singleLine: true })
85
+ // → "New York, 10118" — not "New York, , 10118"
86
+ ```
16
87
 
17
- // USPS street suffix lookup
18
- us.lookupStreetSuffix("PKWY") // { primary: "Parkway", standard: "Parkway", ... }
19
- us.lookupStreetSuffix("PKY") // → { primary: "Parkway", standard: "Parkway", ... }
88
+ An unknown country returns `""` rather than guessing an order. 55 of the 252 shipped country
89
+ records carry no usable layout, and saying nothing for one of those reports the absence.
20
90
 
21
- // French postcode pattern
22
- fr.postcodePattern // → /^\d{5}$/
91
+ ### `formatAddressRow(components, countryCode, options?)`
23
92
 
24
- // US ZIP code branded type
25
- import { us } from "@mailwoman/codex"
26
- const zip: us.ZipCode = "94043" // branded, not string alone
93
+ The same render, plus **which tags it printed and which it could not**. That second half is
94
+ the reason this function exists: a country's layout legitimately drops components, and
95
+ searching the output string for each value cannot tell a dropped component from one whose
96
+ text happens to sit inside another (`Paris` inside `Rue de Paris`).
97
+
98
+ ```ts
99
+ formatAddressRow({ locality: "Paris", region: "Île-de-France", postcode: "75008" }, "FR", { singleLine: true })
100
+ // → {
101
+ // raw: "75008 Paris",
102
+ // components: { postcode: "75008", locality: "Paris" },
103
+ // unplaced: ["region"], // France absorbs the region — named, not silently dropped
104
+ // }
27
105
  ```
28
106
 
29
- ## Supported address systems
107
+ Returns `null` when nothing rendered at all.
30
108
 
31
- Each system is exposed as a namespace and as a subpath import:
109
+ ### `canonicalKey(components, options?)`
110
+
111
+ A deterministic match key for record linkage — lowercased, diacritic-stripped,
112
+ punctuation-flattened, fields in a fixed order. Two records for the same address that differ
113
+ only in spelling produce the same key.
32
114
 
33
115
  ```ts
34
- import { us } from "@mailwoman/codex"
35
- import { lookupStreetSuffix } from "@mailwoman/codex/us"
116
+ import { canonicalKey } from "@mailwoman/codex/address-key"
117
+
118
+ canonicalKey({
119
+ house_number: "123",
120
+ street: "Main",
121
+ street_suffix: "St",
122
+ locality: "Portland",
123
+ region: "OR",
124
+ postcode: "97201",
125
+ })
126
+ // → "123|main|st|portland|or|97201"
127
+ ```
128
+
129
+ Venue and attention are excluded on purpose: those carry organization identity, not address
130
+ identity.
131
+
132
+ ### `renderAddress(layout, components)`
133
+
134
+ The layer under `formatAddress`, for callers that need the pieces rather than a string — a
135
+ syntax highlighter, a template that wraps each component in its own element, an aligner
136
+ turning a render into labeled spans. It returns `AddressPiece[]`, each piece carrying the
137
+ tag that produced it (or `null` for a separator).
138
+
139
+ ## Where the order comes from
140
+
141
+ Each country's layout is **data**, written as a tagged template that reads in the order it
142
+ prints:
143
+
144
+ ```ts
145
+ // %N%n%O%n%A%n%C, %S %Z
146
+ US: addr`${attention}
147
+ ${venue}
148
+ ${numberFirstStreet}
149
+ ${locality}, ${region} ${postcode}
150
+ ${country}`,
151
+ ```
152
+
153
+ Checking a country means looking at the shape of an address from there, not at a nested call.
154
+ The line skeletons are derived from [libaddressinput](https://github.com/google/libaddressinput),
155
+ Google's address metadata (Apache-2.0); 186 countries are generated from it and the 11 locales
156
+ this project publishes models for are hand-authored and checked against real addresses on a
157
+ committed board.
158
+
159
+ **One rule governs rendering:**
160
+
161
+ > A node that renders nothing removes itself, and its connector goes with it.
162
+
163
+ A connector between two slots needs a rendered slot on each side. A connector at a line's edge
164
+ has only one side, so it binds to the single slot it touches — which is how Japan's 〒
165
+ disappears along with an absent postcode while an interior space does not. Where several
166
+ connectors survive in a row, the strongest wins: punctuation outranks whitespace, so
167
+ `Calle Mayor, 12` keeps its comma when the street suffix is absent.
168
+
169
+ Four things libaddressinput does not model are authored here, each from a measurement rather
170
+ than a guess: the street line's spelling (183 countries space-join the number and the name, 22
171
+ comma-join), the post-office box line, the country line, and the sub-locality line.
172
+
173
+ ## Reference data
174
+
175
+ The formatter sits on top of the per-address-system tables, which are useful on their own.
176
+
177
+ ```ts
178
+ import { us, fr } from "@mailwoman/codex"
179
+
180
+ us.lookupStreetSuffix("PKWY") // → { primary: "Parkway", standard: "Parkway", … }
181
+ fr.postcodePattern // → /^\d{5}$/
182
+
183
+ const zip: us.ZipCode = "94043" // branded, not string alone
36
184
  ```
37
185
 
38
186
  | System | Scope |
@@ -44,34 +192,29 @@ import { lookupStreetSuffix } from "@mailwoman/codex/us"
44
192
  | **`ca`** | Canada Post postcode format, province abbreviations, the urban/rural FSA split |
45
193
  | **`au`** | Australia Post postcode format, state abbreviations |
46
194
 
47
- ## Cross-system utilities
195
+ ### Which country is this postcode from?
48
196
 
49
197
  ```ts
50
198
  import { candidateSystemsForPostcode } from "@mailwoman/codex"
51
199
 
52
- // Which systems could this shape belong to? A five-digit run is not evidence of one country.
53
200
  candidateSystemsForPostcode("94043") // → ["us", "de", "fr"]
54
201
  candidateSystemsForPostcode("SW1A 1AA") // → ["gb"]
55
-
56
- // Address system conventions (forbidden tags, expected shapes, etc.)
57
- import { ADDRESS_SYSTEM_CONVENTIONS, conventionsForSystem } from "@mailwoman/codex"
58
202
  ```
59
203
 
60
204
  Note the first answer. This is a **shape** test, not a gazetteer membership test, and
61
- returning all three is the correct answer rather than a hedge — the caller's country
62
- scope is what narrows it. Picking one locale here would be a guess wearing a fact's
63
- clothes.
205
+ returning all three is the correct answer rather than a hedge — the caller's country scope is
206
+ what narrows it. Picking one locale here would be a guess wearing a fact's clothes.
64
207
 
65
- ## Postcode granularity: three tiers, each earned by measurement
208
+ ### Postcode granularity: three tiers, each earned by measurement
66
209
 
67
- One `postalcode` placetype covers systems that are not comparable. An Irish Eircode
68
- names a single address; an Australian postcode names a locality. Between them sit
69
- most of the world, and the distinction that changes an answer is narrower:
70
- **is this code finer than the locality that contains it?**
210
+ One `postalcode` placetype covers systems that are not comparable. An Irish Eircode names a
211
+ single address; an Australian postcode names a locality. Between them sit most of the world,
212
+ and the distinction that changes an answer is narrower: **is this code finer than the locality
213
+ that contains it?**
71
214
 
72
- That is a fact about a country's _administrative_ geography, not its postal system,
73
- and code length does not predict it. France and Germany are both five digits and land
74
- on opposite sides.
215
+ That is a fact about a country's _administrative_ geography, not its postal system, and code
216
+ length does not predict it. France and Germany are both five digits and land on opposite
217
+ sides.
75
218
 
76
219
  ```ts
77
220
  import { isUnitGradePostcodeHit, areaPostcodeLeadsLocality } from "@mailwoman/codex"
@@ -88,54 +231,57 @@ areaPostcodeLeadsLocality("FR") // → false (one code postal often spans severa
88
231
  | **area, but still finer than the locality** (`AREA_POSTCODE_FINER_THAN_LOCALITY`) | DE | full-panel measurement: 5.84 km → 1.24 km p50, better on every percentile |
89
232
  | **area** (the default) | everything else | the locality-first convention |
90
233
 
91
- Membership is earned by a measurement, never by a shape that looks tight. Canada is
92
- the case that shows why: its urban LDU is unit-grade, its **rural** LDU measures
93
- 2.08 km against the locality's 929 m and is excluded — and Canada Post already marks
94
- the difference with a `0` in the second character, so the code says which before any
95
- lookup runs. The pooled Canadian number reads 0.10 km and looks like a uniform win;
96
- it is two populations, and a tier claim that averages two granularities is exactly
97
- what these tables exist to prevent.
234
+ Membership is earned by a measurement, never by a shape that looks tight. Canada is the case
235
+ that shows why: its urban LDU is unit-grade, its **rural** LDU measures 2.08 km against the
236
+ locality's 929 m and is excluded — and Canada Post already marks the difference with a `0` in
237
+ the second character, so the code says which before any lookup runs. The pooled Canadian
238
+ number reads 0.10 km and looks like a uniform win; it is two populations, and a tier claim
239
+ that averages two granularities is exactly what these tables exist to prevent.
240
+
241
+ ## What this package does not do
98
242
 
99
- `@mailwoman/resolver`'s `admin-winner` consumes both predicates, and so does the
100
- browser demo's pin ranking — one definition, because the two once disagreed and
101
- nothing noticed.
243
+ It does not **parse**. Turning `"1600 Amphitheatre Pkwy, Mountain View CA"` into components is
244
+ a sequence-labeling problem, and that is [`mailwoman`](https://www.npmjs.com/package/mailwoman)
245
+ a small transformer encoder, installed separately. It does not geocode either. This package
246
+ is the inverse direction and the reference tables, and it stays dependency-free so a consumer
247
+ who only wants to print an address does not pull a model down.
102
248
 
103
249
  ## Design
104
250
 
105
- - **Zero runtime dependencies.** Pure TypeScript data tables — no database, no I/O,
106
- no network. Suitable for bundling into browser and edge environments.
107
- - **Branded types.** ZIP codes, postcodes, and abbreviations carry nominal types
108
- so the type system catches locale mismatches at compile time.
109
- - **One definition.** The resolver, the decoder's convention masks, the
110
- corpus synthesis layer, and the matcher all import from `@mailwoman/codex`.
251
+ - **Zero runtime dependencies.** Pure TypeScript data and a small evaluator — no database, no
252
+ I/O, no network. Suitable for bundling into browser and edge environments.
253
+ - **Branded types.** ZIP codes, postcodes and abbreviations carry nominal types, so the type
254
+ system catches locale mismatches at compile time.
255
+ - **An absence is reported, never invented.** A country with no layout returns `""`; a
256
+ component the layout has no slot for is named in `unplaced`.
257
+ - **One definition.** The parser, the resolver, the corpus synthesis layer and the matcher all
258
+ import these tables rather than each carrying a copy.
111
259
 
112
260
  ## The normative tier (codex vs the libpostal dictionaries)
113
261
 
114
- Mailwoman carries two closed-class vocabularies that overlap on purpose and must not
115
- be merged:
116
-
117
- - **Codex is normative.** USPS Pub-28 (and each system's equivalent) verbatim: the
118
- canonical word, every _recognized_ variant, and the one _preferred_ abbreviation.
119
- Its consumers are precision-shaped — corpus synthesis recipes, the eval harness's
120
- invariance transforms, and formatting (rendering `N` vs `North` requires knowing
121
- which form the authority prints).
122
- - **The libpostal dictionaries** (`core/data/libpostal/dictionaries/`, Pelias
123
- lineage) **are descriptive**: everything people write, including forms no
124
- authority recognizes (`en/directionals.txt` lists `lower`/`upper`/`central`).
125
- Their consumers are recall-shaped — evidence-lexicon curation laws, street
126
- decomposition for training gold, the street-morphology FST. See the README in
127
- that directory for the full consumer map and the four-tier curated-data layering.
128
-
129
- Broadening codex with descriptive forms would corrupt formatting; narrowing the
130
- descriptive lists to normative forms would weaken the evidence guards. Different
131
- questions, different tables.
262
+ Mailwoman carries two closed-class vocabularies that overlap on purpose and must not be
263
+ merged:
264
+
265
+ - **Codex is normative.** USPS Pub-28 (and each system's equivalent) verbatim: the canonical
266
+ word, every _recognized_ variant, and the one _preferred_ abbreviation. Its consumers are
267
+ precision-shaped — corpus synthesis recipes, the eval harness's invariance transforms, and
268
+ formatting (rendering `N` vs `North` requires knowing which form the authority prints).
269
+ - **The libpostal dictionaries** (`core/data/libpostal/dictionaries/`, Pelias lineage) **are
270
+ descriptive**: everything people write, including forms no authority recognizes
271
+ (`en/directionals.txt` lists `lower`/`upper`/`central`). Their consumers are recall-shaped
272
+ evidence-lexicon curation laws, street decomposition for training gold, the street-morphology
273
+ FST.
274
+
275
+ Broadening codex with descriptive forms would corrupt formatting; narrowing the descriptive
276
+ lists to normative forms would weaken the evidence guards. Different questions, different
277
+ tables.
132
278
 
133
279
  ## Related
134
280
 
135
- - [`@mailwoman/core`](../core) — `ComponentTag` schema, pipeline infrastructure
136
- - [`@mailwoman/address-id`](../address-id) — uses codex for stable address primary keys
281
+ - [`mailwoman`](https://www.npmjs.com/package/mailwoman) — the parser: free text → components
282
+ - [`@mailwoman/record`](../record) — record schema and per-field normalizers for entity matching
283
+ - [`@mailwoman/address-id`](../address-id) — stable address primary keys, built on these tables
137
284
  - [`@mailwoman/resolver`](../resolver) — consumes the granularity tiers to order a resolved tree
138
- - [Address system conventions](https://github.com/sister-software/mailwoman/blob/main/docs/engineering/reference/SCHEMA.mdx)
139
285
 
140
286
  ## License
141
287
 
@@ -0,0 +1,144 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Render a `ComponentTag`-keyed dict into a country-localized string — the inverse of the parser.
7
+ *
8
+ * The order lives in `@mailwoman/codex/address-layouts`, as data. This module is the public surface over
9
+ * {@linkcode renderAddress}: {@linkcode formatAddress} is the join, and {@linkcode formatAddressRow} is the join plus
10
+ * the tags the layout printed, which is what every corpus adapter actually wants.
11
+ *
12
+ * IT USED TO WRAP A THIRD-PARTY ENGINE, and 229 of this file's 438 lines existed to work around templates written
13
+ * against OpenStreetMap's tag vocabulary rather than this project's: a pass that parsed 295 mustache templates at
14
+ * module load to discover which of them could render a sub-locality, a second that spliced a missing line back in
15
+ * afterwards, a third that removed a connector the template wrote between two slots when one was empty, and a
16
+ * translation layer between the two vocabularies. Owning the layouts deletes all four — a layout that declares a
17
+ * `dependent_locality` slot needs no interrogation about whether it has one, and a line assembled from present values
18
+ * never writes a connector around an absent one.
19
+ */
20
+
21
+ import { layoutForCountry, lineJoinForCountry } from "#address/layouts/index"
22
+ import { joinRendering, renderAddress, type ComponentDict } from "#address/render"
23
+ import type { ComponentTag } from "#component"
24
+
25
+ export type { ComponentDict } from "#address/render"
26
+
27
+ /**
28
+ * Options accepted by {@linkcode formatAddress} and {@linkcode formatAddressRow}.
29
+ */
30
+ export interface FormatAddressOptions {
31
+ /**
32
+ * Replace the layout's line breaks with this separator. Default `"\n"`: the envelope form.
33
+ */
34
+ separator?: string
35
+
36
+ /**
37
+ * Join the lines the way the COUNTRY does, for the single-line form a query or a corpus row takes — `", "` for most,
38
+ * `" "` for Japan and Korea, and nothing at all for the Chinese-script systems, whose admin run is unseparated.
39
+ *
40
+ * It is an option rather than each caller's literal because the literal is wrong outside the anglophone systems:
41
+ * joining Japan's lines with a comma gives `1-9-1, 丸の内, 千代田区, 東京都 100-0005`, which is the romanized convention
42
+ * printed backwards. `separator` wins when both are given.
43
+ */
44
+ singleLine?: boolean
45
+ }
46
+
47
+ function separatorFor(country: string, opts: FormatAddressOptions): string {
48
+ if (opts.separator !== undefined) return opts.separator
49
+
50
+ return opts.singleLine ? lineJoinForCountry(country) : "\n"
51
+ }
52
+
53
+ /**
54
+ * Render a component dict into an idiomatic per-country address string.
55
+ *
56
+ * Returns an empty string when the dict is empty, and when no layout names `country` — 55 of the 252 shipped country
57
+ * records carry no usable skeleton, and answering nothing for one of those reports absence rather than inventing an
58
+ * order. Throws nothing; a partial dict degrades to the parts the layout can print.
59
+ */
60
+ export function formatAddress(components: ComponentDict, country: string, opts: FormatAddressOptions = {}): string {
61
+ return formatAddressRow(components, country, opts)?.raw ?? ""
62
+ }
63
+
64
+ /**
65
+ * A rendered address and the components that survived the render.
66
+ */
67
+ export interface AddressRow {
68
+ /**
69
+ * The rendered string.
70
+ */
71
+ readonly raw: string
72
+ /**
73
+ * The subset of the input dict the layout PRINTED, with the caller's original values. This is the half a corpus row
74
+ * needs: a label whose text is not in `raw` cannot be aligned against it.
75
+ */
76
+ readonly components: ComponentDict
77
+ /**
78
+ * Tags the dict carried a value for that the layout has no slot for, NAMED rather than silently dropped. France
79
+ * absorbing a region into its postcode line is the common case.
80
+ */
81
+ readonly unplaced: readonly ComponentTag[]
82
+ }
83
+
84
+ /**
85
+ * Render `components` for `country` and report what the layout printed, in one pass.
86
+ *
87
+ * Returns null when nothing rendered — an empty dict, a country with no layout, or a dict whose every value falls in a
88
+ * slot this country omits. Every corpus adapter asked both questions and paid for two renders to get them, then
89
+ * recovered the alignment by searching the output string for each value; that search cannot tell a component the layout
90
+ * dropped from one whose value happens to sit inside another — `Paris` inside `Rue de Paris`. The render knows, so the
91
+ * answer is read rather than inferred.
92
+ */
93
+ export function formatAddressRow(
94
+ components: ComponentDict,
95
+ country: string,
96
+ opts: FormatAddressOptions = {}
97
+ ): AddressRow | null {
98
+ const layout = layoutForCountry(country)
99
+
100
+ if (!layout) return null
101
+
102
+ const rendering = renderAddress(layout, components)
103
+
104
+ if (!rendering.placed.length) return null
105
+
106
+ const raw = joinRendering(rendering, separatorFor(country, opts))
107
+
108
+ if (!raw) return null
109
+
110
+ const placed: ComponentDict = {}
111
+
112
+ for (const tag of rendering.placed) {
113
+ const value = components[tag]
114
+
115
+ if (value) {
116
+ placed[tag] = value
117
+ }
118
+ }
119
+
120
+ return { raw, components: placed, unplaced: rendering.unplaced }
121
+ }
122
+
123
+ /**
124
+ * Which of `components` occur verbatim in `raw`, case- and whitespace-insensitively.
125
+ *
126
+ * This is a question about a string somebody else built — a committed golden fixture, a source's own address line — and
127
+ * it is the WEAKER of the two reconciliations: a substring test cannot tell a component the renderer dropped from one
128
+ * whose value happens to sit inside another. Anything rendered through a layout should read
129
+ * {@linkcode formatAddressRow}'s `components` instead, which the render knows rather than infers.
130
+ */
131
+ export function componentsPresentIn(components: ComponentDict, raw: string): ComponentDict {
132
+ const haystack = raw.toLowerCase().replaceAll(/\s+/g, " ")
133
+ const out: ComponentDict = {}
134
+
135
+ for (const [tag, value] of Object.entries(components)) {
136
+ if (!value) continue
137
+
138
+ if (haystack.includes(value.toLowerCase().replaceAll(/\s+/g, " "))) {
139
+ out[tag as ComponentTag] = value
140
+ }
141
+ }
142
+
143
+ return out
144
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The canonical match key — a normalized, deterministic string derived from address components,
7
+ * distinct from the human-readable formatted string.
8
+ *
9
+ * Where `format.ts` produces something for a person to read, this produces something for a
10
+ * _machine_ to collide on: lowercased, diacritic-stripped, punctuation-flattened, whitespace-
11
+ * collapsed, fields in a fixed canonical order. Two records for the same address that differ only
12
+ * in spelling, case, or punctuation produce the same key — which is exactly what the matcher's
13
+ * blocking stage wants as one cheap, high-precision candidate signal (alongside geographic
14
+ * proximity, which carries the real weight — see the geocode-first record-matching concept doc).
15
+ *
16
+ * Deliberately NOT done yet (follow-ups, all conditioned on `@mailwoman/codex`): expanding street
17
+ * suffixes (`Ave` → `avenue`) and directionals (`N` → `north`) to a canonical form, and
18
+ * USPS-style standardization. This first version is pure normalization with no dictionary expansion,
19
+ * so the key is stable and explainable; expansion is an additive refinement, not a rewrite.
20
+ */
21
+
22
+ import type { ComponentDict } from "#address/format"
23
+ import type { ComponentTag } from "#component"
24
+
25
+ /**
26
+ * The address-identifying components, in canonical key order. Venue / attention are intentionally excluded — those
27
+ * carry organization identity, which the record layer keys separately.
28
+ */
29
+ const KEY_FIELD_ORDER = [
30
+ "po_box",
31
+ "house_number",
32
+ "street_prefix",
33
+ "street_prefix_particle",
34
+ "street",
35
+ "street_suffix",
36
+ "intersection_a",
37
+ "intersection_b",
38
+ "unit",
39
+ "dependent_locality",
40
+ "locality",
41
+ "subregion",
42
+ "region",
43
+ "postcode",
44
+ "country",
45
+ ] as const satisfies readonly ComponentTag[]
46
+
47
+ /**
48
+ * Options accepted by {@linkcode canonicalKey}.
49
+ */
50
+ export interface CanonicalKeyOptions {
51
+ /**
52
+ * Field separator in the emitted key. Default `"|"` — preserves field boundaries for blocking.
53
+ */
54
+ separator?: string
55
+ }
56
+
57
+ /**
58
+ * Options for {@linkcode foldForKey} — the two points where the formatter's address-token fold and the record package's
59
+ * fragment fold legitimately differ.
60
+ */
61
+ export interface FoldForKeyOptions {
62
+ /**
63
+ * How connective punctuation folds. `"space"` turns `&`, `+`, and `/` into word boundaries (`"A&B"` → `"a b"`);
64
+ * `"and"` spells `&` and `+` out as the word `and` (`"AT&T"` → `"at and t"`), leaving `/` to the punctuation
65
+ * catch-all (still a word boundary).
66
+ */
67
+ ampersand: "space" | "and"
68
+ /**
69
+ * Intra-token deletion set. When true, periods join the apostrophes as intra-token noise and are deleted (`"S.A."` →
70
+ * `"sa"`), while a backtick falls to the punctuation catch-all. When false or omitted, backticks are deleted
71
+ * alongside the apostrophes and periods become word boundaries (`"S.A."` → `"s a"`).
72
+ */
73
+ dropPeriods?: boolean
74
+ }
75
+
76
+ /**
77
+ * The shared fold behind every match key: NFKD-decompose and strip combining marks (so `é` → `e`), lowercase, delete
78
+ * intra-token punctuation, expand or flatten connective punctuation per {@linkcode FoldForKeyOptions}, space every
79
+ * remaining non-alphanumeric, and collapse whitespace. Deterministic — the same input and options always yield the same
80
+ * output.
81
+ */
82
+ export function foldForKey(input: string, options: FoldForKeyOptions): string {
83
+ const folded = input
84
+ .normalize("NFKD")
85
+ // strip combining marks (U+0300–U+036F) left by NFKD decomposition, so "é" → "e"
86
+ .replaceAll(/[\u0300-\u036F]/g, "")
87
+ .toLowerCase()
88
+ // apostrophes are intra-word (possessives, "O'Brien") — delete so the token stays whole
89
+ .replaceAll(options.dropPeriods ? /[.'’]/g : /['’`]/g, "")
90
+
91
+ const connected =
92
+ options.ampersand === "and"
93
+ ? folded.replaceAll("&", " and ").replaceAll("+", " and ")
94
+ : folded.replaceAll(/[&+/]/g, " ")
95
+
96
+ return (
97
+ connected
98
+ // everything else non-alphanumeric (keep spaces) is noise
99
+ .replaceAll(/[^a-z0-9\s]/g, " ")
100
+ .replaceAll(/\s+/g, " ")
101
+ .trim()
102
+ )
103
+ }
104
+
105
+ /**
106
+ * Normalize a single token for matching: {@linkcode foldForKey} with connective punctuation flattened to spaces (so
107
+ * `"A&B"` → `"a b"`, not `"ab"`). Deterministic and reversible-free — the same input always yields the same output.
108
+ */
109
+ export function normalizeAddressToken(input: string): string {
110
+ return foldForKey(input, { ampersand: "space" })
111
+ }
112
+
113
+ /**
114
+ * Derive the canonical match key from an address component dict: each present, address-identifying field normalized via
115
+ * {@linkcode normalizeAddressToken}, in fixed order, joined by the separator. Empty / whitespace-only fields are
116
+ * skipped. Returns an empty string if nothing identifying remains.
117
+ */
118
+ export function canonicalKey(components: ComponentDict, opts: CanonicalKeyOptions = {}): string {
119
+ const separator = opts.separator ?? "|"
120
+ const parts: string[] = []
121
+
122
+ for (const tag of KEY_FIELD_ORDER) {
123
+ const value = components[tag]
124
+
125
+ if (!value) continue
126
+ const normalized = normalizeAddressToken(value)
127
+
128
+ if (normalized) {
129
+ parts.push(normalized)
130
+ }
131
+ }
132
+
133
+ return parts.join(separator)
134
+ }