Skip to content

Commit

Permalink
Implement an HTML formatter for addresses.
Browse files Browse the repository at this point in the history
  • Loading branch information
bojanz committed Oct 25, 2020
1 parent ed408e3 commit 9d61d73
Show file tree
Hide file tree
Showing 2 changed files with 354 additions and 0 deletions.
156 changes: 156 additions & 0 deletions formatter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Copyright (c) 2020 Bojan Zivanovic and contributors
// SPDX-License-Identifier: MIT

package address

import (
"html"
"strings"
)

// Formatter formats addresses for display.
type Formatter struct {
locale Locale
// NoCountry turns off displaying the country name.
// Defaults to false.
NoCountry bool
// WrapperElement is the wrapper HTML element.
// Defaults to "p".
WrapperElement string
// WrapperClass is the wrapper HTML class.
// Defaults to "address".
WrapperClass string
}

// NewFormatter creates a new formatter for the given locale.
func NewFormatter(locale Locale) *Formatter {
f := &Formatter{
locale: locale,
WrapperElement: "p",
WrapperClass: "address",
}
return f
}

// Locale returns the locale.
func (f *Formatter) Locale() Locale {
return f.locale
}

// Format formats the given address.
func (f *Formatter) Format(addr Address) string {
if addr.IsEmpty() {
return ""
}
format := GetFormat(addr.CountryCode)
layout := format.SelectLayout(f.locale)
countryBefore := (layout == format.LocalLayout)
countryAfter := (layout != format.LocalLayout)
country := ""
if !f.NoCountry {
countries := GetCountryNames()
country = html.EscapeString(countries[addr.CountryCode])
country = `<span class="country" data-value="` + addr.CountryCode + `">` + country + `</span>`
}
values := f.getValues(addr)
for field, value := range values {
if value != "" {
value = html.EscapeString(value)
value = `<span class="` + f.getClass(field) + `">` + value + `</span>`
values[field] = value
}
}

sb := strings.Builder{}
sb.Grow(200)
sb.WriteString(`<` + f.WrapperElement + ` class="`)
sb.WriteString(f.WrapperClass)
sb.WriteString(`" translate="no">` + "\n")
if !f.NoCountry && countryBefore {
sb.WriteString(country)
sb.WriteString("<br>\n")
}
f.writeValues(&sb, layout, values)
if !f.NoCountry && countryAfter {
sb.WriteString("<br>\n")
sb.WriteString(country)
}
sb.WriteString("\n</" + f.WrapperElement + ">")

return sb.String()
}

// getClass returns the HTML class for the given field.
func (f *Formatter) getClass(field Field) string {
var class string
switch field {
case FieldLine1:
class = "line1"
case FieldLine2:
class = "line2"
case FieldLine3:
class = "line3"
case FieldSublocality:
class = "sublocality"
case FieldLocality:
class = "locality"
case FieldRegion:
class = "region"
case FieldPostalCode:
class = "postal-code"
}

return class
}

// getValues returns all values for the given address, keyed by field.
//
// Region IDs are replaced by region names if available.
func (f *Formatter) getValues(addr Address) map[Field]string {
values := map[Field]string{
FieldLine1: addr.Line1,
FieldLine2: addr.Line2,
FieldLine3: addr.Line3,
FieldSublocality: addr.Sublocality,
FieldLocality: addr.Locality,
FieldRegion: addr.Region,
FieldPostalCode: addr.PostalCode,
}
format := GetFormat(addr.CountryCode)
regions := format.SelectRegions(f.locale)
if !format.ShowRegionID && len(regions) > 0 {
region, ok := regions[addr.Region]
if ok {
values[FieldRegion] = region
}
}

return values
}

// writeValues inserts values into the layout and writes it to b.
//
// Tokens of empty fields are removed, as are their preceeding chars.
// For example: "%L, %P" becomes "%L" when %P has no value.
func (f *Formatter) writeValues(b *strings.Builder, layout string, values map[Field]string) {
prev := 0
for i := 0; i < len(layout); i++ {
if layout[i] != '%' {
continue
}
j, k := i+1, i+2
field := Field(layout[j:k])
if values[field] != "" {
prefix := layout[prev:i]
for l := 0; l < len(prefix); l++ {
if prefix[l] == '\n' {
// Prepend <br> to each newline.
b.WriteString("<br>")
}
b.WriteByte(prefix[l])
}
b.WriteString(values[field])
}
prev = k
}
}
198 changes: 198 additions & 0 deletions formatter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
// Copyright (c) 2020 Bojan Zivanovic and contributors
// SPDX-License-Identifier: MIT

package address_test

import (
"strings"
"testing"

"github.com/bojanz/address"
)

func TestFormatter_Locale(t *testing.T) {
locale := address.NewLocale("fr-CH")
formatter := address.NewFormatter(locale)
got := formatter.Locale().String()
if got != "fr-CH" {
t.Errorf("got %v, want fr-CH", got)
}
}

func TestFormatter_FormatES(t *testing.T) {
locale := address.NewLocale("en")
formatter := address.NewFormatter(locale)
formatter.WrapperElement = "div"
formatter.WrapperClass = "address postal-address"

// Empty address.
got := formatter.Format(address.Address{})
if got != "" {
t.Errorf("got: %v, want an empty string", got)
}

// Address with embedded HTML and an unrecognized region.
addr := address.Address{
Line1: "Calle Numa<b>55</b>",
Locality: "Dos Hermanas",
Region: "SEV",
PostalCode: "41089",
CountryCode: "ES",
}
wantLines := []string{
`<div class="address postal-address" translate="no">`,
`<span class="line1">Calle Numa&lt;b&gt;55&lt;/b&gt;</span><br>`,
`<span class="postal-code">41089</span> <span class="locality">Dos Hermanas</span> <span class="region">SEV</span><br>`,
`<span class="country" data-value="ES">Spain</span>`,
`</div>`,
}
got = formatter.Format(addr)
want := strings.Join(wantLines, "\n")
if got != want {
t.Errorf("got:\n%v\nwant:\n%v", got, want)
}

// Address with no country displayed.
addr = address.Address{
Line1: "Calle Numa 55",
Locality: "Dos Hermanas",
Region: "SE",
PostalCode: "41089",
CountryCode: "ES",
}
wantLines = []string{
`<div class="address postal-address" translate="no">`,
`<span class="line1">Calle Numa 55</span><br>`,
`<span class="postal-code">41089</span> <span class="locality">Dos Hermanas</span> <span class="region">Sevilla</span>`,
`</div>`,
}
formatter.NoCountry = true
got = formatter.Format(addr)
want = strings.Join(wantLines, "\n")
if got != want {
t.Errorf("got:\n%v\nwant:\n%v", got, want)
}
}

func TestFormatter_FormatUS(t *testing.T) {
locale := address.NewLocale("en")
formatter := address.NewFormatter(locale)

// Full address (every field provided).
addr := address.Address{
Line1: "c/o The Westin Seattle",
Line2: "Room #505",
Line3: "1900 5th Avenue",
Locality: "Seattle",
Region: "WA",
PostalCode: "98101",
CountryCode: "US",
}
wantLines := []string{
`<p class="address" translate="no">`,
`<span class="line1">c/o The Westin Seattle</span><br>`,
`<span class="line2">Room #505</span><br>`,
`<span class="line3">1900 5th Avenue</span><br>`,
`<span class="locality">Seattle</span>, <span class="region">WA</span> <span class="postal-code">98101</span><br>`,
`<span class="country" data-value="US">United States</span>`,
`</p>`,
}
got := formatter.Format(addr)
want := strings.Join(wantLines, "\n")
if got != want {
t.Errorf("got:\n%v\nwant:\n%v", got, want)
}

// Partial address (no postal code).
addr = address.Address{
Line1: "1098 Alta Ave",
Locality: "Mountain View",
Region: "CA",
CountryCode: "US",
}
wantLines = []string{
`<p class="address" translate="no">`,
`<span class="line1">1098 Alta Ave</span><br>`,
`<span class="locality">Mountain View</span>, <span class="region">CA</span><br>`,
`<span class="country" data-value="US">United States</span>`,
`</p>`,
}
got = formatter.Format(addr)
want = strings.Join(wantLines, "\n")
if got != want {
t.Errorf("got:\n%v\nwant:\n%v", got, want)
}

// Partial address (no region).
addr = address.Address{
Line1: "1098 Alta Ave",
Locality: "Mountain View",
PostalCode: "94043",
CountryCode: "US",
}
wantLines = []string{
`<p class="address" translate="no">`,
`<span class="line1">1098 Alta Ave</span><br>`,
`<span class="locality">Mountain View</span> <span class="postal-code">94043</span><br>`,
`<span class="country" data-value="US">United States</span>`,
`</p>`,
}
got = formatter.Format(addr)
want = strings.Join(wantLines, "\n")
if got != want {
t.Errorf("got:\n%v\nwant:\n%v", got, want)
}
}

func TestFormatter_FormatCN(t *testing.T) {
locale := address.NewLocale("en")
formatter := address.NewFormatter(locale)
// Latin address.
addr := address.Address{
Line1: "Xing Fu Zhong Lu",
Sublocality: "Xincheng Qu",
Locality: "Xi'an Shi",
Region: "SN",
PostalCode: "710043",
CountryCode: "CN",
}
wantLines := []string{
`<p class="address" translate="no">`,
`<span class="line1">Xing Fu Zhong Lu</span><br>`,
`<span class="sublocality">Xincheng Qu</span><br>`,
`<span class="locality">Xi&#39;an Shi</span><br>`,
`<span class="region">Shaanxi Sheng</span>, <span class="postal-code">710043</span><br>`,
`<span class="country" data-value="CN">China</span>`,
`</p>`,
}
got := formatter.Format(addr)
want := strings.Join(wantLines, "\n")
if got != want {
t.Errorf("got:\n%v\nwant:\n%v", got, want)
}

locale = address.NewLocale("zh")
formatter = address.NewFormatter(locale)
// Local address.
addr = address.Address{
Line1: "幸福中路",
Sublocality: "新城区",
Locality: "西安市",
Region: "SN",
PostalCode: "710043",
CountryCode: "CN",
}
wantLines = []string{
`<p class="address" translate="no">`,
`<span class="country" data-value="CN">China</span><br>`,
`<span class="postal-code">710043</span><br>`,
`<span class="region">陕西省</span><span class="locality">西安市</span><span class="sublocality">新城区</span><br>`,
`<span class="line1">幸福中路</span>`,
`</p>`,
}
got = formatter.Format(addr)
want = strings.Join(wantLines, "\n")
if got != want {
t.Errorf("got:\n%v\nwant:\n%v", got, want)
}
}

0 comments on commit 9d61d73

Please sign in to comment.