diff --git a/.github/workflows/validate-merge-request.yml b/.github/workflows/validate-merge-request.yml index e4d867d..5fcf967 100644 --- a/.github/workflows/validate-merge-request.yml +++ b/.github/workflows/validate-merge-request.yml @@ -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 diff --git a/README.md b/README.md index d817cd2..918de7b 100644 --- a/README.md +++ b/README.md @@ -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 @@ -83,7 +94,7 @@ Maverik Minett maverik.minett@gmail.com ## Copyright -© 2020-2024 Maverik Minett +© 2020-2025 Maverik Minett ## License diff --git a/package.json b/package.json index 575ab77..6472609 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/string.spec.ts b/src/lib/string.spec.ts index ed4ac18..ce08a35 100644 --- a/src/lib/string.spec.ts +++ b/src/lib/string.spec.ts @@ -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', () => { @@ -430,4 +430,48 @@ describe('quantify', () => { value = "432.00"; expect( quanitfy(value, label) ).toBe("432.00 cats") }) -}) \ No newline at end of file +}) + +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(''); + }); +}); diff --git a/src/lib/string.ts b/src/lib/string.ts index 2efb27c..c903787 100644 --- a/src/lib/string.ts +++ b/src/lib/string.ts @@ -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, @@ -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. @@ -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")