@mailwoman/codex 9.3.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.
- package/README.md +221 -75
- package/lib/address/format.ts +144 -0
- package/lib/address/key.ts +134 -0
- package/lib/address/layout.ts +189 -0
- package/lib/address/layouts/generated.ts +1576 -0
- package/lib/address/layouts/index.ts +213 -0
- package/lib/address/render.ts +176 -0
- package/lib/{address-system-conventions.ts → address/system-conventions.ts} +6 -5
- package/lib/component.ts +96 -0
- package/lib/country/index.ts +3 -2
- package/lib/country/{reference.ts → reference/index.ts} +1 -1
- package/lib/country/region-languages.ts +46 -0
- package/lib/es/co-official-languages.ts +62 -0
- package/lib/es/index.ts +1 -0
- package/lib/gb/index.ts +2 -2
- package/lib/index.ts +4 -2
- package/lib/jp/index.ts +1 -0
- package/lib/jp/municipality-register.ts +62 -0
- package/lib/kr/index.ts +10 -0
- package/lib/kr/sigungu-register.ts +294 -0
- package/lib/placetype-map.ts +157 -0
- package/lib/postcode/shapes.json +80 -0
- package/lib/postcode/shapes.ts +56 -0
- package/lib/{postcode-systems.ts → postcode/systems.ts} +8 -1
- package/lib/register-completion.ts +24 -0
- package/lib/us/index.ts +2 -2
- package/lib/us/po-box.ts +37 -10
- package/lib/us/{street-directional.ts → street/directional.ts} +1 -1
- package/lib/us/{street-suffix.ts → street/suffix.ts} +1 -1
- package/out/address/format.d.ts +87 -0
- package/out/address/format.d.ts.map +1 -0
- package/out/address/format.js +85 -0
- package/out/address/format.js.map +1 -0
- package/out/address/key.d.ts +67 -0
- package/out/address/key.d.ts.map +1 -0
- package/out/address/key.js +91 -0
- package/out/address/key.js.map +1 -0
- package/out/address/layout.d.ts +104 -0
- package/out/address/layout.d.ts.map +1 -0
- package/out/address/layout.js +123 -0
- package/out/address/layout.js.map +1 -0
- package/out/address/layouts/generated.d.ts +21 -0
- package/out/address/layouts/generated.d.ts.map +1 -0
- package/out/address/layouts/generated.js +1380 -0
- package/out/address/layouts/generated.js.map +1 -0
- package/out/address/layouts/index.d.ts +81 -0
- package/out/address/layouts/index.d.ts.map +1 -0
- package/out/address/layouts/index.js +184 -0
- package/out/address/layouts/index.js.map +1 -0
- package/out/address/render.d.ts +64 -0
- package/out/address/render.d.ts.map +1 -0
- package/out/address/render.js +115 -0
- package/out/address/render.js.map +1 -0
- package/out/{address-system-conventions.d.ts → address/system-conventions.d.ts} +6 -5
- package/out/address/system-conventions.d.ts.map +1 -0
- package/out/{address-system-conventions.js → address/system-conventions.js} +2 -2
- package/out/address/system-conventions.js.map +1 -0
- package/out/au/delivery-service.d.ts.map +1 -1
- package/out/au/level-designator.d.ts.map +1 -1
- package/out/au/state.d.ts.map +1 -1
- package/out/ca/province.d.ts.map +1 -1
- package/out/component.d.ts +50 -0
- package/out/component.d.ts.map +1 -0
- package/out/component.js +84 -0
- package/out/component.js.map +1 -0
- package/out/country/codes.d.ts +2 -2
- package/out/country/codes.d.ts.map +1 -1
- package/out/country/display-names.d.ts.map +1 -1
- package/out/country/display-names.js.map +1 -1
- package/out/country/index.d.ts +3 -2
- package/out/country/index.d.ts.map +1 -1
- package/out/country/index.js +3 -2
- package/out/country/index.js.map +1 -1
- package/out/country/{reference-data.d.ts → reference/data.d.ts} +1 -1
- package/out/country/reference/data.d.ts.map +1 -0
- package/out/country/{reference-data.js → reference/data.js} +1 -1
- package/out/country/reference/data.js.map +1 -0
- package/out/country/{reference.d.ts → reference/index.d.ts} +1 -1
- package/out/country/reference/index.d.ts.map +1 -0
- package/out/country/{reference.js → reference/index.js} +2 -2
- package/out/country/reference/index.js.map +1 -0
- package/out/country/region-languages.d.ts +25 -0
- package/out/country/region-languages.d.ts.map +1 -0
- package/out/country/region-languages.js +41 -0
- package/out/country/region-languages.js.map +1 -0
- package/out/de/bundesland.d.ts.map +1 -1
- package/out/de/postleitzahl.d.ts.map +1 -1
- package/out/de/street-type.d.ts.map +1 -1
- package/out/es/co-official-languages.d.ts +37 -0
- package/out/es/co-official-languages.d.ts.map +1 -0
- package/out/es/co-official-languages.js +61 -0
- package/out/es/co-official-languages.js.map +1 -0
- package/out/es/index.d.ts +1 -0
- package/out/es/index.d.ts.map +1 -1
- package/out/es/index.js +1 -0
- package/out/es/index.js.map +1 -1
- package/out/fr/departement.d.ts.map +1 -1
- package/out/fr/region.d.ts.map +1 -1
- package/out/gb/country.d.ts.map +1 -1
- package/out/gb/index.d.ts +2 -2
- package/out/gb/index.d.ts.map +1 -1
- package/out/gb/index.js +2 -2
- package/out/gb/index.js.map +1 -1
- package/out/gb/{postcode-area.d.ts → postcode/area.d.ts} +1 -1
- package/out/gb/postcode/area.d.ts.map +1 -0
- package/out/gb/{postcode-area.js → postcode/area.js} +1 -1
- package/out/gb/postcode/area.js.map +1 -0
- package/out/gb/{postcode.d.ts → postcode/index.d.ts} +1 -1
- package/out/gb/postcode/index.d.ts.map +1 -0
- package/out/gb/{postcode.js → postcode/index.js} +1 -1
- package/out/gb/postcode/index.js.map +1 -0
- package/out/gb/street-type.d.ts.map +1 -1
- package/out/index.d.ts +4 -2
- package/out/index.d.ts.map +1 -1
- package/out/index.js +4 -2
- package/out/index.js.map +1 -1
- package/out/jp/address-unit.d.ts.map +1 -1
- package/out/jp/index.d.ts +1 -0
- package/out/jp/index.d.ts.map +1 -1
- package/out/jp/index.js +1 -0
- package/out/jp/index.js.map +1 -1
- package/out/jp/municipality-register.d.ts +48 -0
- package/out/jp/municipality-register.d.ts.map +1 -0
- package/out/jp/municipality-register.js +41 -0
- package/out/jp/municipality-register.js.map +1 -0
- package/out/jp/prefecture.d.ts.map +1 -1
- package/out/kr/index.d.ts +10 -0
- package/out/kr/index.d.ts.map +1 -0
- package/out/kr/index.js +10 -0
- package/out/kr/index.js.map +1 -0
- package/out/kr/sigungu-register.d.ts +37 -0
- package/out/kr/sigungu-register.d.ts.map +1 -0
- package/out/kr/sigungu-register.js +283 -0
- package/out/kr/sigungu-register.js.map +1 -0
- package/out/level-semantics.d.ts.map +1 -1
- package/out/level-semantics.js.map +1 -1
- package/out/nz/delivery-service.d.ts.map +1 -1
- package/out/placetype-map.d.ts +83 -0
- package/out/placetype-map.d.ts.map +1 -0
- package/out/placetype-map.js +130 -0
- package/out/placetype-map.js.map +1 -0
- package/out/postcode/shapes.d.ts +47 -0
- package/out/postcode/shapes.d.ts.map +1 -0
- package/out/postcode/shapes.js +38 -0
- package/out/postcode/shapes.js.map +1 -0
- package/out/postcode/shapes.json +80 -0
- package/out/{postcode-systems.d.ts → postcode/systems.d.ts} +8 -1
- package/out/postcode/systems.d.ts.map +1 -0
- package/out/{postcode-systems.js → postcode/systems.js} +9 -2
- package/out/postcode/systems.js.map +1 -0
- package/out/register-completion.d.ts +14 -0
- package/out/register-completion.d.ts.map +1 -0
- package/out/register-completion.js +23 -0
- package/out/register-completion.js.map +1 -0
- package/out/us/floor-designator.d.ts.map +1 -1
- package/out/us/index.d.ts +2 -2
- package/out/us/index.js +2 -2
- package/out/us/military-address.d.ts.map +1 -1
- package/out/us/po-box.d.ts +6 -0
- package/out/us/po-box.d.ts.map +1 -1
- package/out/us/po-box.js +35 -10
- package/out/us/po-box.js.map +1 -1
- package/out/us/state.d.ts.map +1 -1
- package/out/us/{street-directional.d.ts → street/directional.d.ts} +1 -1
- package/out/us/street/directional.d.ts.map +1 -0
- package/out/us/{street-directional.js → street/directional.js} +2 -2
- package/out/us/street/directional.js.map +1 -0
- package/out/us/{street-suffix.d.ts → street/suffix.d.ts} +1 -1
- package/out/us/street/suffix.d.ts.map +1 -0
- package/out/us/{street-suffix.js → street/suffix.js} +2 -2
- package/out/us/street/suffix.js.map +1 -0
- package/out/us/zipcode.d.ts +1 -1
- package/out/us/zipcode.d.ts.map +1 -1
- package/package.json +120 -30
- package/lib/tools/build-country-surface-lexicon.ts +0 -177
- package/lib/tools/export-country-surfaces.ts +0 -48
- package/lib/tools/generate-country-population.ts +0 -142
- package/lib/tools/generate-country-reference.ts +0 -168
- package/lib/tools/generate-official-languages.ts +0 -210
- package/lib/tools/index.ts +0 -12
- package/out/address-system-conventions.d.ts.map +0 -1
- package/out/address-system-conventions.js.map +0 -1
- package/out/country/reference-data.d.ts.map +0 -1
- package/out/country/reference-data.js.map +0 -1
- package/out/country/reference.d.ts.map +0 -1
- package/out/country/reference.js.map +0 -1
- package/out/gb/postcode-area.d.ts.map +0 -1
- package/out/gb/postcode-area.js.map +0 -1
- package/out/gb/postcode.d.ts.map +0 -1
- package/out/gb/postcode.js.map +0 -1
- package/out/postcode-systems.d.ts.map +0 -1
- package/out/postcode-systems.js.map +0 -1
- package/out/tools/build-country-surface-lexicon.d.ts +0 -43
- package/out/tools/build-country-surface-lexicon.d.ts.map +0 -1
- package/out/tools/build-country-surface-lexicon.js +0 -149
- package/out/tools/build-country-surface-lexicon.js.map +0 -1
- package/out/tools/export-country-surfaces.d.ts +0 -15
- package/out/tools/export-country-surfaces.d.ts.map +0 -1
- package/out/tools/export-country-surfaces.js +0 -34
- package/out/tools/export-country-surfaces.js.map +0 -1
- package/out/tools/generate-country-population.d.ts +0 -42
- package/out/tools/generate-country-population.d.ts.map +0 -1
- package/out/tools/generate-country-population.js +0 -106
- package/out/tools/generate-country-population.js.map +0 -1
- package/out/tools/generate-country-reference.d.ts +0 -36
- package/out/tools/generate-country-reference.d.ts.map +0 -1
- package/out/tools/generate-country-reference.js +0 -107
- package/out/tools/generate-country-reference.js.map +0 -1
- package/out/tools/generate-official-languages.d.ts +0 -50
- package/out/tools/generate-official-languages.d.ts.map +0 -1
- package/out/tools/generate-official-languages.js +0 -149
- package/out/tools/generate-official-languages.js.map +0 -1
- package/out/tools/index.d.ts +0 -12
- package/out/tools/index.d.ts.map +0 -1
- package/out/tools/index.js +0 -12
- package/out/tools/index.js.map +0 -1
- package/out/us/street-directional.d.ts.map +0 -1
- package/out/us/street-directional.js.map +0 -1
- package/out/us/street-suffix.d.ts.map +0 -1
- package/out/us/street-suffix.js.map +0 -1
- /package/lib/country/{reference-data.ts → reference/data.ts} +0 -0
- /package/lib/gb/{postcode-area.ts → postcode/area.ts} +0 -0
- /package/lib/gb/{postcode.ts → postcode/index.ts} +0 -0
package/README.md
CHANGED
|
@@ -1,38 +1,186 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
84
|
+
formatAddress({ locality: "New York", postcode: "10118" }, "US", { singleLine: true })
|
|
85
|
+
// → "New York, 10118" — not "New York, , 10118"
|
|
86
|
+
```
|
|
16
87
|
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
22
|
-
fr.postcodePattern // → /^\d{5}$/
|
|
91
|
+
### `formatAddressRow(components, countryCode, options?)`
|
|
23
92
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
107
|
+
Returns `null` when nothing rendered at all.
|
|
30
108
|
|
|
31
|
-
|
|
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 {
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
the
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
106
|
-
no network. Suitable for bundling into browser and edge environments.
|
|
107
|
-
- **Branded types.** ZIP codes, postcodes
|
|
108
|
-
|
|
109
|
-
- **
|
|
110
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
- **Codex is normative.** USPS Pub-28 (and each system's equivalent) verbatim: the
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
- [
|
|
136
|
-
- [`@mailwoman/
|
|
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
|
+
}
|