diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4393bc3..fca1f1f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,4 +43,4 @@ jobs: NPM_TOKEN: ${{ secrets.NPM_API_TOKEN }} - name: Publish to JSR - run: npx jsr publish + run: npx jsr publish --allow-dirty diff --git a/changelog.md b/changelog.md index 4086776..90524b6 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,17 @@ 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.1.0] - 2025-12-30 + +**Added**: + +- Made the name set iterable (i.e., for-of statements) +- Added ability for deep equal between 2 namefully instances +- Made certain types flexible so callers aren’t boxed into enums +- Added support for JSON-serializable names +- Extended ALLOWED_FORMAT_TOKENS with more symbols +- Allowed dirty TS (e.g., example/*) when publish JSR + ## [2.0.2] - 2025-12-02 **Fixed**: diff --git a/example/advanced.ts b/example/advanced.ts index cf64c3f..ed0ba41 100644 --- a/example/advanced.ts +++ b/example/advanced.ts @@ -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: Title.US }); console.log(name.full); // Mr. Nikola Tesla } diff --git a/jsr.json b/jsr.json index 575b21e..41949a1 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@ralflorent/namefully", - "version": "2.0.2", + "version": "2.1.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 20f1c1f..9d2ad3d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "namefully", - "version": "2.0.2", + "version": "2.1.0", "description": "Handle personal names in a particular order, way, or shape.", "author": "Ralph Florent", "license": "MIT", diff --git a/src/builder.ts b/src/builder.ts index f1b87e4..66f5750 100644 --- a/src/builder.ts +++ b/src/builder.ts @@ -1,7 +1,7 @@ import { Name } from './name.js'; import { Config } from './config.js'; -import { Namefully } from './namefully.js'; import { ArrayNameValidator } from './validator.js'; +import { Namefully, NameOptions } from './namefully.js'; type VoidCallback = () => void; type Callback = (value: Type) => Return; @@ -141,13 +141,13 @@ export class NameBuilder extends Builder { * Regardless of how the names are added, both first and last names must exist * to complete a fine build. Otherwise, it throws a NameError. */ - build(config?: Partial): Namefully { + build(options?: NameOptions): Namefully { this.prebuild?.(); const names = [...this.queue]; ArrayNameValidator.create().validate(names); - this.instance = new Namefully(names, config); + this.instance = new Namefully(names, options); this.postbuild?.(this.instance); return this.instance; diff --git a/src/constants.ts b/src/constants.ts index 07d4003..fe7d988 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,27 +1,4 @@ -export const VERSION = '2.0.2'; +export const VERSION = '2.1.0'; export const MIN_NUMBER_OF_NAME_PARTS = 2; export const MAX_NUMBER_OF_NAME_PARTS = 5; -export const ALLOWED_FORMAT_TOKENS = [ - '.', - ',', - ' ', - '-', - '_', - 'b', - 'B', - 'f', - 'F', - 'l', - 'L', - 'm', - 'M', - 'n', - 'N', - 'o', - 'O', - 'p', - 'P', - 's', - 'S', - '$', -]; +export const ALLOWED_FORMAT_TOKENS = ` .,_-()[]<>'"bBfFlLmMnNoOpPsS$`; diff --git a/src/data.spec.ts b/src/data.spec.ts new file mode 100644 index 0000000..400bec6 --- /dev/null +++ b/src/data.spec.ts @@ -0,0 +1,258 @@ +import { Namefully } from './namefully.js'; +import { InputError, UnknownError } from './error.js'; +import { Name, FirstName, LastName } from './name.js'; +import { deserialize, SerializedName } from './data.js'; + +describe('JSON serialization', () => { + describe('serialize', () => { + test('should serialize a simple name', () => { + const name = new Namefully('John Smith'); + const serialized = name.serialize(); + + expect(serialized).toEqual({ + names: { firstName: 'John', lastName: 'Smith' }, + config: expect.objectContaining({ + name: expect.any(String), + orderedBy: expect.any(String), + separator: expect.any(String), + title: expect.any(String), + ending: expect.any(Boolean), + bypass: expect.any(Boolean), + surname: expect.any(String), + }), + }); + }); + + test('should serialize a name with prefix and suffix', () => { + const name = new Namefully([Name.prefix('Mr'), Name.first('John'), Name.last('Smith'), Name.suffix('Jr')]); + const serialized = name.serialize(); + + expect(serialized.names.prefix).toBe('Mr'); + expect(serialized.names.firstName).toBe('John'); + expect(serialized.names.lastName).toBe('Smith'); + expect(serialized.names.suffix).toBe('Jr'); + }); + + test('should serialize a name with middle names', () => { + const name = new Namefully([ + Name.first('John'), + Name.middle('Michael'), + Name.middle('David'), + Name.last('Smith'), + ]); + const serialized = name.serialize(); + + expect(serialized.names.firstName).toBe('John'); + expect(serialized.names.middleName).toEqual(['Michael', 'David']); + expect(serialized.names.lastName).toBe('Smith'); + }); + + test('should serialize a name with multiple first names', () => { + const name = new Namefully([new FirstName('John', 'Michael'), Name.last('Smith')]); + const serialized = name.serialize(); + + expect(serialized.names.firstName).toEqual({ value: 'John', more: ['Michael'] }); + expect(serialized.names.lastName).toBe('Smith'); + }); + + test('should serialize a name with hyphenated last name', () => { + const name = new Namefully([Name.first('John'), new LastName('Smith', 'Jones')]); + const serialized = name.serialize(); + + expect(serialized.names.firstName).toBe('John'); + expect(serialized.names.lastName).toEqual({ father: 'Smith', mother: 'Jones' }); + }); + + test('should serialize a complex name with all parts', () => { + const name = new Namefully([ + Name.prefix('Dr'), + new FirstName('John', 'Michael'), + Name.middle('David'), + new LastName('Smith', 'Jones'), + Name.suffix('PhD'), + ]); + const serialized = name.serialize(); + + expect(serialized.names.prefix).toBe('Dr'); + expect(serialized.names.firstName).toEqual({ value: 'John', more: ['Michael'] }); + expect(serialized.names.middleName).toEqual(['David']); + expect(serialized.names.lastName).toEqual({ father: 'Smith', mother: 'Jones' }); + expect(serialized.names.suffix).toBe('PhD'); + }); + }); + + 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', + }, + }; + + const name = deserialize(data); + expect(name.firstName()).toBe('John'); + expect(name.lastName()).toBe('Smith'); + expect(name.full).toBe('John Smith'); + }); + + test('should deserialize a name from JSON string', () => { + const jsonString = JSON.stringify({ + names: { + firstName: 'Jane', + lastName: 'Doe', + }, + config: { + name: 'fullName', + orderedBy: 'firstName', + separator: ' ', + title: 'US', + ending: false, + bypass: false, + surname: 'father', + }, + }); + + const name = deserialize(jsonString); + expect(name.firstName()).toBe('Jane'); + expect(name.lastName()).toBe('Doe'); + }); + + 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(serialized); + expect(name.prefix).toBe(original.prefix); + expect(name.suffix).toBe(original.suffix); + }); + + 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); + 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); + expect(name.firstName(false)).toBe('John'); + }); + + test('should deserialize a name with hyphenated last name', () => { + const data: SerializedName = { + names: { + firstName: 'John', + lastName: { + father: 'Smith', + 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'); + }); + + test('should throw NameError for invalid data', () => { + expect(() => deserialize(null as any)).toThrow(InputError); + expect(() => deserialize(undefined as any)).toThrow(InputError); + expect(() => deserialize(123 as any)).toThrow(InputError); + expect(() => deserialize('invalid json')).toThrow(UnknownError); + expect(() => deserialize('{ invalid }')).toThrow(UnknownError); + expect(() => deserialize({} as any)).toThrow(UnknownError); + }); + }); + + describe('round-trip', () => { + test('should serialize and deserialize a simple name', () => { + const original = new Namefully('John Smith', { name: 'round-trip' }); + const serialized = original.serialize(); + const deserialized = deserialize(serialized); + + expect(deserialized.config.name).toBe(original.config.name); + expect(deserialized.full).toBe(original.full); + expect(deserialized.firstName()).toBe(original.firstName()); + expect(deserialized.lastName()).toBe(original.lastName()); + }); + + test('should serialize and deserialize a complex name', () => { + const original = new Namefully([ + Name.prefix('Dr'), + new FirstName('John', 'Michael'), + Name.middle('David'), + new LastName('Smith', 'Jones'), + Name.suffix('PhD'), + ]); + const serialized = original.serialize(); + const deserialized = deserialize(serialized); + + expect(deserialized.prefix).toBe(original.prefix); + expect(deserialized.firstName()).toBe(original.firstName()); + expect(deserialized.middleName()).toEqual(original.middleName()); + expect(deserialized.lastName()).toBe(original.lastName()); + expect(deserialized.suffix).toBe(original.suffix); + }); + + test('should serialize and deserialize from JSON string', () => { + const original = new Namefully([Name.first('Jane'), Name.last('Doe')]); + const serialized = original.serialize(); + const jsonString = JSON.stringify(serialized); + const deserialized = deserialize(jsonString); + + expect(deserialized.full).toBe(original.full); + }); + }); +}); diff --git a/src/data.ts b/src/data.ts new file mode 100644 index 0000000..4b04f5b --- /dev/null +++ b/src/data.ts @@ -0,0 +1,71 @@ +import { NameBuilder } from './builder.js'; +import { Name, FirstName, LastName } 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; + }; + /** The configuration data. */ + config: { + name: string; + orderedBy: string; + separator: string; + title: string; + ending: boolean; + bypass: boolean; + surname: string; + }; +} + +/** + * Deserializes a JSON object into a Namefully instance. + * + * This is the inverse operation of `serialize()`, reconstructing a Namefully + * instance from a previously serialized JSON object, preserving the name hierarchy. + * + * @param {SerializedName | string} data the serialized Namefully data (from `serialize()` + * or compatible format). + * @returns a new Namefully instance. + * + * @throws {NameError} if the data cannot be parsed or is invalid. + */ +export function deserialize(data: SerializedName | string): Namefully { + try { + const parsed: SerializedName = typeof data === 'string' ? JSON.parse(data) : data; + if (!parsed || typeof parsed !== 'object') { + throw new InputError({ + source: String(data), + message: 'invalid serialized data; must be an object or a string', + }); + } + + const { names, config } = parsed; + const { firstName: fn, lastName: ln, middleName: mn, prefix: px, suffix: sx } = names; + const builder = NameBuilder.of(); + + if (px) builder.add(Name.prefix(px)); + if (sx) builder.add(Name.suffix(sx)); + if (mn) builder.add(...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); + } catch (error) { + if (error instanceof NameError) throw error; + + throw new UnknownError({ + source: String(data), + message: 'could not deserialize data', + origin: error instanceof Error ? error : new Error(String(error)), + }); + } +} diff --git a/src/error.spec.ts b/src/error.spec.ts index 3e57216..ef72376 100644 --- a/src/error.spec.ts +++ b/src/error.spec.ts @@ -116,7 +116,7 @@ describe('InputError', () => { describe('NotAllowedError', () => { test('is thrown if wrong key params are given when formatting', () => { const name = new Namefully('Jane Doe'); - for (const k of ['[', '{', '^', '!', '@', '#', 'a', 'c', 'd']) { + for (const k of ['{', '^', '!', '@', '#', 'a', 'c', 'd']) { expect(() => name.format(k)).toThrow(Errors.NotAllowedError); } }); diff --git a/src/fixtures/helpers.ts b/src/fixtures/helpers.ts index 1232c80..b2cedc7 100644 --- a/src/fixtures/helpers.ts +++ b/src/fixtures/helpers.ts @@ -1,8 +1,8 @@ import { Config } from '../config.js'; +import { Parser } from '../parser.js'; import { FullName } from '../fullname.js'; +import { Namefully, NameOptions } from '../namefully.js'; import { FirstName, LastName, Name, JsonName } from '../name.js'; -import { Namefully } from '../namefully.js'; -import { Parser } from '../parser.js'; import { NameOrder, Separator, Surname, Title } from '../types.js'; export class SimpleParser extends Parser { @@ -19,7 +19,7 @@ export function findNameCase(name: string): Namefully { interface NameCase { name: string | string[] | Name[] | JsonName; - options: Partial; + options: NameOptions; } const NAME_CASES: { [key: string]: NameCase } = { diff --git a/src/fullname.spec.ts b/src/fullname.spec.ts index 7a83dc4..d0259dc 100644 --- a/src/fullname.spec.ts +++ b/src/fullname.spec.ts @@ -77,6 +77,27 @@ describe('FullName', () => { expect(fullName.has(Namon.LAST_NAME)).toBe(true); expect(fullName.has(Namon.SUFFIX)).toBe(false); }); + + test('.toIterable() returns sequence of name parts', () => { + fullName = new FullName() + .setPrefix(prefix) + .setFirstName(firstName) + .setMiddleName(middleName) + .setLastName(lastName) + .setSuffix(suffix); + + const parts = fullName[Symbol.iterator](); + expect(Name.prefix('Mr').equal(parts.next().value)).toBe(true); + expect(Name.first('John').equal(parts.next().value)).toBe(true); + expect(Name.middle('Ben').equal(parts.next().value)).toBe(true); + expect(Name.middle('Carl').equal(parts.next().value)).toBe(true); + expect(Name.last('Smith').equal(parts.next().value)).toBe(true); + expect(Name.suffix('Ph.D').equal(parts.next().value)).toBe(true); + + const over = parts.next(); + expect(over.done).toBe(true); + expect(over.value).toBe(undefined); + }); }); function runExpectations(fullName: FullName) { diff --git a/src/fullname.ts b/src/fullname.ts index f0c8f4a..0290452 100644 --- a/src/fullname.ts +++ b/src/fullname.ts @@ -125,9 +125,31 @@ export class FullName { } /** Returns true if a namon has been set. */ - has(namon: Namon): boolean { + has(key: Namon | string): boolean { + const namon = typeof key === 'string' ? Namon.cast(key) : key; + if (!namon) return false; if (namon.equal(Namon.PREFIX)) return !!this.#prefix; if (namon.equal(Namon.SUFFIX)) return !!this.#suffix; return namon.equal(Namon.MIDDLE_NAME) ? this.#middleName.length > 0 : true; } + + /** Returns an `Iterable` of existing `Name`s. */ + *toIterable(flat: boolean = false): Iterable { + if (this.#prefix) yield this.#prefix; + if (flat) { + yield* this.#firstName.asNames; + yield* this.#middleName; + yield* this.#lastName.asNames; + } else { + yield this.#firstName; + yield* this.#middleName; + yield this.#lastName; + } + if (this.#suffix) yield this.#suffix; + } + + /** Returns the default iterator for this name set (enabling for-of statements). */ + *[Symbol.iterator](): Iterator { + yield* this.toIterable(true); + } } diff --git a/src/index.ts b/src/index.ts index 1db0627..7278713 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import namefully from './namefully.js'; export * from './builder.js'; export * from './config.js'; export { VERSION as version } from './constants.js'; +export * from './data.js'; export * from './error.js'; export * from './fullname.js'; export * from './name.js'; diff --git a/src/name.ts b/src/name.ts index aaabbf4..abc9a62 100644 --- a/src/name.ts +++ b/src/name.ts @@ -205,7 +205,7 @@ export class LastName extends Name { constructor( father: string, mother?: string, - readonly format = Surname.FATHER, + readonly format: Surname | 'father' | 'mother' | 'hyphenated' | 'all' = Surname.FATHER, ) { super(father, Namon.LAST_NAME); this.validate(mother); @@ -238,7 +238,7 @@ export class LastName extends Name { return names; } - toString(format?: Surname): string { + toString(format?: Surname | 'father' | 'mother' | 'hyphenated' | 'all'): string { format = format ?? this.format; switch (format) { case Surname.FATHER: @@ -247,12 +247,12 @@ export class LastName extends Name { return this.mother ?? ''; case Surname.HYPHENATED: return this.hasMother ? `${this.value}-${this.#mother}` : this.value; - case Surname.ALL: + default: return this.hasMother ? `${this.value} ${this.#mother}` : this.value; } } - initials(format?: Surname): string[] { + initials(format?: Surname | 'father' | 'mother' | 'hyphenated' | 'all'): string[] { const inits: string[] = []; switch (format ?? this.format) { case Surname.HYPHENATED: diff --git a/src/namefully.spec.ts b/src/namefully.spec.ts index e81c723..74d9e65 100644 --- a/src/namefully.spec.ts +++ b/src/namefully.spec.ts @@ -1,11 +1,11 @@ import { Config } from './config.js'; import { NameError } from './error.js'; -import { FirstName, LastName, Name } from './name.js'; +import { NameIndex } from './utils.js'; import { Namefully } from './namefully.js'; import { NameBuilder } from './builder.js'; -import { Flat, NameOrder, NameType, Namon, Separator, Surname, Title } from './types.js'; +import { FirstName, LastName, Name } from './name.js'; import { SimpleParser, findNameCase } from './fixtures/helpers.js'; -import { NameIndex } from './utils.js'; +import { Flat, NameOrder, NameType, Namon, Separator, Surname, Title } from './types.js'; describe('Namefully', () => { describe('(default settings)', () => { @@ -15,23 +15,53 @@ describe('Namefully', () => { name = new Namefully('Mr John Ben Smith Ph.D', Config.create('generic')); }); + test('.parts returns the name components as a sequence', () => { + expect(Array.from(name.parts).length).toBe(5); + for (const part of name.parts) expect(part).toBeInstanceOf(Name); + }); + + test('.[Symbol.iterator]() returns a sequence of name parts', () => { + const parts = name[Symbol.iterator](); + expect(Name.prefix('Mr').equal(parts.next().value)).toBe(true); + expect(Name.first('John').equal(parts.next().value)).toBe(true); + expect(Name.middle('Ben').equal(parts.next().value)).toBe(true); + expect(Name.last('Smith').equal(parts.next().value)).toBe(true); + expect(Name.suffix('Ph.D').equal(parts.next().value)).toBe(true); + + const over = parts.next(); + expect(over.done).toBe(true); + expect(over.value).toBe(undefined); + }); + test('.has() determines if the full name has a specific namon', () => { expect(name.has(Namon.PREFIX)).toBe(true); expect(name.has(Namon.SUFFIX)).toBe(true); expect(name.has(Namon.MIDDLE_NAME)).toBe(true); expect(name.hasMiddle).toBe(true); + + expect(name.has('firstName')).toBe(true); + expect(name.has('lastName')).toBe(true); + expect(name.has('middle')).toBe(false); // unknown namon key. }); test('.toString() returns a String version of the full name', () => { expect(name.toString()).toBe('Mr John Ben Smith Ph.D'); }); - test('.equal() checks whether two names are equal', () => { - expect(name.equal(new Namefully('Mr John Ben Smith Ph.D'))).toBe(true); - expect(name.equal(new Namefully('Mr John Ben Smith'))).toBe(false); + test('.equal() checks whether two names are equal from a raw-string perspective', () => { + const names = [Name.prefix('Mr'), new FirstName('John', 'Ben'), new LastName('Smith'), Name.suffix('Ph.D')]; + expect(name.equal(new Namefully(names))).toBe(true); + expect(name.equal(new Namefully(names.slice(1)))).toBe(false); + }); + + 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')]); + expect(name1.equal(name2)).toBe(true); + expect(name1.deepEqual(name2)).toBe(false); }); - test('get(). gets the raw form of a name', () => { + test('.get() gets the raw form of a name', () => { expect(name.config).toBeDefined(); expect(name.get(Namon.PREFIX)).toBeInstanceOf(Name); expect(name.get(Namon.FIRST_NAME)).toBeInstanceOf(FirstName); @@ -40,6 +70,11 @@ describe('Namefully', () => { const middles = name.get(Namon.MIDDLE_NAME) as Name[]; middles.forEach((n) => expect(n).toBeInstanceOf(Name)); + + expect(name.get('prefix')).toBeInstanceOf(Name); + expect(name.get('firstName')).toBeInstanceOf(FirstName); + expect(name.get('lastName')).toBeInstanceOf(LastName); + expect(name.get('suffix')).toBeInstanceOf(Name); }); test('.json() returns a json version of the full name', () => { @@ -241,10 +276,12 @@ describe('Namefully', () => { describe('can be instantiated with', () => { test('string', () => { expect(new Namefully('John Smith').toString()).toBe('John Smith'); + expect(new Namefully('Jane D Smith').toString()).toBe('Jane D Smith'); }); test('string[]', () => { expect(new Namefully(['John', 'Smith']).toString()).toBe('John Smith'); + expect(new Namefully(['Jane', 'D', 'Smith']).toString()).toBe('Jane D Smith'); }); test('json', () => { @@ -264,9 +301,7 @@ describe('Namefully', () => { }); test('Parser (Custom Parser)', () => { - expect(new Namefully(new SimpleParser('John#Smith'), Config.create('simpleParser')).toString()).toBe( - 'John Smith', - ); + expect(new Namefully(new SimpleParser('John#Smith')).toString()).toBe('John Smith'); }); test('tryParse()', () => { @@ -422,6 +457,19 @@ describe('Namefully', () => { surname: Surname.HYPHENATED, }), ); + + const { config } = new Namefully('f l', { + name: 'partial', + orderedBy: 'lastName', + title: 'US', + surname: 'all', + ending: true, + }); + 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); }); test('can create more than 1 configuration when necessary', () => { diff --git a/src/namefully.ts b/src/namefully.ts index c443876..eca7e2e 100644 --- a/src/namefully.ts +++ b/src/namefully.ts @@ -1,10 +1,11 @@ import { Config } from './config.js'; import { FullName } from './fullname.js'; +import { SerializedName } from './data.js'; import { ALLOWED_FORMAT_TOKENS } from './constants.js'; -import { InputError, NotAllowedError } from './error.js'; import { Name, JsonName, isNameArray } from './name.js'; -import { Flat, NameOrder, NameType, Namon, Nullable, Surname } from './types.js'; +import { InputError, NotAllowedError } from './error.js'; import { capitalize, decapitalize, isStringArray, NameIndex, toggleCase } from './utils.js'; +import { Flat, NameOrder, NameType, Namon, Nullable, Surname, Separator, Title } from './types.js'; import { ArrayNameParser, ArrayStringParser, NamaParser, Parser, StringParser } from './parser.js'; /** @@ -65,8 +66,8 @@ export class Namefully { * name during its existence. All name parts must have at least one (1) character * to proceed. That is the only requirement/validation of namefully. */ - constructor(names: string | string[] | Name[] | JsonName | Parser, options?: Partial) { - this.#fullName = this.#toParser(names).parse(options); + constructor(names: string | string[] | Name[] | JsonName | Parser, options?: NameOptions) { + this.#fullName = this.#toParser(names).parse(options as Partial); } /** @@ -172,18 +173,49 @@ export class Namefully { return this.format('p l'); } - /** Returns the full name as set. */ + /** + * Returns an iterable of the name components in their natural form. + * + * Regardless of the order of appearance, this method will always return the + * existing `Name`s according to the name standards upon which this library + * is based. + * + * This is useful for iterating over the name parts in a consistent manner and + * this automatically enables operations such as mapping, filtering, etc. + */ + get parts(): Iterable { + return this.#fullName.toIterable(); + } + + /** The number of name components. */ + get size(): number { + return Array.from(this.parts).length; + } + + /** + * Makes the name set iterable (i.e., for-of statements). + * + * This is similar to `parts` with the exception that all name components are + * returned as `Name` classes (instead of their natural form - e.g., `FirstName`) + * to maintain certain homogeneity and consistency across each name piece. + */ + *[Symbol.iterator](): Iterator { + yield* this.#fullName.toIterable(true); + } + + /** Gets a string representation of the full name. */ toString(): string { return this.full; } /** Fetches the raw form of a name piece. */ - get(namon: Namon): Nullable { - if (namon.equal(Namon.PREFIX)) return this.#fullName.prefix; - if (namon.equal(Namon.FIRST_NAME)) return this.#fullName.firstName; - if (namon.equal(Namon.MIDDLE_NAME)) return this.#fullName.middleName; - if (namon.equal(Namon.LAST_NAME)) return this.#fullName.lastName; - if (namon.equal(Namon.SUFFIX)) return this.#fullName.suffix; + get(key: Namon | string): Nullable { + const namon = typeof key === 'string' ? Namon.cast(key) : key; + if (namon?.equal(Namon.PREFIX)) return this.#fullName.prefix; + if (namon?.equal(Namon.FIRST_NAME)) return this.#fullName.firstName; + if (namon?.equal(Namon.MIDDLE_NAME)) return this.#fullName.middleName; + if (namon?.equal(Namon.LAST_NAME)) return this.#fullName.lastName; + if (namon?.equal(Namon.SUFFIX)) return this.#fullName.suffix; return undefined; } @@ -192,6 +224,15 @@ export class Namefully { return this.toString() === other.toString(); } + /** Whether this name is equal to another one from a component perspective. */ + deepEqual(other: Namefully): boolean { + const others = Array.from(other.parts); + for (const part of this.parts) { + if (!others.some((name) => name.equal(part))) return false; + } + return true; + } + /** Gets a JSON representation of the full name. */ toJson(): JsonName { return { @@ -204,8 +245,8 @@ export class Namefully { } json = this.toJson; - /** Confirms that a name part has been set. */ - has(namon: Namon): boolean { + /** Confirms whether a name component exists. */ + has(namon: Namon | string): boolean { return this.#fullName.has(namon); } @@ -216,14 +257,14 @@ export class Namefully { * name, overriding the preset configuration. * * `Namefully.format()` may also be used to alter manually the order of appearance - * of full name. For example: + * of a full name. For example: * ```ts * const name = new Namefully('Jon Stark Snow'); * console.log(name.fullName(NameOrder.LAST_NAME)); // "Snow Jon Stark" * console.log(name.format('l f m')); // "Snow Jon Stark" * ``` */ - fullName(orderedBy?: NameOrder): string { + fullName(orderedBy?: NameOptions['orderedBy']): string { const sep: string = this.config.ending ? ',' : ''; const names: string[] = []; @@ -244,7 +285,7 @@ export class Namefully { * @param orderedBy forces to order by first or last name by overriding the * preset configuration. */ - birthName(orderedBy?: NameOrder): string { + birthName(orderedBy?: NameOptions['orderedBy']): string { orderedBy ??= this.config.orderedBy; return orderedBy === NameOrder.FIRST_NAME ? [this.first, ...this.middleName(), this.last].join(' ') @@ -270,7 +311,7 @@ export class Namefully { * @param {Surname} format overrides the how-to formatting of a surname output, * considering its sub-parts. */ - lastName(format?: Surname): string { + lastName(format?: NameOptions['surname']): string { return this.#fullName.lastName.toString(format); } @@ -288,8 +329,8 @@ export class Namefully { * - `John Ben Smith` => `['J', 'B', 'S']`. */ initials(options?: { - orderedBy?: NameOrder; - only?: NameType; + orderedBy?: NameOptions['orderedBy']; + only?: NameType | 'firstName' | 'lastName' | 'middleName' | 'birthName'; asJson?: boolean; }): string[] | Record { const { orderedBy = this.config.orderedBy, only = NameType.BIRTH_NAME, asJson } = options ?? {}; @@ -326,7 +367,7 @@ export class Namefully { * For a given `FirstName FatherName MotherName`, shortening this name when * the surname is set as `mother` is equivalent to making it: `FirstName MotherName`. */ - shorten(orderedBy?: NameOrder): string { + shorten(orderedBy?: NameOptions['orderedBy']): string { orderedBy ??= this.config.orderedBy; const { firstName, lastName } = this.#fullName; return orderedBy === NameOrder.FIRST_NAME @@ -366,11 +407,11 @@ export class Namefully { flatten( options: Partial<{ limit: number; - by: Flat; + by: Flat | 'firstName' | 'lastName' | 'middleName' | 'birthName' | 'firstMid' | 'midLast' | 'all' | '*'; withPeriod: boolean; recursive: boolean; withMore: boolean; - surname: Surname; + surname: NameOptions['surname']; }>, ): string { const { @@ -413,7 +454,7 @@ export class Namefully { case Flat.MID_LAST: name = hasMid ? [fn, m, l] : [fn, l]; break; - case Flat.ALL: + default: name = hasMid ? [f, m, l] : [f, l]; break; } @@ -434,7 +475,7 @@ export class Namefully { case Flat.MID_LAST: name = hasMid ? [l, fn, m] : [l, fn]; break; - case Flat.ALL: + default: name = hasMid ? [l, f, m] : [l, f]; break; } @@ -497,14 +538,6 @@ export class Namefully { * - 'P': capitalized prefix * - 's': suffix * - 'S': capitalized suffix - * - * punctuations - * ------------ - * - '.': period - * - ',': comma - * - ' ': space - * - '-': hyphen - * - '_': underscore * - '$': an escape character to select only the initial of the next char. * * Given the name `Joe Jim Smith`, use `format` with the `pattern` string. @@ -526,7 +559,7 @@ export class Namefully { let group = ''; const formatted: string[] = []; for (const char of pattern) { - if (ALLOWED_FORMAT_TOKENS.indexOf(char) === -1) { + if (!ALLOWED_FORMAT_TOKENS.includes(char)) { throw new NotAllowedError({ source: this.full, operation: 'format', @@ -624,12 +657,6 @@ export class Namefully { #map(char: string): Nullable { switch (char) { - case '.': - case ',': - case ' ': - case '-': - case '_': - return char; case 'b': return this.birth; case 'B': @@ -670,18 +697,52 @@ export class Namefully { case 'S': return this.suffix?.toUpperCase(); case '$f': - case '$F': return this.#fullName.firstName.value[0]; + case '$F': + return this.#fullName.firstName.initials(true).join(''); case '$l': - case '$L': return this.#fullName.lastName.value[0]; + case '$L': + return this.#fullName.lastName.initials().join(''); case '$m': - case '$M': return this.hasMiddle ? this.middle![0] : undefined; + case '$M': + return this.hasMiddle ? this.#fullName.middleName.map((n) => n.value[0]).join('') : undefined; default: - return undefined; + 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, + }, + }; + } } /** @@ -689,6 +750,17 @@ export class Namefully { * @param names element to parse. * @param options additional settings. */ -export default (names: string | string[] | Name[] | JsonName | Parser, options?: Partial) => { +export default (names: string | string[] | Name[] | JsonName | Parser, options?: NameOptions) => { return new Namefully(names, options); }; + +/** Optional namefully parameters (@see {@linkcode Config} for more details). */ +export type NameOptions = Partial<{ + name: string; + orderedBy: NameOrder | 'firstName' | 'lastName'; + separator: Separator; + title: Title | 'UK' | 'US'; + ending: boolean; + bypass: boolean; + surname: Surname | 'father' | 'mother' | 'hyphenated' | 'all'; +}>; diff --git a/src/parser.ts b/src/parser.ts index 6309f0b..382e445 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -139,8 +139,8 @@ export class ArrayNameParser extends Parser { } else if (name.isMiddleName) { fullName.middleName.push(name); } else if (name.isLastName) { - const lastName = new LastName(name.value, name instanceof LastName ? name.mother : undefined, config.surname); - fullName.setLastName(lastName); + const mother = name instanceof LastName ? name.mother : undefined; + fullName.setLastName(new LastName(name.value, mother, config.surname)); } } return fullName; diff --git a/src/types.ts b/src/types.ts index 88cc942..1eefe92 100644 --- a/src/types.ts +++ b/src/types.ts @@ -104,6 +104,14 @@ export class Namon { [Namon.SUFFIX.key, Namon.SUFFIX], ]); + private static readonly aliases: Record = { + [Namon.PREFIX.key]: ['prefix', 'px', 'p'], + [Namon.FIRST_NAME.key]: ['firstname', 'first', 'fn', 'f'], + [Namon.MIDDLE_NAME.key]: ['middlename', 'middle', 'mid', 'mn', 'm'], + [Namon.LAST_NAME.key]: ['lastname', 'last', 'ln', 'l'], + [Namon.SUFFIX.key]: ['suffix', 'sx', 's'], + }; + private constructor( readonly index: number, readonly key: string, @@ -116,7 +124,9 @@ export class Namon { /** Makes a string key a namon type. */ static cast(key: string): Nullable { - return Namon.has(key) ? Namon.all.get(key) : undefined; + const searchValue = String(key).toLowerCase(); + const namon = Object.entries(Namon.aliases).find(([, list]) => list.includes(searchValue))?.[0]; + return Namon.has(namon ?? '') ? Namon.all.get(key) : undefined; } /** String representation of this object. */ @@ -165,6 +175,16 @@ export class Separator { readonly token: string, ) {} + /** Makes a string key a separator type. */ + static cast(key: string): Nullable { + for (const [name, separator] of Separator.all) { + if (separator.token === key || name.toLowerCase() === key.toLowerCase()) { + return separator; + } + } + return undefined; + } + /** String representation of this object. */ toString(): string { return `Separator.${this.name}`;