Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/validate-merge-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,4 @@ jobs:

- name: Run tests for @agape/string
working-directory: ../AgapeToolkit
run: npx nx test string
run: npx nx test string --no-watch --ci
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,23 @@ Remove all symbols and spaces, captialize words.
`pluralize(input: string)`

Adds an 's' to most words. Words that end in 'y' are changed to 'ies'.
Words that end in s have 'es' appended to the word.
Words that end in s have 'es' appended to the word. Handles special cases
like children and geese.

`quantify(value: number, unit: string, plural?: string)`

The value will be paired with the unit, either singular or plural form

`singularize(input: string)`

Converts a word to it's singular form if it is a plural. Removes the 's' from
most words. Replacies 'ies' with 'y'. Removes 'es' from the end of a word.
Handles special cases like 'child' and 'goose'.

`snakify(input: string)`

Converted to snake_case: lower case, word boundaries replaced with underscores.

`titalize(input: number)`

The first letter of each word is capitalized with the exception of
Expand All @@ -83,7 +94,7 @@ Maverik Minett maverik.minett@gmail.com

## Copyright

© 2020-2024 Maverik Minett
© 2020-2025 Maverik Minett

## License

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@agape/string",
"version": "2.1.0",
"version": "2.2.0",
"description": "String and token manipulation",
"main": "./cjs/index.js",
"module": "./es2020/index.js",
Expand Down
48 changes: 46 additions & 2 deletions src/lib/string.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { camelize, kebabify, verbalize, pascalize, pluralize, titalize, quanitfy } from './string'
import { camelize, kebabify, verbalize, pascalize, pluralize, snakify, titalize, quanitfy } from './string'

describe('camelize', () => {
it('should convert space-separated words to camelCase', () => {
Expand Down Expand Up @@ -430,4 +430,48 @@ describe('quantify', () => {
value = "432.00";
expect( quanitfy(value, label) ).toBe("432.00 cats")
})
})
})

describe('snakify', () => {
it('converts camelCase to kebab-case', () => {
expect(snakify('camelCaseExample')).toBe('camel_case_example');
});

it('handles PascalCase correctly', () => {
expect(snakify('PascalCase')).toBe('pascal_case');
});

it('separates acronym and word', () => {
expect(snakify('HTMLParser')).toBe('html_parser');
});

it('handles version tags correctly', () => {
expect(snakify('EmployeeV2')).toBe('employee_v2');
expect(snakify('ApiV3Response')).toBe('api_v3_response');
});

it('preserves number separation', () => {
expect(snakify('Version2Id')).toBe('version_2_id');
expect(snakify('HTML5Parser')).toBe('html_5_parser');
});

it('converts spaces to underscores', () => {
expect(snakify('this is spaced')).toBe('this_is_spaced');
});

it('converts dashes and symbols to underscores', () => {
expect(snakify('hello-world!again')).toBe('hello_world_again');
});

it('trims leading and trailing symbols', () => {
expect(snakify('__HelloWorld__')).toBe('hello_world');
});

it('handles a complex example with everything', () => {
expect(snakify(' V2HTML5ApiResponse_v3.1! ')).toBe('v2_html_5_api_response_v3_1');
});

it('returns empty string when input is empty', () => {
expect(snakify('')).toBe('');
});
});
66 changes: 52 additions & 14 deletions src/lib/string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ export function camelize(input: string): string {
.join('');
}


/**
* Converts a string to kebab-case.
* Replaces spaces, underscores, and camelCase transitions with dashes,
Expand Down Expand Up @@ -204,6 +203,24 @@ function preserveCasing(source: string, sourceLower: string, target: string): st
return result;
}

/**
* Format a number in units, pluralizing the units if there is more or less than
* one count.
* @param count Number of units
* @param unit Label for the value
* @param plural Plural label for the value, will pluralize the single unit if
* not provided
* @returns String in `x units` format
*/
export function quanitfy(count: number|string, unit: string, plural?: string) {
const value = typeof count == 'number' ? count : Number(count);

const label = value === 1 ? unit : plural === undefined ? pluralize(unit) : plural;

return `${count} ${label}`

}

/**
* Returns the singular form of a plural word.
* Handles common English pluralization patterns and known irregulars.
Expand Down Expand Up @@ -259,24 +276,45 @@ export function singularize(word: string): string {
return word;
}


/**
* Format a number in units, pluralizing the units if there is more or less than
* one count.
* @param count Number of units
* @param unit Label for the value
* @param plural Plural label for the value, will pluralize the single unit if
* not provided
* @returns String in `x units` format
* Converts a string to kebab-case.
* Replaces spaces, underscores, and camelCase transitions with dashes,
* removes special characters, and lowercases the result.
*
* @param input A string to be converted to kebab-case.
*/
export function quanitfy(count: number|string, unit: string, plural?: string) {
const value = typeof count == 'number' ? count : Number(count);

const label = value === 1 ? unit : plural === undefined ? pluralize(unit) : plural;

return `${count} ${label}`
export function snakify(input: string): string {
const versionMatches: string[] = [];

// Step 1: replace version tokens (v2) with unique placeholders
const preserved = input.replace(/(v\d+)(?=[A-Z]|\b)/gi, (match) => {
const id = versionMatches.length;
versionMatches.push(match);
return `_${PLACEHOLDER}_${id}`;
});

let kebabed = preserved
.replace(/([a-z0-9])([A-Z])/g, '$1_$2') // camelCase
.replace(/([A-Z])([A-Z][a-z])/g, '$1_$2') // acronymWord
.replace(/([a-zA-Z])([0-9])/g, '$1_$2') // letter-digit
.replace(/([0-9])([a-zA-Z])/g, '$1_$2') // digit-letter
.replace(/-/g, '_') // dash → underscore (1:1)
.replace(/\s+/g, '_') // spaces → dash
.replace(new RegExp(`[^a-zA-Z0-9-${PLACEHOLDER}]+`, 'g'), '_') // symbols → dash
.replace(/^_+|_+$/g, '') // trim underscores
.toLowerCase();

// Step 3: restore version placeholders (convert dots to dashes, lowercase)
versionMatches.forEach((version, i) => {
kebabed = kebabed.replace(`${PLACEHOLDER}_${i}`, version);
});

// Step 4: lowercase entire result
return kebabed.toLowerCase();
}


/**
* Converts a string to title case.
* Capitalizes the first letter of each word, except for small words (e.g., "of", "and")
Expand Down