Skip to content
This repository has been archived by the owner on Oct 1, 2024. It is now read-only.

Commit

Permalink
added name package
Browse files Browse the repository at this point in the history
  • Loading branch information
mikegarfinkle committed Aug 8, 2023
1 parent 09109ac commit 1cc5375
Show file tree
Hide file tree
Showing 21 changed files with 1,076 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .changeset/popular-items-occur.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
5 changes: 5 additions & 0 deletions packages/name/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Changelog

## 1.0.0

- Start of Changelog
52 changes: 52 additions & 0 deletions packages/name/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# `@shopify/name`

[![Build Status](https://github.com/Shopify/quilt/workflows/Node-CI/badge.svg?branch=main)](https://github.com/Shopify/quilt/actions?query=workflow%3ANode-CI)
[![Build Status](https://github.com/Shopify/quilt/workflows/Ruby-CI/badge.svg?branch=main)](https://github.com/Shopify/quilt/actions?query=workflow%3ARuby-CI)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE.md) [![npm version](https://badge.fury.io/js/%40shopify%2Fname.svg)](https://badge.fury.io/js/%40shopify%2Fname)
![npm bundle size (minified + gzip)](https://img.shields.io/bundlephobia/minzip/%40shopify%2Fname.svg)

Utilities for formatting localized names.

## Installation

```bash
yarn add @shopify/name
```

### formatName

Formats a name (given name and/or family name) according to the locale. For example:

- `formatName({name: {givenName: 'John', familyName: 'Smith'}, locale: 'en'})` will return `John` in Germany and `Smith様` in Japan
- `formatName({name: {givenName: 'John', familyName: 'Smith'}, locale: 'en', options: {full: true}})` will return `John Smith` in Germany and `SmithJohn` in Japan

`formatName.hasEasternNameOrderFormatter` returns true when an eastern name order formatter corresponding to the locale
exists.

### abbreviateName

Takes a name (given and family name) and returns a language appropriate abbreviated name, or will return `formatName` if
it is unable to find a suitable abbreviation.

For example:

- `abbreviateName({name: {givenName: 'John', familyName: 'Smith'}, locale: 'en'})` will return `JS`
- `abbreviateName({name: {givenName: '健', familyName: '田中'}, locale: 'en'})` will return `田中`

You may also pass an optional `idealMaxLength` parameter, which gives the maximum allowable abbreviation length when
trying to abbreviate a name in the Korean language (default 3 characters). In Korean, if the first name is longer than
this length, the method will instead return the first character of the first name.

### abbreviateBusinessName

Takes a name and returns a language appropriate abbreviated name, or will return the input name if it is unable to find
a suitable abbreviation.

For example:

- `abbreviateBusinessName({name: 'Shopify'})` will return `Sho`
- `abbreviateBusinessName({name: 'My Store'})` will return `MS`
- `abbreviateBusinessName({name: '任天堂'})` will return `任天堂`

You may also pass an optional `idealMaxLength` parameter, which gives the maximum allowable abbreviation length when
trying to abbreviate a name.
44 changes: 44 additions & 0 deletions packages/name/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "@shopify/name",
"version": "1.0.0",
"license": "MIT",
"description": "Name-related utilities",
"main": "index.js",
"types": "./build/ts/index.d.ts",
"publishConfig": {
"access": "public",
"@shopify:registry": "https://registry.npmjs.org"
},
"author": "Shopify Inc.",
"repository": {
"type": "git",
"url": "git+https://github.com/Shopify/quilt.git",
"directory": "packages/name"
},
"bugs": {
"url": "https://github.com/Shopify/quilt/issues"
},
"homepage": "https://github.com/Shopify/quilt/blob/main/packages/name/README.md",
"engines": {
"node": "^14.17.0 || >=16.0.0"
},
"sideEffects": false,
"files": [
"build/",
"!build/*.tsbuildinfo",
"!build/ts/**/tests/",
"index.js",
"index.mjs",
"index.esnext"
],
"module": "index.mjs",
"esnext": "index.esnext",
"exports": {
".": {
"types": "./build/ts/index.d.ts",
"esnext": "./index.esnext",
"import": "./index.mjs",
"require": "./index.js"
}
}
}
6 changes: 6 additions & 0 deletions packages/name/rollup.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import {buildConfig} from '../../config/rollup.mjs';

export default buildConfig(import.meta.url, {
entries: ['./src/index.ts'],
entrypoints: {index: './src/index.ts'},
});
67 changes: 67 additions & 0 deletions packages/name/src/abbreviateBusinessName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {getGraphemes, identifyScripts} from './utilities';
import {UnicodeCharacterSet} from './constants';

// Note: A similar Ruby implementation of this function also exists at https://github.com/Shopify/shopify-i18n/blob/main/lib/shopify-i18n/business_name_formatter.rb.
export function abbreviateBusinessName({
name,
idealMaxLength,
}: {
name: string;
idealMaxLength?: number;
}) {
return tryAbbreviateBusinessName({name, idealMaxLength}) ?? name;
}

export function tryAbbreviateBusinessName({
name,
idealMaxLength = 3,
}: {
name: string;
idealMaxLength?: number;
}): string | undefined {
const nameTrimmed = name.trim();

const scripts = identifyScripts(nameTrimmed);
if (scripts.length !== 1) {
return undefined;
}
const script = scripts[0];
const words = nameTrimmed.split(' ');

switch (script) {
case UnicodeCharacterSet.Latin:
if (words.length === 1) {
return words[0].slice(0, idealMaxLength);
} else if (words.length <= idealMaxLength) {
return words.map((word) => word[0]).join('');
} else {
return words.slice(0)[0][0] + words.slice(-1)[0][0];
}
case UnicodeCharacterSet.Han:
case UnicodeCharacterSet.Katakana:
case UnicodeCharacterSet.Hiragana: {
const graphemes = getGraphemes({text: nameTrimmed, locale: 'ja'});
if (graphemes.includes(' ')) {
return undefined;
} else {
return nameTrimmed;
}
}
case UnicodeCharacterSet.Hangul: {
const firstWord = nameTrimmed.split(' ')[0];
return getGraphemes({text: firstWord, locale: 'ko'})
.slice(0, idealMaxLength)
.join('');
}
case UnicodeCharacterSet.Thai: {
// Thai language does not use spaces between words
if (nameTrimmed.includes(' ')) {
return undefined;
} else {
return getGraphemes({text: nameTrimmed, locale: 'th'})[0];
}
}
default:
return undefined;
}
}
77 changes: 77 additions & 0 deletions packages/name/src/abbreviateName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {getGraphemes, identifyScripts, tryAbbreviateName} from './utilities';
import {formatName} from './formatName';
import {UnicodeCharacterSet} from './constants';

// Note: A similar Ruby implementation of this function also exists at https://github.com/Shopify/shopify-i18n/blob/main/lib/shopify-i18n/name_formatter.rb.
export function abbreviateName({
name,
locale,
options,
}: {
name: {givenName?: string; familyName?: string};
locale: string;
options?: {idealMaxLength?: number};
}) {
return (
tryAbbreviateName({
givenName: name.givenName,
familyName: name.familyName,
idealMaxLength: options?.idealMaxLength,
}) ?? formatName({name, locale})
);
}

export function tryAbbreviateName({
givenName,
familyName,
idealMaxLength = 3,
}: {
givenName?: string;
familyName?: string;
idealMaxLength?: number;
}): string | undefined {
if (!givenName && !familyName) {
return undefined;
}

const firstNameTrimmed = givenName?.trim();
const lastNameTrimmed = familyName?.trim();

const combinedName = [firstNameTrimmed, lastNameTrimmed].join('');
if (new RegExp(`${UnicodeCharacterSet.Punctuation}|\\s`).test(combinedName)) {
return undefined;
}

const scripts = identifyScripts(combinedName);
if (scripts.length !== 1) {
return undefined;
}
const script = scripts[0];

switch (script) {
case UnicodeCharacterSet.Latin:
return [firstNameTrimmed?.[0], lastNameTrimmed?.[0]].join('');
case UnicodeCharacterSet.Han:
case UnicodeCharacterSet.Katakana:
case UnicodeCharacterSet.Hiragana:
return lastNameTrimmed;
case UnicodeCharacterSet.Hangul:
if (firstNameTrimmed) {
if (firstNameTrimmed.length > idealMaxLength) {
return getGraphemes({text: firstNameTrimmed, locale: 'ko'})?.[0];
} else {
return firstNameTrimmed;
}
} else {
return lastNameTrimmed;
}
case UnicodeCharacterSet.Thai:
if (firstNameTrimmed) {
return getGraphemes({text: firstNameTrimmed, locale: 'th'})?.[0];
} else {
return getGraphemes({text: lastNameTrimmed, locale: 'th'})?.[0];
}
default:
return undefined;
}
}
28 changes: 28 additions & 0 deletions packages/name/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export enum UnicodeCharacterSet {
Punctuation = '[!-#%-\\*,-\\/:;\\?@\\[-\\]_\\{\\}\xA1\xA7\xAB\xB6\xB7\xBB\xBF\u037E\u0387\u055A-\u055F\u0589\u058A\u05BE\u05C0\u05C3\u05C6\u05F3\u05F4\u0609\u060A\u060C\u060D\u061B\u061D-\u061F\u066A-\u066D\u06D4\u0700-\u070D\u07F7-\u07F9\u0830-\u083E\u085E\u0964\u0965\u0970\u09FD\u0A76\u0AF0\u0C77\u0C84\u0DF4\u0E4F\u0E5A\u0E5B\u0F04-\u0F12\u0F14\u0F3A-\u0F3D\u0F85\u0FD0-\u0FD4\u0FD9\u0FDA\u104A-\u104F\u10FB\u1360-\u1368\u1400\u166E\u169B\u169C\u16EB-\u16ED\u1735\u1736\u17D4-\u17D6\u17D8-\u17DA\u1800-\u180A\u1944\u1945\u1A1E\u1A1F\u1AA0-\u1AA6\u1AA8-\u1AAD\u1B5A-\u1B60\u1B7D\u1B7E\u1BFC-\u1BFF\u1C3B-\u1C3F\u1C7E\u1C7F\u1CC0-\u1CC7\u1CD3\u2010-\u2027\u2030-\u2043\u2045-\u2051\u2053-\u205E\u207D\u207E\u208D\u208E\u2308-\u230B\u2329\u232A\u2768-\u2775\u27C5\u27C6\u27E6-\u27EF\u2983-\u2998\u29D8-\u29DB\u29FC\u29FD\u2CF9-\u2CFC\u2CFE\u2CFF\u2D70\u2E00-\u2E2E\u2E30-\u2E4F\u2E52-\u2E5D\u3001-\u3003\u3008-\u3011\u3014-\u301F\u3030\u303D\u30A0\u30FB\uA4FE\uA4FF\uA60D-\uA60F\uA673\uA67E\uA6F2-\uA6F7\uA874-\uA877\uA8CE\uA8CF\uA8F8-\uA8FA\uA8FC\uA92E\uA92F\uA95F\uA9C1-\uA9CD\uA9DE\uA9DF\uAA5C-\uAA5F\uAADE\uAADF\uAAF0\uAAF1\uABEB\uFD3E\uFD3F\uFE10-\uFE19\uFE30-\uFE52\uFE54-\uFE61\uFE63\uFE68\uFE6A\uFE6B\uFF01-\uFF03\uFF05-\uFF0A\uFF0C-\uFF0F\uFF1A\uFF1B\uFF1F\uFF20\uFF3B-\uFF3D\uFF3F\uFF5B\uFF5D\uFF5F-\uFF65\uD800]|[\uDD00-\uDD02\uDF9F\uDFD0]|\uD801\uDD6F|\uD802[\uDC57\uDD1F\uDD3F\uDE50-\uDE58\uDE7F\uDEF0-\uDEF6\uDF39-\uDF3F\uDF99-\uDF9C]|\uD803[\uDEAD\uDF55-\uDF59\uDF86-\uDF89]|\uD804[\uDC47-\uDC4D\uDCBB\uDCBC\uDCBE-\uDCC1\uDD40-\uDD43\uDD74\uDD75\uDDC5-\uDDC8\uDDCD\uDDDB\uDDDD-\uDDDF\uDE38-\uDE3D\uDEA9]|\uD805[\uDC4B-\uDC4F\uDC5A\uDC5B\uDC5D\uDCC6\uDDC1-\uDDD7\uDE41-\uDE43\uDE60-\uDE6C\uDEB9\uDF3C-\uDF3E]|\uD806[\uDC3B\uDD44-\uDD46\uDDE2\uDE3F-\uDE46\uDE9A-\uDE9C\uDE9E-\uDEA2]|\uD807[\uDC41-\uDC45\uDC70\uDC71\uDEF7\uDEF8\uDFFF]|\uD809[\uDC70-\uDC74]|\uD80B[\uDFF1\uDFF2]|\uD81A[\uDE6E\uDE6F\uDEF5\uDF37-\uDF3B\uDF44]|\uD81B[\uDE97-\uDE9A\uDFE2]|\uD82F\uDC9F|\uD836[\uDE87-\uDE8B]|\uD83A[\uDD5E\uDD5F]',
Latin = '[A-Za-z\xAA\xBA\xC0-\xD6\xD8-\xF6\xF8-\u02B8\u02E0-\u02E4\u1D00-\u1D25\u1D2C-\u1D5C\u1D62-\u1D65\u1D6B-\u1D77\u1D79-\u1DBE\u1E00-\u1EFF\u2071\u207F\u2090-\u209C\u212A\u212B\u2132\u214E\u2160-\u2188\u2C60-\u2C7F\uA722-\uA787\uA78B-\uA7CA\uA7D0\uA7D1\uA7D3\uA7D5-\uA7D9\uA7F2-\uA7FF\uAB30-\uAB5A\uAB5C-\uAB64\uAB66-\uAB69\uFB00-\uFB06\uFF21-\uFF3A\uFF41-\uFF5A]|\uD801[\uDF80-\uDF85\uDF87-\uDFB0\uDFB2-\uDFBA]|\uD837[\uDF00-\uDF1E]',
Han = '[\u2E80-\u2E99\u2E9B-\u2EF3\u2F00-\u2FD5\u3005\u3007\u3021-\u3029\u3038-\u303B\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFA6D\uFA70-\uFAD9]|\uD81B[\uDFE2\uDFE3\uDFF0\uDFF1]|[\uD840-\uD868\uD86A-\uD86C\uD86F-\uD872\uD874-\uD879\uD880-\uD883][\uDC00-\uDFFF]|\uD869[\uDC00-\uDEDF\uDF00-\uDFFF]|\uD86D[\uDC00-\uDF38\uDF40-\uDFFF]|\uD86E[\uDC00-\uDC1D\uDC20-\uDFFF]|\uD873[\uDC00-\uDEA1\uDEB0-\uDFFF]|\uD87A[\uDC00-\uDFE0]|\uD87E[\uDC00-\uDE1D]|\uD884[\uDC00-\uDF4A]',
Hangul = '[\u1100-\u11FF\u302E\u302F\u3131-\u318E\u3200-\u321E\u3260-\u327E\uA960-\uA97C\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uFFA0-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC]',
Katakana = '[\u30A1-\u30FA\u30FD-\u30FF\u31F0-\u31FF\u32D0-\u32FE\u3300-\u3357\uFF66-\uFF6F\uFF71-\uFF9D\uD82B]|[\uDFF0-\uDFF3\uDFF5-\uDFFB\uDFFD\uDFFE]|\uD82C[\uDC00\uDD20-\uDD22\uDD64-\uDD67]',
Hiragana = '[\u3041-\u3096\u309D-\u309F]|\uD82C[\uDC01-\uDD1F\uDD50-\uDD52]|\uD83C\uDE00',
Thai = '[\u0E01-\u0E3A\u0E40-\u0E5B]',
}

export const FAMILY_NAME_FIRST_NAME_ORDERING = new Map([
['ko', defaultEasternNameFormatter],
[
'ja',
(firstName: string, lastName: string, full: boolean) =>
full ? `${lastName}${firstName}` : `${lastName}様`,
],
['zh-CN', defaultEasternNameFormatter],
['zh-TW', defaultEasternNameFormatter],
]);

function defaultEasternNameFormatter(
firstName: string,
lastName: string,
full: boolean,
) {
return full ? `${lastName}${firstName}` : lastName;
}
30 changes: 30 additions & 0 deletions packages/name/src/formatName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {FAMILY_NAME_FIRST_NAME_ORDERING} from './constants';

export function formatName({
name,
locale,
options,
}: {
name: {givenName?: string; familyName?: string};
locale: string;
options?: {full?: boolean};
}) {
if (!name.givenName) {
return name.familyName || '';
}
if (!name.familyName) {
return name.givenName;
}

const isFullName = Boolean(options && options.full);

const customNameFormatter = FAMILY_NAME_FIRST_NAME_ORDERING.get(locale);

if (customNameFormatter) {
return customNameFormatter(name.givenName, name.familyName, isFullName);
}
if (isFullName) {
return `${name.givenName} ${name.familyName}`;
}
return name.givenName;
}
7 changes: 7 additions & 0 deletions packages/name/src/hasFamilyNameFirstNameOrdering.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import {FAMILY_NAME_FIRST_NAME_ORDERING} from '../constants';

export function hasFamilyNameFirstNameOrdering(locale: string) {
const familyNameFirstNameOrdering =
FAMILY_NAME_FIRST_NAME_ORDERING.get(locale);
return Boolean(familyNameFirstNameOrdering);
}
4 changes: 4 additions & 0 deletions packages/name/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export {abbreviateName} from './abbreviateName';
export {abbreviateBusinessName} from './abbreviateBusinessName';
export {formatName} from './formatName';
export {hasFamilyNameFirstNameOrdering} from './hasFamilyNameFirstNameOrdering';
16 changes: 16 additions & 0 deletions packages/name/src/tests/abbreviateBusinessName.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {
abbreviateBusinessName,
tryAbbreviateBusinessName,
} from '../abbreviateBusinessName';

describe('#abbreviateBusinessName()', () => {
it('returns input name if no abbreviation found', () => {
const input = {name: '😀😃😄'};
expect(abbreviateBusinessName(input)).toBe(input.name);
});

it('returns abbreviated name if abbreviation found', () => {
const input = {name: 'shop-123'};
expect(abbreviateBusinessName(input)).toBe('sho');
});
});
18 changes: 18 additions & 0 deletions packages/name/src/tests/abbreviateName.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {formatName} from '../formatName';
import {abbreviateName, tryAbbreviateName} from '../abbreviateName';

const locale = 'en';

describe('#abbreviateName()', () => {
it('returns formatName if no abbreviation found', () => {
// no abbreviation as has space in last name
const name = {givenName: 'Michael', familyName: 'van Finkle'};
expect(abbreviateName({name, locale})).toBe(formatName({name, locale}));
});

it('returns abbreviated name if abbreviation found', () => {
const name = {givenName: 'Michael', familyName: 'Garfinkle'};
expect(abbreviateName({name, locale})).toBeDefined();
expect(abbreviateName({name, locale})).toBe(tryAbbreviateName(name));
});
});
Loading

0 comments on commit 1cc5375

Please sign in to comment.