diff --git a/changelog.md b/changelog.md index 90524b6..dce6c5e 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,21 @@ This file contains the documentation on the notable changes and bug fixes, and is formatted following this [standard](https://keepachangelog.com/en/1.0.0/). This project also adheres to [Semantic Versioning](https://semver.org/). +## [2.2.0] - 2026-02-22 + +**Added**: + +- Added support for mononyms (enabled via `Config.mono` flag); only available only for + `Namefully(string | string[] | Names[])` or `NameBuilder`. +- Updated FullName parsing to handle new JsonName structure (now hierarchical) +- Added more practical examples + +**Fixed**: + +- Refactored deserialization logic to accommodate flexible name formats +- Adjusted error handling for invalid name inputs +- Fixed deserialization issue when casting separators + ## [2.1.0] - 2025-12-30 **Added**: @@ -204,6 +219,8 @@ This project also adheres to [Semantic Versioning](https://semver.org/). Initial version +[2.2.0]: https://github.com/ralflorent/namefully/compare/v2.1.0...v2.2.0 +[2.1.0]: https://github.com/ralflorent/namefully/compare/v2.0.2...v2.1.0 [2.0.2]: https://github.com/ralflorent/namefully/compare/v2.0.1...v2.0.2 [2.0.1]: https://github.com/ralflorent/namefully/compare/v2.0.0...v2.0.1 [2.0.0]: https://github.com/ralflorent/namefully/compare/v1.3.0...v2.0.0 diff --git a/example/advanced.ts b/example/advanced.ts index ed0ba41..49a5fe4 100644 --- a/example/advanced.ts +++ b/example/advanced.ts @@ -1,4 +1,4 @@ -import { Name, NameBuilder, Title } from '../src/index'; +import { Name, NameBuilder } from 'namefully'; function main() { const builder = NameBuilder.of(Name.first('Nikola'), Name.last('Tesla')); @@ -11,7 +11,7 @@ function main() { builder.add(Name.prefix('Mr')); // Build the name with options if needed - name = builder.build({ title: Title.US }); + name = builder.build({ title: 'US' }); console.log(name.full); // Mr. Nikola Tesla } diff --git a/example/index.html b/example/index.html new file mode 100644 index 0000000..5846f97 --- /dev/null +++ b/example/index.html @@ -0,0 +1,82 @@ + + + + + Namefully | Demo + + + + + +
+

How to use namefully

+

Simple case

+

Given the name John Smith, use only the first name:

+
+ + +

It can also do that

+

+ Let us say you want to distinguish the name parts of a full name like this + Mr,Smith,John,Ben,Ph.D (comma-separated values), you can achieve the following: +

+
+ +
+ + diff --git a/example/main.ts b/example/main.ts index 267f3bf..93ea8a7 100644 --- a/example/main.ts +++ b/example/main.ts @@ -1,4 +1,4 @@ -import namefully from '../src/index'; +import namefully from 'namefully'; function main() { // Gives a simple name some super power. diff --git a/example/mononym.ts b/example/mononym.ts new file mode 100644 index 0000000..f7dbf02 --- /dev/null +++ b/example/mononym.ts @@ -0,0 +1,17 @@ +import namefully from 'namefully'; + +function main() { + const name = namefully('Plato', { mono: true }); + console.log(name.length); // 5 + console.log(name.first); // Plato + console.log(name.middle); // undefined + console.log(name.last); // + console.log(name.public); // Plato + console.log(name.initials()); // ['P'] + console.log(name.format('L, f m')); // , Plato + console.log(name.shorten()); // Plato + console.log(name.zip()); // P. + console.log(name.toUpperCase()); // PLATO +} + +main(); diff --git a/example/parser.ts b/example/parser.ts new file mode 100644 index 0000000..869a6fd --- /dev/null +++ b/example/parser.ts @@ -0,0 +1,28 @@ +import namefully, { Config, FullName, Parser } from 'namefully'; + +class CustomParser extends Parser { + constructor( + raw: string, + public separator = '|', + ) { + super(raw); + } + + parse(options: Partial): FullName { + const [fn, ln] = this.raw.split(this.separator, 2); + return new FullName(options).setFirstName(fn.trim()).setLastName(ln.trim()); + } +} + +function main() { + const name = namefully(new CustomParser('Juan | García | Jr.')); + console.log(name.full); // Juan García + console.log(name.first); // Juan + console.log(name.initials()); // ['J', 'G'] + console.log(name.format('L, f m')); // García, Juan + console.log(name.size); // 2 + console.log(name.zip()); // Juan G. + console.log(name.toUpperCase()); // JUAN GARCÍA +} + +main(); diff --git a/jsr.json b/jsr.json index 41949a1..6f7dab4 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@ralflorent/namefully", - "version": "2.1.0", + "version": "2.2.0", "description": "Handle personal names in a particular order, way, or shape.", "keywords": ["format", "parse", "human", "personal", "family", "name"], "exports": "./src/index.ts", diff --git a/package.json b/package.json index 9d2ad3d..97c31f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "namefully", - "version": "2.1.0", + "version": "2.2.0", "description": "Handle personal names in a particular order, way, or shape.", "author": "Ralph Florent", "license": "MIT", @@ -30,7 +30,7 @@ "format": "prettier --write src example", "lint": "eslint src", "test": "jest", - "test:cov": "jest --collectCoverage", + "test:cov": "jest --coverage", "prebuild": "shx rm -rf dist", "build:esm": "tsc -p tsconfig.json", "build:cjs": "tsc -p tsconfig.cjs.json", diff --git a/readme.md b/readme.md index 03b1d5f..96321e5 100644 --- a/readme.md +++ b/readme.md @@ -4,6 +4,7 @@ [![JSR Version][jsr-version]][jsr-url] [![CI build][ci-img]][ci-url] [![MIT License][license-img]][license-url] +[![DeepWiki][deepwiki-img]][deepwiki-url] Human name handling made easy. [Try it live](https://stackblitz.com/edit/namefully). @@ -204,6 +205,24 @@ const name = new Namefully( ); ``` +### mono + +`boolean | Namon` - default: `false` + +Enables support for mononyms (i.e., single word names). You may also use `Namon` +to assign which name type is being used to represent the mononym. + +> You should know that this goes against the original design philosophy of the library, +> which is intentionally opinionated around shaping and organizing multiple name components. +> Treating a single token as a "full" name makes a lot of the existing API semantics +> somewhat meaningless. + +```ts +const name = new Namefully('Plato', { mono: true }); // throws an exception without this flag. +console.log(name.full); // Plato +console.log(name.initials()); // ['P'] +``` + To sum it all up, the default values are: ```ts @@ -213,7 +232,8 @@ To sum it all up, the default values are: title: Title.UK, ending: false, bypass: true, - surname: Surname.FATHER + surname: Surname.FATHER, + mono: false } ``` @@ -227,8 +247,10 @@ import { Config, FullName, Namefully, Parser } from 'namefully'; // Suppose you want to cover this '#' separator class SimpleParser extends Parser { parse(options: Partial): FullName { - const [firstName, lastName] = this.raw.split('#'); - return FullName.parse({ firstName, lastName }, Config.merge(options)); + const [fn, ln] = this.raw.split('#', 2); + return new FullName(options) + .setFirstName(fn.trim()) + .setLastName(ln.trim()); } } @@ -303,7 +325,7 @@ So, this utility understands the name parts as follows: `namefully` does not support certain use cases: -- mononame: `Plato` - a workaround is to set the mononame as both first and last name; +- mononame: `Plato` - enable the mononym flag `Config.mono` to support this. - nickname: `Dwayne "The Rock" Johnson` - use custom parser instead. - multiple prefixes or suffixes: `Prof. Dr. Einstein`. @@ -326,6 +348,8 @@ The underlying content of this utility is licensed under [MIT License][license-u [ci-url]: https://github.com/ralflorent/namefully/actions/workflows/ci.yml [license-img]: https://img.shields.io/npm/l/namefully [license-url]: https://opensource.org/licenses/MIT +[deepwiki-img]: https://deepwiki.com/badge.svg +[deepwiki-url]: https://deepwiki.com/ralflorent/namefully [contributing-url]: https://github.com/ralflorent/namefully/blob/main/CONTRIBUTING.md [examples]: https://github.com/ralflorent/namefully/tree/main/example diff --git a/src/builder.ts b/src/builder.ts index 66f5750..8fae278 100644 --- a/src/builder.ts +++ b/src/builder.ts @@ -145,9 +145,10 @@ export class NameBuilder extends Builder { this.prebuild?.(); const names = [...this.queue]; - ArrayNameValidator.create().validate(names); + const config = Config.merge(options as Partial); + if (!config.mono) ArrayNameValidator.create().validate(names); - this.instance = new Namefully(names, options); + this.instance = new Namefully(names, config); this.postbuild?.(this.instance); return this.instance; diff --git a/src/config.ts b/src/config.ts index e882ea8..c3e04c0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,4 @@ -import { NameOrder, Separator, Title, Surname } from './types.js'; +import { NameOrder, Namon, Separator, Title, Surname } from './types.js'; const defaultName = 'default'; const copyAlias = '_copy'; @@ -39,6 +39,7 @@ export class Config { #ending: boolean; #bypass: boolean; #surname: Surname; + #mono: boolean | Namon; /** Cache for multiple instances. */ private static cache = new Map(); @@ -91,6 +92,11 @@ export class Config { return this.#surname; } + /** Whether to parse a single word name as a mononym. */ + get mono(): boolean | Namon { + return this.#mono; + } + /** The name of the cached configuration. */ get name(): string { return this.#name; @@ -104,6 +110,7 @@ export class Config { ending = false, bypass = true, surname = Surname.FATHER, + mono: boolean | Namon = false, ) { this.#name = name; this.#orderedBy = orderedBy; @@ -112,6 +119,7 @@ export class Config { this.#ending = ending; this.#bypass = bypass; this.#surname = surname; + this.#mono = mono; } /** @@ -139,6 +147,7 @@ export class Config { config.#ending = other.ending ?? config.ending; config.#bypass = other.bypass ?? config.bypass; config.#surname = other.surname ?? config.surname; + config.#mono = other.mono ?? config.mono; return config; } } @@ -153,7 +162,7 @@ export class Config { * be named `default_copy`. */ copyWith(options: Partial = {}): Config { - const { name, orderedBy, separator, title, ending, bypass, surname } = options; + const { name, orderedBy, separator, title, ending, bypass, surname, mono } = options; const config = Config.create(this.#genNewName(name ?? this.name + copyAlias)); config.#orderedBy = orderedBy ?? this.orderedBy; config.#separator = separator ?? this.separator; @@ -161,6 +170,7 @@ export class Config { config.#ending = ending ?? this.ending; config.#bypass = bypass ?? this.bypass; config.#surname = surname ?? this.surname; + config.#mono = mono ?? this.mono; return config; } @@ -177,21 +187,11 @@ export class Config { this.#ending = false; this.#bypass = true; this.#surname = Surname.FATHER; + this.#mono = false; Config.cache.set(this.name, this); } - /** - * Alters the name order between the first and last name, and rearrange the - * order of appearance of a name set. - * @deprecated use `update()` method instead. - */ - updateOrder(orderedBy: NameOrder): void { - this.update({ orderedBy }); - } - - /** - * Allows the possibility to alter some options after creating a name set. - */ + /** Allows the possibility to alter behavior-related options after creating a name set. */ update({ orderedBy, title, ending }: Partial>): void { const config = Config.cache.get(this.name); if (!config) return; diff --git a/src/constants.ts b/src/constants.ts index fe7d988..705e08c 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,4 +1,5 @@ -export const VERSION = '2.1.0'; +export const VERSION = '2.2.0'; export const MIN_NUMBER_OF_NAME_PARTS = 2; export const MAX_NUMBER_OF_NAME_PARTS = 5; export const ALLOWED_FORMAT_TOKENS = ` .,_-()[]<>'"bBfFlLmMnNoOpPsS$`; +export const ZERO_WIDTH_SPACE = String.fromCharCode(8203); diff --git a/src/data.spec.ts b/src/data.spec.ts index 400bec6..50dc25f 100644 --- a/src/data.spec.ts +++ b/src/data.spec.ts @@ -2,6 +2,7 @@ import { Namefully } from './namefully.js'; import { InputError, UnknownError } from './error.js'; import { Name, FirstName, LastName } from './name.js'; import { deserialize, SerializedName } from './data.js'; +import { Separator, Title, Surname, NameOrder } from './types.js'; describe('JSON serialization', () => { describe('serialize', () => { @@ -19,6 +20,7 @@ describe('JSON serialization', () => { ending: expect.any(Boolean), bypass: expect.any(Boolean), surname: expect.any(String), + mono: expect.any(Boolean), }), }); }); @@ -82,27 +84,41 @@ describe('JSON serialization', () => { }); describe('deserialize', () => { - test('should deserialize a simple name from object', () => { - const data: SerializedName = { - names: { - firstName: 'John', - lastName: 'Smith', - }, - config: { - name: 'fullName', - orderedBy: 'firstName', - separator: ' ', - title: 'US', - ending: false, - bypass: false, - surname: 'father', - }, + let defaultConfig: SerializedName['config']; + + beforeEach(() => { + defaultConfig = { + name: 'defaultConfig', + orderedBy: 'firstName', + separator: ' ', + title: 'US', + ending: false, + bypass: true, + surname: 'father', + mono: false, }; + }); - const name = deserialize(data); - expect(name.firstName()).toBe('John'); - expect(name.lastName()).toBe('Smith'); + test('should deserialize a simple name from object', () => { + const name = deserialize({ + names: { firstName: 'John', lastName: 'Smith' }, + config: { ...defaultConfig, name: 'fullName', bypass: false, surname: 'all' }, + }); + expect(name.first).toBe('John'); + expect(name.last).toBe('Smith'); expect(name.full).toBe('John Smith'); + expect(name.middle).toBeUndefined(); + expect(name.prefix).toBeUndefined(); + expect(name.suffix).toBeUndefined(); + + expect(name.config.name).toBe('fullName'); + expect(name.config.orderedBy).toBe('firstName'); + expect(name.config.separator).toBe(Separator.SPACE); + expect(name.config.title).toBe(Title.US); + expect(name.config.ending).toBe(false); + expect(name.config.bypass).toBe(false); + expect(name.config.surname).toBe(Surname.ALL); + expect(name.config.mono).toBe(false); }); test('should deserialize a name from JSON string', () => { @@ -110,80 +126,67 @@ describe('JSON serialization', () => { names: { firstName: 'Jane', lastName: 'Doe', + suffix: 'Ph.D', }, config: { - name: 'fullName', - orderedBy: 'firstName', - separator: ' ', - title: 'US', - ending: false, - bypass: false, + name: 'byLastName', + orderedBy: 'lastName', + separator: ',', + title: 'UK', + ending: true, + bypass: true, surname: 'father', }, }); const name = deserialize(jsonString); - expect(name.firstName()).toBe('Jane'); - expect(name.lastName()).toBe('Doe'); + expect(name.first).toBe('Jane'); + expect(name.last).toBe('Doe'); + expect(name.full).toBe('Doe Jane, Ph.D'); + expect(name.middle).toBeUndefined(); + expect(name.prefix).toBeUndefined(); + expect(name.suffix).toBe('Ph.D'); + + expect(name.config.name).toBe('byLastName'); + expect(name.config.orderedBy).toBe(NameOrder.LAST_NAME); + expect(name.config.separator).toBe(Separator.COMMA); + expect(name.config.title).toBe(Title.UK); + expect(name.config.ending).toBe(true); + expect(name.config.bypass).toBe(true); + expect(name.config.surname).toBe(Surname.FATHER); }); test('should deserialize a name with prefix and suffix', () => { - const original = new Namefully([Name.prefix('Mr'), Name.first('John'), Name.last('Smith'), Name.suffix('Jr')]); - const serialized = original.serialize(); + const name = deserialize({ + names: { prefix: 'Mr', firstName: 'John', lastName: 'Smith', suffix: 'Jr' }, + config: defaultConfig, + }); - const name = deserialize(serialized); - expect(name.prefix).toBe(original.prefix); - expect(name.suffix).toBe(original.suffix); + expect(name.prefix).toBe('Mr.'); + expect(name.first).toBe('John'); + expect(name.last).toBe('Smith'); + expect(name.suffix).toBe('Jr'); }); test('should deserialize a name with middle names', () => { - const data: SerializedName = { - names: { - firstName: 'John', - middleName: ['Michael', 'David'], - lastName: 'Smith', - }, - config: { - name: 'fullName', - orderedBy: 'firstName', - separator: ' ', - title: 'US', - ending: false, - bypass: false, - surname: 'father', - }, - }; - - const name = deserialize(data); + const name = deserialize({ + names: { firstName: 'John', middleName: ['Michael', 'David'], lastName: 'Smith' }, + config: defaultConfig, + }); expect(name.middleName()).toEqual(['Michael', 'David']); }); test('should deserialize a name with multiple first names', () => { - const data: SerializedName = { - names: { - firstName: { - value: 'John', - more: ['Michael'], - }, - lastName: 'Smith', - }, - config: { - name: 'fullName', - orderedBy: 'firstName', - separator: ' ', - title: 'US', - ending: false, - bypass: false, - surname: 'father', - }, - }; - - const name = deserialize(data); + const name = deserialize({ + names: { firstName: { value: 'John', more: ['Michael'] }, lastName: 'Smith' }, + config: defaultConfig, + }); expect(name.firstName(false)).toBe('John'); + expect(name.firstName(true)).toEqual('John Michael'); }); test('should deserialize a name with hyphenated last name', () => { - const data: SerializedName = { + const name = deserialize({ names: { firstName: 'John', lastName: { @@ -191,19 +194,11 @@ describe('JSON serialization', () => { mother: 'Jones', }, }, - config: { - name: 'fullName', - orderedBy: 'firstName', - separator: ' ', - title: 'US', - ending: false, - bypass: false, - surname: 'father', - }, - }; - - const name = deserialize(data); - expect(name.lastName()).toBe('Smith'); + config: { ...defaultConfig, surname: 'hyphenated' }, + }); + expect(name.first).toBe('John'); + expect(name.last).toBe('Smith-Jones'); + expect(name.full).toBe('John Smith-Jones'); }); test('should throw NameError for invalid data', () => { @@ -254,5 +249,14 @@ describe('JSON serialization', () => { expect(deserialized.full).toBe(original.full); }); + + test('should serialize and deserialize mononyms', () => { + const original = new Namefully('Plato', { mono: true }); + const serialized = original.serialize(); + const deserialized = deserialize(serialized); + + expect(deserialized.first).toBe(original.first); + expect(deserialized.last).toBe(original.last); + }); }); }); diff --git a/src/data.ts b/src/data.ts index 4b04f5b..d055395 100644 --- a/src/data.ts +++ b/src/data.ts @@ -1,18 +1,13 @@ import { NameBuilder } from './builder.js'; -import { Name, FirstName, LastName } from './name.js'; +import { Namon, Separator } from './types.js'; +import { Name, FirstName, LastName, JsonName } from './name.js'; import { type Namefully, NameOptions } from './namefully.js'; import { InputError, NameError, UnknownError } from './error.js'; /** Serialized representation of a Namefully instance. */ export interface SerializedName { /** The name data (with its hierarchy intact). */ - names: { - prefix?: string; - firstName: string | { value: string; more?: string[] }; - middleName?: string[]; - lastName: string | { father: string; mother?: string }; - suffix?: string; - }; + names: JsonName; /** The configuration data. */ config: { name: string; @@ -22,6 +17,7 @@ export interface SerializedName { ending: boolean; bypass: boolean; surname: string; + mono: boolean | string; }; } @@ -53,12 +49,14 @@ export function deserialize(data: SerializedName | string): Namefully { if (px) builder.add(Name.prefix(px)); if (sx) builder.add(Name.suffix(sx)); - if (mn) builder.add(...mn.map((n) => Name.middle(n))); + if (mn) builder.add(...(typeof mn === 'string' ? [Name.middle(mn)] : mn.map((n) => Name.middle(n)))); builder.add(typeof fn === 'string' ? Name.first(fn) : new FirstName(fn.value, ...(fn.more ?? []))); builder.add(typeof ln === 'string' ? Name.last(ln) : new LastName(ln.father, ln.mother)); - return builder.build(config as unknown as NameOptions); + const separator = Separator.cast(config.separator); + const mono = typeof config.mono === 'string' ? (Namon.cast(config.mono) ?? false) : config.mono; + return builder.build({ ...config, separator, mono } as NameOptions); } catch (error) { if (error instanceof NameError) throw error; diff --git a/src/error.spec.ts b/src/error.spec.ts index ef72376..204a7a2 100644 --- a/src/error.spec.ts +++ b/src/error.spec.ts @@ -111,6 +111,10 @@ describe('InputError', () => { test('is thrown if the wrong argument is provided for a last name', () => { expect(() => Validators.lastName.validate({} as LastName)); }); + + test('is thrown for unknown input types', () => { + expect(() => new Namefully(class {} as never)).toThrow(Errors.InputError); + }); }); describe('NotAllowedError', () => { diff --git a/src/fullname.spec.ts b/src/fullname.spec.ts index d0259dc..d47b802 100644 --- a/src/fullname.spec.ts +++ b/src/fullname.spec.ts @@ -1,8 +1,8 @@ import { FirstName, LastName, Name } from './name.js'; +import { NameError, ValidationError } from './error.js'; import { FullName } from './fullname.js'; import { Config } from './config.js'; import { Namon } from './types.js'; -import { NameError } from './error.js'; describe('FullName', () => { let prefix: Name; @@ -31,6 +31,22 @@ describe('FullName', () => { }); runExpectations(fullName); + + fullName = FullName.parse({ + prefix: 'Mr', + firstName: { value: 'John', more: ['David'] }, + middleName: 'Michael', + lastName: { father: 'Smith', mother: 'Jones' }, + suffix: 'Ph.D', + }); + + expect(fullName.prefix?.toString()).toBe('Mr'); + expect(fullName.firstName.toString()).toBe('John'); + expect(fullName.firstName.toString(true)).toBe('John David'); + expect(fullName.middleName.map((n) => n.toString()).join(' ')).toBe('Michael'); + expect(fullName.lastName.toString()).toBe('Smith'); + expect(fullName.lastName.toString('all')).toBe('Smith Jones'); + expect(fullName.suffix?.toString()).toBe('Ph.D'); }); test('builds a full name as it goes', () => { @@ -44,17 +60,19 @@ describe('FullName', () => { runExpectations(fullName); }); - test('builds a full name with no validation rules', () => { - fullName = new FullName(Config.merge({ name: 'withBypass', bypass: true })) - .setFirstName(new FirstName('2Pac')) - .setLastName(new LastName('Shakur')); - - expect(fullName.firstName).toBeInstanceOf(FirstName); - expect(fullName.lastName).toBeInstanceOf(LastName); - expect(fullName.firstName.toString()).toBe('2Pac'); - expect(fullName.lastName.toString()).toBe('Shakur'); - expect(fullName.config).toBeDefined(); - expect(fullName.config.name).toBe('withBypass'); + test('enforces validation rules if needed when building a full name', () => { + try { + fullName = new FullName(Config.merge({ name: 'noBypass', bypass: false })) + .setFirstName(new FirstName('2Pac')) // name with digits is invalid + .setLastName(new LastName('Shakur')); + + fail('Expected NameError to be thrown'); + } catch (error: unknown) { + expect(error).toBeInstanceOf(NameError); + expect((error as NameError).name).toContain('ValidationError'); + expect((error as NameError).source).toContain('2Pac'); + expect((error as ValidationError).nameType).toBe('firstName'); + } }); test('creates a full name as it goes from raw strings', () => { diff --git a/src/fullname.ts b/src/fullname.ts index 0290452..6f6d118 100644 --- a/src/fullname.ts +++ b/src/fullname.ts @@ -1,5 +1,6 @@ import { Config } from './config.js'; import { Validators } from './validator.js'; +import { ZERO_WIDTH_SPACE } from './constants.js'; import { Nullable, Namon, Title } from './types.js'; import { NameError, UnknownError } from './error.js'; import { FirstName, LastName, Name, JsonName } from './name.js'; @@ -66,6 +67,11 @@ export class FullName { return this.#suffix; } + /** Whether the full name is a single word name. */ + get isMono(): boolean { + return this instanceof Mononym; + } + /** * Parses a JSON name into a full name. * @param {JsonName} json parsable name element @@ -73,12 +79,13 @@ export class FullName { */ static parse(json: JsonName, config?: Config): FullName { try { + const { prefix, firstName: fn, middleName: mn, lastName: ln, suffix } = json; return new FullName(config) - .setPrefix(json.prefix) - .setFirstName(json.firstName) - .setMiddleName(json.middleName ?? []) - .setLastName(json.lastName) - .setSuffix(json.suffix); + .setPrefix(prefix) + .setFirstName(typeof fn === 'string' ? fn : new FirstName(fn.value, ...(fn.more ?? []))) + .setMiddleName(typeof mn === 'string' ? [mn] : (mn ?? [])) + .setLastName(typeof ln === 'string' ? ln : new LastName(ln.father, ln.mother)) + .setSuffix(suffix); } catch (error) { if (error instanceof NameError) throw error; @@ -94,7 +101,7 @@ export class FullName { if (!name) return this; if (!this.#config.bypass) Validators.prefix.validate(name); const prefix = name instanceof Name ? name.value : name; - this.#prefix = Name.prefix(this.#config.title === Title.US ? `${prefix}.` : prefix); + this.#prefix = Name.prefix(this.#config.title === Title.US && !prefix.endsWith('.') ? `${prefix}.` : prefix); return this; } @@ -133,6 +140,11 @@ export class FullName { return namon.equal(Namon.MIDDLE_NAME) ? this.#middleName.length > 0 : true; } + toString(): string { + if (this.isMono) return (this as unknown as Mononym).value; + return Array.from(this.toIterable(true)).join(' '); + } + /** Returns an `Iterable` of existing `Name`s. */ *toIterable(flat: boolean = false): Iterable { if (this.#prefix) yield this.#prefix; @@ -153,3 +165,61 @@ export class FullName { yield* this.toIterable(true); } } + +/** + * A single word name or mononym. + * + * This is a special case of `FullName` that is used to represent mononyms. This contradicts + * the original purpose of this library such as shaping and organizing name pieces accordingly. + * + * When enabled via `Config.mono`, this becomes the full name of a human. And as a single name, + * most of the `Namefully` methods become irrelevant. + */ +export class Mononym extends FullName { + readonly #namon!: string; + #type!: Namon; + + /** + * Constructs a mononym from a piece of string. + * @param {string | Name} name to be used to construct the mononym. + */ + constructor(name: string | Name, options?: Partial) { + super(options ?? { name: 'mononym', mono: true }); + this.#namon = name.toString(); + this.type = name instanceof Name ? name.type : Namon.FIRST_NAME; + } + + /** + * Re-assigns which name type is being used to represent the mononym. + * + * Ideally, this doesn't really matter as the mononym is always a single piece of name. + * When used as `string`, it must be a valid `Namon` type or else it will default to + * `Namon.FIRST_NAME`. + * @param {string | Namon} type of name to use. + */ + set type(type: string | Namon) { + this.#type = typeof type === 'string' ? (Namon.cast(type) ?? Namon.FIRST_NAME) : type; + this.#build(this.#namon); + } + + /** The type of name being used to represent the mononym. */ + get type(): Namon { + return this.#type; + } + + /** The piece of string treated as a name. */ + get value(): string { + return this.#namon; + } + + #build(name: string): void { + this.setFirstName(ZERO_WIDTH_SPACE).setLastName(ZERO_WIDTH_SPACE).setMiddleName([]).setPrefix(null).setSuffix(null); + + if (this.#type.equal(Namon.FIRST_NAME)) this.setFirstName(name); + else if (this.#type.equal(Namon.LAST_NAME)) this.setLastName(name); + else if (this.#type.equal(Namon.MIDDLE_NAME)) this.setMiddleName([name]); + else if (this.#type.equal(Namon.PREFIX)) this.setPrefix(name); + else if (this.#type.equal(Namon.SUFFIX)) this.setSuffix(name); + else throw new NameError(name, 'invalid mononym type'); + } +} diff --git a/src/name.ts b/src/name.ts index abc9a62..0d5c9d7 100644 --- a/src/name.ts +++ b/src/name.ts @@ -296,8 +296,8 @@ export function isNameArray(value?: unknown): value is Name[] { /** JSON signature for `FullName` data. */ export interface JsonName { prefix?: string; - firstName: string; - middleName?: string[]; - lastName: string; + firstName: string | { value: string; more?: string[] }; + middleName?: string | string[]; + lastName: string | { father: string; mother?: string }; suffix?: string; } diff --git a/src/namefully.spec.ts b/src/namefully.spec.ts index 74d9e65..ff637e3 100644 --- a/src/namefully.spec.ts +++ b/src/namefully.spec.ts @@ -1,8 +1,11 @@ import { Config } from './config.js'; import { NameError } from './error.js'; import { NameIndex } from './utils.js'; +import { deserialize } from './data.js'; import { Namefully } from './namefully.js'; +import namefully from './namefully.js'; import { NameBuilder } from './builder.js'; +import { ZERO_WIDTH_SPACE } from './constants.js'; import { FirstName, LastName, Name } from './name.js'; import { SimpleParser, findNameCase } from './fixtures/helpers.js'; import { Flat, NameOrder, NameType, Namon, Separator, Surname, Title } from './types.js'; @@ -16,7 +19,7 @@ describe('Namefully', () => { }); test('.parts returns the name components as a sequence', () => { - expect(Array.from(name.parts).length).toBe(5); + expect(name.size).toBe(5); for (const part of name.parts) expect(part).toBeInstanceOf(Name); }); @@ -57,8 +60,11 @@ describe('Namefully', () => { test('.deepEqual() checks whether two names are equal from a component perspective', () => { const name1 = new Namefully('John Ben Smith'); const name2 = new Namefully([new FirstName('John', 'Ben'), new LastName('Smith')]); + const name3 = new Namefully([Name.first('John'), Name.middle('Ben'), Name.last('Smith')]); expect(name1.equal(name2)).toBe(true); expect(name1.deepEqual(name2)).toBe(false); + expect(name1.equal(name3)).toBe(true); + expect(name1.deepEqual(name3)).toBe(true); }); test('.get() gets the raw form of a name', () => { @@ -75,6 +81,7 @@ describe('Namefully', () => { expect(name.get('firstName')).toBeInstanceOf(FirstName); expect(name.get('lastName')).toBeInstanceOf(LastName); expect(name.get('suffix')).toBeInstanceOf(Name); + expect(name.get('middle')).toBeUndefined(); }); test('.json() returns a json version of the full name', () => { @@ -113,6 +120,7 @@ describe('Namefully', () => { expect(name.format('f $l.')).toBe('John S.'); expect(name.format('f $m. l')).toBe('John B. Smith'); expect(name.format('$F.$M.$L')).toBe('J.B.S'); + expect(name.format('$f.$m.$l')).toBe('J.B.S'); expect(name.format('$p')).toBe(''); expect(new Namefully('John Smith').format('o')).toBe('SMITH, John'); @@ -277,11 +285,13 @@ describe('Namefully', () => { test('string', () => { expect(new Namefully('John Smith').toString()).toBe('John Smith'); expect(new Namefully('Jane D Smith').toString()).toBe('Jane D Smith'); + expect(new Namefully('Madonna', { mono: true }).toString()).toBe('Madonna'); }); test('string[]', () => { expect(new Namefully(['John', 'Smith']).toString()).toBe('John Smith'); expect(new Namefully(['Jane', 'D', 'Smith']).toString()).toBe('Jane D Smith'); + expect(new Namefully(['Madonna'], { mono: true }).toString()).toBe('Madonna'); }); test('json', () => { @@ -292,16 +302,41 @@ describe('Namefully', () => { const names = [new FirstName('John'), new LastName('Smith')]; expect(new Namefully(names).toString()).toBe('John Smith'); expect(new Namefully([Name.first('John'), Name.last('Smith'), Name.suffix('Ph.D')]).birth).toBe('John Smith'); + expect(new Namefully([Name.last('Madonna')], { mono: true }).toString()).toBe('Madonna'); }); test('NameBuilder', () => { const builder = NameBuilder.create(); builder.add(Name.first('John'), Name.last('Smith'), Name.suffix('Ph.D')); expect(builder.build().birth).toBe('John Smith'); + + builder.clear(); + builder.add(Name.middle('Madonna')); + expect(builder.size).toBe(1); + expect(builder.build({ mono: Namon.MIDDLE_NAME }).toString()).toBe('Madonna'); }); test('Parser (Custom Parser)', () => { - expect(new Namefully(new SimpleParser('John#Smith')).toString()).toBe('John Smith'); + const parser = new SimpleParser('John#Smith'); + expect(new Namefully(parser).toString()).toBe('John Smith'); + }); + + test('deserialize()', () => { + const name = deserialize({ + names: { firstName: 'John', lastName: 'Smith' }, + config: { + name: 'deserialize', + orderedBy: 'firstName', + separator: ' ', + title: 'US', + ending: false, + bypass: true, + surname: 'father', + mono: false, + }, + }); + expect(name).toBeInstanceOf(Namefully); + expect(name.full).toBe('John Smith'); }); test('tryParse()', () => { @@ -364,6 +399,13 @@ describe('Namefully', () => { await expect(Namefully.parse('John')).rejects.toThrow(NameError); }); + + test('default namefully function import', () => { + expect(namefully('John Smith').full).toBe('John Smith'); + expect(namefully(['John', 'Smith']).full).toBe('John Smith'); + expect(namefully([Name.first('John'), Name.last('Smith')]).full).toBe('John Smith'); + expect(namefully(new SimpleParser('John#Smith')).full).toBe('John Smith'); + }); }); describe('can be built with a name', () => { @@ -420,9 +462,23 @@ describe('Namefully', () => { expect(() => new Namefully('Mr John Joe Sm1th', Config.create('noBypass'))).toThrow(NameError); expect(() => new Namefully('Mr John Joe Smith Ph+', Config.create('noBypass'))).toThrow(NameError); }); + + test('of mononyms', () => { + const name = new Namefully('Madonna', { mono: true }); + expect(name.toString()).toBe('Madonna'); + expect(name.first).toBe('Madonna'); + expect(name.last).toBe(ZERO_WIDTH_SPACE); + expect(name.middle).toBeUndefined(); + expect(name.initials()).toStrictEqual(['M']); + expect(name.format('L, f m')).toBe(ZERO_WIDTH_SPACE + ', Madonna'); + expect(name.shorten()).toBe('Madonna'); + expect(name.zip()).toBe('M.'); + }); }); describe('Config', () => { + beforeEach(() => (Config as any).cache.clear()); + test('creates a default configuration', () => { expect(Config.create()).toEqual( expect.objectContaining({ @@ -433,6 +489,7 @@ describe('Namefully', () => { bypass: true, ending: false, surname: Surname.FATHER, + mono: false, }), ); }); @@ -445,6 +502,7 @@ describe('Namefully', () => { title: Title.US, surname: Surname.HYPHENATED, ending: true, + mono: true, }), ).toEqual( expect.objectContaining({ @@ -455,21 +513,24 @@ describe('Namefully', () => { bypass: true, ending: true, surname: Surname.HYPHENATED, + mono: true, }), ); - const { config } = new Namefully('f l', { + const config = Config.merge({ name: 'partial', orderedBy: 'lastName', title: 'US', surname: 'all', ending: true, - }); + mono: Namon.LAST_NAME, + } as Partial); expect(config.name).toBe('partial'); expect(config.orderedBy).toBe(NameOrder.LAST_NAME); expect(config.title).toBe(Title.US); expect(config.ending).toBe(true); expect(config.surname).toBe(Surname.ALL); + expect(config.mono).toBe(Namon.LAST_NAME); }); test('can create more than 1 configuration when necessary', () => { @@ -491,6 +552,7 @@ describe('Namefully', () => { bypass: true, ending: false, surname: Surname.FATHER, + mono: false, }), ); @@ -504,6 +566,7 @@ describe('Namefully', () => { bypass: false, ending: false, surname: Surname.MOTHER, + mono: false, }), ); }); @@ -528,6 +591,7 @@ describe('Namefully', () => { bypass: false, ending: false, surname: Surname.MOTHER, + mono: false, }), ); @@ -541,6 +605,7 @@ describe('Namefully', () => { bypass: false, ending: false, surname: Surname.MOTHER, + mono: false, }), ); @@ -554,6 +619,7 @@ describe('Namefully', () => { bypass: true, ending: false, surname: Surname.FATHER, + mono: false, }), ); @@ -568,6 +634,7 @@ describe('Namefully', () => { bypass: true, ending: false, surname: Surname.FATHER, + mono: false, }), ); }); diff --git a/src/namefully.ts b/src/namefully.ts index eca7e2e..042a269 100644 --- a/src/namefully.ts +++ b/src/namefully.ts @@ -36,8 +36,8 @@ import { ArrayNameParser, ArrayStringParser, NamaParser, Parser, StringParser } * this: `John Smith`, where `John` is the first name piece and `Smith`, the last * name piece. * - * @see {@link https://www.fbiic.gov/public/2008/nov/Naming_practice_guide_UK_2006.pdf} - * for more info on name standards. + * @see {@link https://www.fbiic.gov/public/2008/nov/Naming_practice_guide_UK_2006.pdf} for + * more info on name standards. * * **IMPORTANT**: Keep in mind that the order of appearance (or name order) matters * and may be altered through configurable parameters, which will be seen later. @@ -108,8 +108,14 @@ export class Namefully { return this.#fullName.config; } + /** Whether the name is a single word name. */ + get isMono(): boolean { + return this.#fullName.isMono; + } + /** The number of characters of the `birthName`, including spaces. */ get length(): number { + if (this.isMono) return this.#fullName.toString().length; return this.birth.length; } @@ -265,6 +271,8 @@ export class Namefully { * ``` */ fullName(orderedBy?: NameOptions['orderedBy']): string { + if (this.isMono) return this.#fullName.toString(); + const sep: string = this.config.ending ? ',' : ''; const names: string[] = []; @@ -272,7 +280,12 @@ export class Namefully { if ((orderedBy ?? this.config.orderedBy) === NameOrder.FIRST_NAME) { names.push(this.first, ...this.middleName(), this.last + sep); } else { - names.push(this.last, this.first, this.middleName().join(' ') + sep); + names.push(this.last); + if (this.hasMiddle) { + names.push(this.first, this.middleName().join(' ') + sep); + } else { + names.push(this.first + sep); + } } if (this.suffix) names.push(this.suffix); @@ -286,6 +299,7 @@ export class Namefully { * preset configuration. */ birthName(orderedBy?: NameOptions['orderedBy']): string { + if (this.isMono) return this.#fullName.toString(); orderedBy ??= this.config.orderedBy; return orderedBy === NameOrder.FIRST_NAME ? [this.first, ...this.middleName(), this.last].join(' ') @@ -333,6 +347,8 @@ export class Namefully { only?: NameType | 'firstName' | 'lastName' | 'middleName' | 'birthName'; asJson?: boolean; }): string[] | Record { + if (this.isMono) return [this.#fullName.toString()[0]]; + const { orderedBy = this.config.orderedBy, only = NameType.BIRTH_NAME, asJson } = options ?? {}; const firstInits = this.#fullName.firstName.initials(); @@ -340,7 +356,6 @@ export class Namefully { const lastInits = this.#fullName.lastName.initials(); if (asJson) return { firstName: firstInits, middleName: midInits, lastName: lastInits }; - if (only !== NameType.BIRTH_NAME) { return only === NameType.FIRST_NAME ? firstInits : only === NameType.MIDDLE_NAME ? midInits : lastInits; } else if (orderedBy === NameOrder.FIRST_NAME) { @@ -368,6 +383,8 @@ export class Namefully { * the surname is set as `mother` is equivalent to making it: `FirstName MotherName`. */ shorten(orderedBy?: NameOptions['orderedBy']): string { + if (this.isMono) return this.#fullName.toString(); + orderedBy ??= this.config.orderedBy; const { firstName, lastName } = this.#fullName; return orderedBy === NameOrder.FIRST_NAME @@ -424,6 +441,7 @@ export class Namefully { } = options; if (this.length <= limit) return this.full; + if (this.isMono) return `${this.initials()}${withPeriod ? '.' : ''}`; const { firstName, lastName, middleName } = this.#fullName; const sep = withPeriod ? '.' : ''; @@ -645,6 +663,38 @@ export class Namefully { return toggleCase(this.birth); } + /** + * Serializes this Namefully instance to a JSON object. + * + * This includes both the name data (with full hierarchy for FirstName and LastName) + * and the configuration, allowing for complete reconstruction of the Namefully instance. + * + * @returns a JSON-serializable object containing name data and config. + */ + serialize(): SerializedName { + const { config, firstName: fn, lastName: ln } = this.#fullName; + + return { + names: { + prefix: this.prefix, + firstName: fn.hasMore ? { value: fn.value, more: fn.more } : fn.value, + middleName: this.hasMiddle ? this.middleName() : undefined, + lastName: ln.hasMother ? { father: ln.father, mother: ln.mother } : ln.value, + suffix: this.suffix, + }, + config: { + name: config.name, + orderedBy: config.orderedBy, + separator: config.separator.token, + title: config.title, + ending: config.ending, + bypass: config.bypass, + surname: config.surname, + mono: config.mono instanceof Namon ? config.mono.key : config.mono, + }, + }; + } + #toParser(raw: string | string[] | Name[] | JsonName | Parser): Parser { if (raw instanceof Parser) return raw; if (typeof raw === 'string') return new StringParser(raw); @@ -712,37 +762,6 @@ export class Namefully { return ALLOWED_FORMAT_TOKENS.includes(char) ? char : undefined; } } - - /** - * Serializes this Namefully instance to a JSON object. - * - * This includes both the name data (with full hierarchy for FirstName and LastName) - * and the configuration, allowing for complete reconstruction of the Namefully instance. - * - * @returns a JSON-serializable object containing name data and config. - */ - serialize(): SerializedName { - const { config, firstName: fn, lastName: ln } = this.#fullName; - - return { - names: { - prefix: this.prefix, - firstName: fn.hasMore ? { value: fn.value, more: fn.more } : fn.value, - middleName: this.hasMiddle ? this.middleName() : undefined, - lastName: ln.hasMother ? { father: ln.father, mother: ln.mother } : ln.value, - suffix: this.suffix, - }, - config: { - name: config.name, - orderedBy: config.orderedBy, - separator: config.separator.token, - title: config.title, - ending: config.ending, - bypass: config.bypass, - surname: config.surname, - }, - }; - } } /** @@ -763,4 +782,5 @@ export type NameOptions = Partial<{ ending: boolean; bypass: boolean; surname: Surname | 'father' | 'mother' | 'hyphenated' | 'all'; + mono: boolean | Namon; }>; diff --git a/src/parser.ts b/src/parser.ts index 382e445..aee346a 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,10 +1,10 @@ import { Config } from './config.js'; import { NameIndex } from './utils.js'; import { InputError } from './error.js'; -import { FullName } from './fullname.js'; +import { FullName, Mononym } from './fullname.js'; import { Namon, Nullable, Separator } from './types.js'; import { FirstName, LastName, Name, JsonName } from './name.js'; -import { ArrayStringValidator, ArrayNameValidator, NamaValidator } from './validator.js'; +import { ArrayStringValidator, ArrayNameValidator, Validators } from './validator.js'; /** * A parser signature that helps to organize the names accordingly. @@ -16,6 +16,12 @@ export abstract class Parser { */ constructor(public raw: T) {} + /** + * Parses raw data into a `FullName` while applying some options. + * @param options for additional configuration to apply. + */ + abstract parse(options?: Partial): FullName; + /** * Builds a dynamic `Parser` on the fly and throws a `NameError` when unable * to do so. The built parser only knows how to operate birth names. @@ -32,10 +38,7 @@ export abstract class Parser { } if (length < 2) { - throw new InputError({ - source: text, - message: 'cannot build from invalid input', - }); + throw new InputError({ source: text, message: 'expecting at least 2 name parts' }); } else if (length === 2 || length === 3) { return new StringParser(text); } else { @@ -53,26 +56,23 @@ export abstract class Parser { return Promise.reject(error); } } - - /** - * Parses the raw data into a `FullName` while considering some options. - * @param options for additional configuration to apply. - */ - abstract parse(options?: Partial): FullName; } export class StringParser extends Parser { parse(options: Partial): FullName { const config = Config.merge(options); const names = this.raw.split(config.separator.token); - return new ArrayStringParser(names).parse(options); + return new ArrayStringParser(names).parse(config); } } export class ArrayStringParser extends Parser { parse(options: Partial): FullName { const config = Config.merge(options); - const fullName = new FullName(config); + + if (this.raw.length === 1 && config.mono) { + return new MonoParser(this.raw[0]).parse(config); + } const raw = this.raw.map((n) => n.trim()); const index = NameIndex.when(config.orderedBy, raw.length); @@ -85,8 +85,9 @@ export class ArrayStringParser extends Parser { } const { firstName, lastName, middleName, prefix, suffix } = index; - fullName.setFirstName(new FirstName(raw[firstName])); - fullName.setLastName(new LastName(raw[lastName])); + const fullName = new FullName(config) + .setFirstName(new FirstName(raw[firstName])) + .setLastName(new LastName(raw[lastName])); if (raw.length >= 3) fullName.setMiddleName(raw[middleName].split(config.separator.token)); if (raw.length >= 4) fullName.setPrefix(Name.prefix(raw[prefix])); @@ -113,9 +114,9 @@ export class NamaParser extends Parser { ); if (config.bypass) { - NamaValidator.create().validateKeys(names); + Validators.nama.validateKeys(names); } else { - NamaValidator.create().validate(names); + Validators.nama.validate(names); } return FullName.parse(this.raw, config); @@ -125,10 +126,14 @@ export class NamaParser extends Parser { export class ArrayNameParser extends Parser { parse(options: Partial): FullName { const config = Config.merge(options); - const fullName = new FullName(config); - ArrayNameValidator.create().validate(this.raw); + if (this.raw.length === 1 && config.mono) { + return new MonoParser(this.raw[0]).parse(options); + } else { + ArrayNameValidator.create().validate(this.raw); + } + const fullName = new FullName(config); for (const name of this.raw) { if (name.isPrefix) { fullName.setPrefix(name); @@ -146,3 +151,15 @@ export class ArrayNameParser extends Parser { return fullName; } } + +export class MonoParser extends Parser { + parse(options: Partial): Mononym { + const config = Config.merge(options); + + if (config.bypass) Validators.namon.validate(this.raw); + + const type = config.mono instanceof Namon ? config.mono : Namon.FIRST_NAME; + const name = this.raw instanceof Name ? this.raw : new Name(this.raw.trim(), type); + return new Mononym(name, config); + } +}