Add initial project files
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
*.md
|
||||
node_modules
|
||||
dist
|
||||
public
|
||||
build
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"printWidth": 150,
|
||||
"tabWidth": 2,
|
||||
"endOfLine": "lf",
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"semi": true,
|
||||
"bracketSameLine": false,
|
||||
"jsxSingleQuote": false,
|
||||
"arrowParens": "always"
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
# [←](../../../docs/README.md) @crux-kit/intl-name
|
||||
|
||||
A lightweight TypeScript service for formatting personal names into display-ready strings with locale support.
|
||||
|
||||
The service accepts multiple input formats (objects or strings), normalizes them into a canonical structure, and formats the output according to language-specific patterns.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { intlNameService, INTL_NAME_TOKENS } from 'intl-name';
|
||||
|
||||
const IntlName = intlNameService(
|
||||
{
|
||||
language: 'uk',
|
||||
fallbackLanguage: 'uk',
|
||||
localeMap: {
|
||||
uk: {
|
||||
formats: {
|
||||
SURNAME_NAME: `${INTL_NAME_TOKENS.LAST.FULL} ${INTL_NAME_TOKENS.FIRST.FULL}`,
|
||||
INITIALS_LF: `${INTL_NAME_TOKENS.LAST.INITIAL}${INTL_NAME_TOKENS.FIRST.INITIAL}`,
|
||||
},
|
||||
},
|
||||
en: {
|
||||
formats: {
|
||||
SURNAME_NAME: `${INTL_NAME_TOKENS.LAST.FULL} ${INTL_NAME_TOKENS.FIRST.FULL}`,
|
||||
INITIALS_LF: `${INTL_NAME_TOKENS.LAST.INITIAL}${INTL_NAME_TOKENS.FIRST.INITIAL}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
console,
|
||||
);
|
||||
|
||||
IntlName.formatToDisplay({ firstName: 'John', lastName: 'Smith' }, 'SURNAME_NAME');
|
||||
// → "Smith John"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Supported Input Formats
|
||||
|
||||
The service accepts names in three formats:
|
||||
|
||||
### camelCase object
|
||||
|
||||
```ts
|
||||
{ firstName, lastName, patronymic?, middleName?, prefix?, suffix? }
|
||||
```
|
||||
|
||||
### snake_case object
|
||||
|
||||
```ts
|
||||
{ first_name, last_name, patronymic?, middle_name?, prefix?, suffix? }
|
||||
```
|
||||
|
||||
### string
|
||||
|
||||
```ts
|
||||
'First Last Patronymic';
|
||||
```
|
||||
|
||||
All input formats are automatically normalized into a canonical internal representation.
|
||||
|
||||
---
|
||||
|
||||
## Formatting Patterns & Tokens
|
||||
|
||||
Name formatting is driven by **patterns** defined in the `localeMap`.
|
||||
|
||||
Patterns are plain strings composed of **tokens** that represent different parts of a person’s name.
|
||||
At runtime, these tokens are replaced with actual values from the input.
|
||||
|
||||
If a token has no value, it is replaced with an empty string.
|
||||
|
||||
---
|
||||
|
||||
## Conditional Blocks `{ ... }`
|
||||
|
||||
Conditional blocks are **optional helpers** mainly intended to control
|
||||
**punctuation, separators, and surrounding spaces**.
|
||||
|
||||
> Tokens work without conditional blocks.
|
||||
> Missing values are simply replaced with an empty string.
|
||||
|
||||
### Why conditional blocks matter
|
||||
|
||||
Without conditional blocks, punctuation remains even when a value is missing:
|
||||
|
||||
```ts
|
||||
// Pattern without a conditional block
|
||||
`${INTL_NAME_TOKENS.LAST.FULL}, ${INTL_NAME_TOKENS.FIRST.FULL}`;
|
||||
|
||||
// Input: { lastName: "Smith" }
|
||||
// Output: "Smith, "
|
||||
```
|
||||
|
||||
Using a conditional block removes the separator entirely:
|
||||
|
||||
```ts
|
||||
`${INTL_NAME_TOKENS.LAST.FULL}{, ${INTL_NAME_TOKENS.FIRST.FULL}}`;
|
||||
|
||||
// Input: { lastName: "Smith" }
|
||||
// Output: "Smith"
|
||||
```
|
||||
|
||||
The whole block (including commas, dots, or spaces) is rendered
|
||||
**only if at least one token inside has a value**.
|
||||
|
||||
### Typical use cases
|
||||
|
||||
- Commas and dots (`", "`, `"."`)
|
||||
- Extra spaces around optional parts
|
||||
- Optional patronymic or middle name
|
||||
|
||||
Example:
|
||||
|
||||
```ts
|
||||
`${INTL_NAME_TOKENS.LAST.FULL} ${INTL_NAME_TOKENS.FIRST.FULL}{ ${INTL_NAME_TOKENS.PATRONYMIC.FULL}}`;
|
||||
```
|
||||
|
||||
- With patronymic → `"Smith John Ivanovich"`
|
||||
- Without patronymic → `"Smith John"`
|
||||
|
||||
Conditional blocks help keep formatting clean without manual string checks.
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### `intlNameService(config, logger?)`
|
||||
|
||||
Creates a service instance.
|
||||
|
||||
Returns:
|
||||
|
||||
- `formatToDisplay(input, formatKey)` — formats the name for display
|
||||
- `toObject(input)` — converts input into a canonical object
|
||||
- `setLanguage(lang)` — changes the active language
|
||||
- `setSupportedLanguages(map)` — replaces the supported locale map
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@crux-kit/intl-name",
|
||||
"version": "1.3.1",
|
||||
"type": "module",
|
||||
"types": "src/index.ts",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@crux-kit/types": "*"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { NameFormatInputType, NameFormatInputTypeCamel, NameFormatInputTypeSnake, NameFormatInputTypeString } from './name-input-types';
|
||||
|
||||
export const isSnakeCaseInputType = (input: NameFormatInputType): input is NameFormatInputTypeSnake => {
|
||||
return typeof input === 'object' && input !== null && 'first_name' in input;
|
||||
};
|
||||
|
||||
export const isCamelCaseInputType = (input: NameFormatInputType): input is NameFormatInputTypeCamel => {
|
||||
return typeof input === 'object' && input !== null && 'firstName' in input;
|
||||
};
|
||||
|
||||
export const isStringInputType = (input: NameFormatInputType): input is NameFormatInputTypeString => {
|
||||
return typeof input === 'string';
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { isCamelCaseInputType, isSnakeCaseInputType, isStringInputType } from './check-input-type';
|
||||
import type { CanonicalName, NameFormatInputType } from './name-input-types';
|
||||
|
||||
export function convertInputToCamel(input: NameFormatInputType): CanonicalName {
|
||||
if (isCamelCaseInputType(input)) {
|
||||
return {
|
||||
firstName: input.firstName ?? '',
|
||||
lastName: input.lastName ?? '',
|
||||
patronymic: input.patronymic,
|
||||
prefix: input.prefix,
|
||||
suffix: input.suffix,
|
||||
middleName: input.middleName,
|
||||
};
|
||||
}
|
||||
|
||||
if (isSnakeCaseInputType(input)) {
|
||||
return {
|
||||
firstName: input.first_name ?? '',
|
||||
lastName: input.last_name ?? '',
|
||||
patronymic: input.patronymic,
|
||||
middleName: input.middle_name,
|
||||
prefix: input.prefix,
|
||||
suffix: input.suffix,
|
||||
};
|
||||
}
|
||||
|
||||
if (isStringInputType(input)) {
|
||||
const parts = input.trim().split(/\s+/);
|
||||
return {
|
||||
firstName: parts[0] ?? '',
|
||||
lastName: parts[1] ?? '',
|
||||
patronymic: parts[2],
|
||||
middleName: undefined,
|
||||
prefix: undefined,
|
||||
suffix: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return { firstName: '', lastName: '' };
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { CanonicalName } from './name-input-types';
|
||||
import { INTL_NAME_TOKENS } from './tokens';
|
||||
|
||||
export const UNIVERSAL_NAME_PATTERN = 'UNIVERSAL' as const;
|
||||
|
||||
function initial(value?: string) {
|
||||
return (value?.[0] ?? '').toUpperCase();
|
||||
}
|
||||
function valueOrEmpty(v?: string) {
|
||||
return v ?? '';
|
||||
}
|
||||
|
||||
export function formatNameByPattern(pattern: string, ctx: CanonicalName): string {
|
||||
if (pattern === UNIVERSAL_NAME_PATTERN) {
|
||||
return [ctx.firstName, ctx.lastName, ctx.patronymic].filter(Boolean).join(' ').trim();
|
||||
}
|
||||
|
||||
const TOKEN_REGEX = /(Pr|Su|FF|LL|PP|MM|F|L|P|M)/g;
|
||||
|
||||
const resolveToken = (token: string): string => {
|
||||
switch (token) {
|
||||
case INTL_NAME_TOKENS.FIRST.FULL:
|
||||
return valueOrEmpty(ctx.firstName);
|
||||
case INTL_NAME_TOKENS.FIRST.INITIAL:
|
||||
return initial(ctx.firstName);
|
||||
|
||||
case INTL_NAME_TOKENS.LAST.FULL:
|
||||
return valueOrEmpty(ctx.lastName);
|
||||
case INTL_NAME_TOKENS.LAST.INITIAL:
|
||||
return initial(ctx.lastName);
|
||||
|
||||
case INTL_NAME_TOKENS.MIDDLE.FULL:
|
||||
return valueOrEmpty(ctx.middleName);
|
||||
case INTL_NAME_TOKENS.MIDDLE.INITIAL:
|
||||
return initial(ctx.middleName);
|
||||
|
||||
case INTL_NAME_TOKENS.PATRONYMIC.FULL:
|
||||
return valueOrEmpty(ctx.patronymic);
|
||||
case INTL_NAME_TOKENS.PATRONYMIC.INITIAL:
|
||||
return initial(ctx.patronymic);
|
||||
|
||||
case INTL_NAME_TOKENS.PREFIX:
|
||||
return valueOrEmpty(ctx.prefix);
|
||||
|
||||
case INTL_NAME_TOKENS.SUFFIX:
|
||||
return valueOrEmpty(ctx.suffix);
|
||||
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const renderTokensWithPresence = (str: string): { text: string; hasValue: boolean } => {
|
||||
let hasValue = false;
|
||||
const text = str.replace(TOKEN_REGEX, (token) => {
|
||||
const value = resolveToken(token);
|
||||
if (value !== '') hasValue = true;
|
||||
return value;
|
||||
});
|
||||
return { text, hasValue };
|
||||
};
|
||||
|
||||
let out = pattern;
|
||||
|
||||
const blockRe = /\{([^{}]*)\}/g;
|
||||
|
||||
while (true) {
|
||||
const before = out;
|
||||
out = out.replace(blockRe, (_, inner: string) => {
|
||||
const { text, hasValue } = renderTokensWithPresence(inner);
|
||||
return hasValue ? text : '';
|
||||
});
|
||||
if (out === before) break;
|
||||
}
|
||||
|
||||
out = renderTokensWithPresence(out).text;
|
||||
return out.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { intlNameService } from './main';
|
||||
export { INTL_NAME_TOKENS } from './tokens';
|
||||
@@ -0,0 +1,47 @@
|
||||
import { convertInputToCamel } from './convert-input';
|
||||
import { formatNameByPattern } from './formatter';
|
||||
import { NameFormatInputType } from './name-input-types';
|
||||
import { InitIntlNameServiceConfig, IntlNameInstanceInterface, IntlNameLocaleMap } from './types';
|
||||
|
||||
export function intlNameService<LM extends IntlNameLocaleMap, FallbackLang extends keyof LM>(
|
||||
config: InitIntlNameServiceConfig<LM, FallbackLang>,
|
||||
logger?: Console,
|
||||
): IntlNameInstanceInterface<LM, FallbackLang> {
|
||||
let language = config.language;
|
||||
let localeMap = config.localeMap;
|
||||
|
||||
const getPattern = (fmt: string): string => {
|
||||
const langPattern = localeMap[language]?.formats?.[fmt];
|
||||
if (langPattern) return langPattern;
|
||||
|
||||
const fallbackPattern = localeMap[config.fallbackLanguage]?.formats?.[fmt];
|
||||
if (fallbackPattern) return fallbackPattern;
|
||||
|
||||
return 'UNIVERSAL';
|
||||
};
|
||||
|
||||
return {
|
||||
setLanguage: (lang) => {
|
||||
if (!localeMap[lang]) {
|
||||
logger?.error('Language is not supported. Add it to the supported list.');
|
||||
return;
|
||||
}
|
||||
language = lang;
|
||||
logger?.log('Set global language: ', lang);
|
||||
},
|
||||
|
||||
setSupportedLanguages: (map) => {
|
||||
localeMap = map;
|
||||
logger?.log('Set supported languages: ', map);
|
||||
},
|
||||
|
||||
formatToDisplay: (input: NameFormatInputType, fmt) => {
|
||||
const name = convertInputToCamel(input);
|
||||
const pattern = getPattern(fmt);
|
||||
|
||||
return formatNameByPattern(pattern, name);
|
||||
},
|
||||
|
||||
toObject: (input: NameFormatInputType) => convertInputToCamel(input),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export type NameFormatInputTypeSnake = {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
patronymic?: string;
|
||||
middle_name?: string;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
};
|
||||
|
||||
export type NameFormatInputTypeCamel = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
patronymic?: string;
|
||||
middleName?: string;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
};
|
||||
|
||||
export type NameFormatInputTypeString = string;
|
||||
|
||||
export type NameFormatInputType = NameFormatInputTypeSnake | NameFormatInputTypeCamel | NameFormatInputTypeString;
|
||||
|
||||
export type CanonicalName = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
patronymic?: string;
|
||||
middleName?: string;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
export const INTL_NAME_TOKENS = {
|
||||
FIRST: {
|
||||
INITIAL: 'F',
|
||||
FULL: 'FF',
|
||||
},
|
||||
LAST: {
|
||||
INITIAL: 'L',
|
||||
FULL: 'LL',
|
||||
},
|
||||
PATRONYMIC: {
|
||||
INITIAL: 'P',
|
||||
FULL: 'PP',
|
||||
},
|
||||
MIDDLE: {
|
||||
INITIAL: 'M',
|
||||
FULL: 'MM',
|
||||
},
|
||||
PREFIX: 'Pr',
|
||||
SUFFIX: 'Su',
|
||||
} as const;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { CanonicalName, NameFormatInputType } from './name-input-types';
|
||||
|
||||
export type IntlNameLocaleMap = Record<
|
||||
string,
|
||||
{
|
||||
formats: Record<string, string>;
|
||||
}
|
||||
>;
|
||||
|
||||
type InferFormatKeys<LM extends IntlNameLocaleMap, FallbackLang extends keyof LM> = keyof LM[FallbackLang]['formats'] & string;
|
||||
|
||||
export type InitIntlNameServiceConfig<LM extends IntlNameLocaleMap, FallbackLang extends keyof LM> = {
|
||||
fallbackLanguage: FallbackLang;
|
||||
language: keyof LM & string;
|
||||
localeMap: LM;
|
||||
};
|
||||
|
||||
export type IntlNameInstanceInterface<LM extends IntlNameLocaleMap, FallbackLang extends keyof LM> = {
|
||||
setLanguage: (lang: keyof LM & string) => void;
|
||||
setSupportedLanguages: (map: LM) => void;
|
||||
formatToDisplay: (input: NameFormatInputType, fmt: InferFormatKeys<LM, FallbackLang>) => string;
|
||||
toObject: (input: NameFormatInputType) => CanonicalName;
|
||||
};
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2023"],
|
||||
"skipLibCheck": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"composite": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user