From 1602c92f589e1600d3441ca9ebac41466b8158c5 Mon Sep 17 00:00:00 2001 From: Maverik Minett Date: Sun, 15 Jun 2025 18:48:49 -0400 Subject: [PATCH 01/16] remove es2015 support --- package.json | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index e48d2a7..7c0f831 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agape/string", - "version": "2.0.0", + "version": "2.0.1", "description": "String and token manipulation", "main": "./cjs/index.js", "module": "./es2020/index.js", @@ -10,15 +10,12 @@ ], "author": "Maverik Minett", "license": "MIT", - "es2020": "./esm2020/index.js", - "es2015": "./esm2015/index.js", + "es2020": "./es2020/index.js", "exports": { "./package.json": { "default": "./package.json" }, ".": { - "es2020": "./es2020/index.js", - "es2015": "./es2015/index.js", "node": "./cjs/index.js", "default": "./es2020/index.js", "require": "./cjs/index.js", From e2dcb5b76bbeeb26e485efe1b561e2eaace745ad Mon Sep 17 00:00:00 2001 From: Maverik Minett Date: Sun, 15 Jun 2025 18:49:16 -0400 Subject: [PATCH 02/16] add singularize function, enhance pluralize support --- src/lib/string.ts | 142 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 130 insertions(+), 12 deletions(-) diff --git a/src/lib/string.ts b/src/lib/string.ts index ebf0f0a..e5b8e2c 100644 --- a/src/lib/string.ts +++ b/src/lib/string.ts @@ -67,28 +67,146 @@ export function pascalize(string: string) { } /** - * Formats a string in it's labels form. Most strings a returned - * with an 's' appended to the end. For strings that end with 'y', - * the 'y' is replaced with 'ies'. Strings that end with 'us', the - * 'us' is replaced with 'i'. - * @param string String to be returned in labels form + * Returns a string formatted in its plural form. + * Handles common English pluralization rules, including: + * - y → ies + * - s/x/z/ch/sh → es + * - known irregulars (e.g., child → children) + * + * The output preserves the casing of the input, especially the prefix. + * + * @param word A singular word to be pluralized */ -export function pluralize(string: string) { - if ( string.endsWith('y') ) { - return string.replace(/y$/, 'ies' ) +export function pluralize(word: string): string { + const lower = word.toLowerCase(); + + const irregulars: Record = { + person: 'people', + man: 'men', + woman: 'women', + child: 'children', + tooth: 'teeth', + foot: 'feet', + mouse: 'mice', + goose: 'geese', + ox: 'oxen', + cactus: 'cacti', + nucleus: 'nuclei', + fungus: 'fungi', + syllabus: 'syllabi', + analysis: 'analyses', + diagnosis: 'diagnoses', + thesis: 'theses', + crisis: 'crises' + }; + + // Handle irregulars + if (irregulars[lower]) { + const plural = irregulars[lower]; + return preserveCasing(word, lower, plural); } - else if ( string.endsWith('s') ) { - return string + 'es' + + // y → ies + if (word.match(/[^aeiou]y$/i)) { + return word.slice(0, -1) + 'ies'; } - else { - return string + 's' + + // s, x, z, ch, sh → es + if (word.match(/(s|x|z|ch|sh)$/i)) { + return word + 'es'; + } + + // Default: just add 's' + return word + 's'; +} + +/** + * Copies the casing of `source` onto `target`, from the start. + * Only affects characters that exist in the same position in both strings. + * Appends remaining part of `target` (if longer) in lowercase. + * + * @param source Original input word (with desired casing) + * @param sourceLower Lowercase version of source + * @param target Plural version (assumed lowercase) + */ +function preserveCasing(source: string, sourceLower: string, target: string): string { + let result = ''; + + for (let i = 0; i < target.length; i++) { + if (i < source.length) { + // Match casing from original input + result += source[i] === sourceLower[i] + ? target[i] + : target[i].toUpperCase(); + } else { + // Suffix, use as-is (lowercase) + result += target[i]; + } } + + return result; } + // define a file-level copy of the pluralize function so that the quanitfy // function can have a `pluralize` attribute and still use `_pluralize` const _pluralize = pluralize; +/** + * Returns the singular form of a plural word. + * Handles common English pluralization patterns and known irregulars. + * Preserves casing of the original input. + * + * @param word Plural word to be converted to singular + */ +export function singularize(word: string): string { + const lower = word.toLowerCase(); + + const irregulars: Record = { + people: 'person', + men: 'man', + women: 'woman', + children: 'child', + teeth: 'tooth', + feet: 'foot', + mice: 'mouse', + geese: 'goose', + oxen: 'ox', + cacti: 'cactus', + nuclei: 'nucleus', + fungi: 'fungus', + syllabi: 'syllabus', + analyses: 'analysis', + diagnoses: 'diagnosis', + theses: 'thesis', + crises: 'crisis' + }; + + // Handle irregulars + if (irregulars[lower]) { + const singular = irregulars[lower]; + return preserveCasing(word, lower, singular); + } + + // ies → y + if (word.match(/ies$/i) && word.length > 3) { + return word.slice(0, -3) + 'y'; + } + + // es → base (for s, x, z, ch, sh) + if (word.match(/(ses|xes|zes|ches|shes)$/i)) { + return word.slice(0, -2); // remove 'es' + } + + // s → base + if (word.match(/s$/i) && !word.match(/ss$/i)) { + return word.slice(0, -1); + } + + // Default: assume word is already singular + return word; +} + /** * Format a number in units, pluralizing the units if there is more or less than * one count. From d42cb3b8b88fe2f64013bcd397a4e115e7a8a905 Mon Sep 17 00:00:00 2001 From: Maverik Minett Date: Sun, 15 Jun 2025 19:07:57 -0400 Subject: [PATCH 03/16] review and enhance implementations --- src/lib/string.ts | 153 +++++++++++++++++++++++----------------------- 1 file changed, 78 insertions(+), 75 deletions(-) diff --git a/src/lib/string.ts b/src/lib/string.ts index e5b8e2c..cd55743 100644 --- a/src/lib/string.ts +++ b/src/lib/string.ts @@ -1,69 +1,71 @@ /** - * Returns a string formatted in camel case. - * @param string A string to be returned as a class name - */ -export function camelize(string: string) { - return string - .replace(/[-_]/, ' ') - .replace(/(\w)([a-z]+)/g , (str, left, right) => { return left.toUpperCase() + right } ) - .replace(/[^A-Za-z0-9]/, '') - .replace(/^([A-Z])/, (str) => { return str.toLowerCase() } ) -} - -/** - * Returns a string formatted as a token. Removes all symbols and replaces - * spaces and punctuation with dashes. - * - * @param string A string to be returned as a token + * Converts a string to camelCase. + * Removes all non-alphanumeric separators and capitalizes each word except the first. + * + * @param input The input string to be camelized. */ -export function kebabify(string: string) { - return string - .replace(/[^A-Za-z0-9\s\-_]/g, '') - .replace(/(.)([A-Z][a-z]+)/g , (str, left, right) => { return left + '-' + right } ) - .replace(/([a-z0-9])([A-Z])/g, (str, left, right) => { return left + '-' + right } ) - .replace(/[_\s]+-?/, '-') - .toLowerCase() +export function camelize(input: string): string { + return input + .replace(/[^A-Za-z0-9]+/g, ' ') + .trim() + .split(' ') + .map((word, index) => { + if (index === 0) return word.toLowerCase(); + return word.charAt(0).toUpperCase() + word.slice(1); + }) + .join(''); } /** - * Returns a string formatted as spoken words. Adds spaces between words, - * replacing underscores and hyphens. - * @param string A string to be returned as spoken words + * 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 verbalize(string: string) { - return string - .replace(/(.)([A-Z][a-z]+)/g , (str, left, right) => { return left + ' ' + right } ) - .replace(/([a-z0-9])([A-Z])/g, (str, left, right) => { return left + ' ' + right } ) - .replace(/[-_]/, ' ') - .replace(/^([a-z])/, (str) => { return str.toUpperCase() } ) +export function kebabify(input: string): string { + return input + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') // camelCase → camel-Case + .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') // XMLHttp → XML-Http + .replace(/[_\s]+/g, '-') // underscores/spaces → dash + .replace(/[^a-zA-Z0-9\-]+/g, '') // remove symbols except dash + .replace(/--+/g, '-') // collapse multiple dashes + .replace(/^-+|-+$/g, '') // trim starting/trailing dash + .toLowerCase(); } /** - * Returns a string formatted as a label. Adds spaces between words, - * replacing underscores and hyphens, capitalize the first word. - * @param string A string to be returned as spoken words + * Returns a string formatted as spoken words. + * Adds spaces between words in camelCase, PascalCase, snake_case, or kebab-case identifiers. + * Capitalizes the first word only. + * + * @param input A string to be returned as spoken words. */ -export function labelize(string: string) { - return string - .replace(/(.)([A-Z][a-z]+)/g , (str, left, right) => { return left + ' ' + right } ) - .replace(/([a-z0-9])([A-Z])/g, (str, left, right) => { return left + ' ' + right } ) - .replace(/[-_]/, ' ') - .toLocaleLowerCase() - .replace(/^([a-z])/, (str) => { return str.toUpperCase() } ) +export function verbalize(input: string): string { + return input + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') // camelCase → camel Case + .replace(/([A-Z])([A-Z][a-z])/g, '$1 $2') // XMLHttp → XML Http + .replace(/[-_]+/g, ' ') // kebab-case and snake_case → space + .replace(/\s+/g, ' ') // collapse extra spaces + .trim() + .replace(/^([a-z])/, (match) => match.toUpperCase()); // Capitalize first letter } /** - * Returns a string formatted as a class name. Removes all spaces and symbols, - * captializes the first letter of each word. - * @param string A string to be returned in pascal case + * Converts a string to PascalCase. + * Removes non-alphanumeric characters, capitalizes each word, and strips leading numbers. + * + * @param input A string to be returned in PascalCase. */ -export function pascalize(string: string) { - return string - .replace(/[-_]/, ' ') - .replace(/(\w)([a-z]+)/g , (str, left, right) => { return left.toUpperCase() + right } ) - .replace(/[^A-Za-z0-9]/, '') - .replace(/^[0-9]+/, '') +export function pascalize(input: string): string { + return input + .replace(/[^A-Za-z0-9]+/g, ' ') // normalize separators + .replace(/^\d+\s*/, '') // remove leading digits + .trim() + .split(' ') + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(''); } /** @@ -212,39 +214,40 @@ export function singularize(word: string): string { * one count. * @param count Number of units * @param unit Label for the value - * @param plural Set to false to disable pluralization + * @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, pluralize = true) { +export function quanitfy(count: number|string, unit: string, plural?: string) { const value = typeof count == 'number' ? count : Number(count); - const label = pluralize === false || value === 1 ? unit : _pluralize(unit); + const label = value === 1 ? unit : plural === undefined ? pluralize(unit) : plural; return `${count} ${label}` } /** - * Formats a string in it's labels form. Most strings a returned - * with an 's' appended to the end. For strings that end with 'y', - * the 'y' is replaced with 'ies'. Strings that end with 'us', the - * 'us' is replaced with 'i'. - * @param string String to be returned in labels form + * Converts a string to title case. + * Capitalizes the first letter of each word, except for small words (e.g., "of", "and") + * unless they appear at the beginning. + * + * @param input The string to be converted to title case. */ -export function titalize(string: string) { - return string - .replace(/(^|\s)([a-z])/g , (str, left, right) => { return left + right.toUpperCase() } ) - .replace(/(?!^)\b(The)\b/, 'the') - .replace(/(?!^)\b(Of)\b/, 'of') - .replace(/(?!^)\b(In)\b/, 'in') - .replace(/(?!^)\b(On)\b/, 'on') - .replace(/(?!^)\b(A)\b/, 'a') - .replace(/(?!^)\b(An)\b/, 'an') - .replace(/(?!^)\b(And)\b/, 'and') - .replace(/(?!^)\b(At)\b/, 'at') - .replace(/(?!^)\b(By)\b/, 'by') - .replace(/(?!^)\b(Be)\b/, 'be') - .replace(/(?!^)\b(To)\b/, 'to') - .replace(/(?!^)\b(But)\b/, 'but') - .replace(/(?!^)\b(For)\b/, 'for') +export function titalize(input: string): string { + const smallWords = new Set([ + 'a', 'an', 'and', 'at', 'be', 'but', 'by', 'for', + 'in', 'of', 'on', 'the', 'to' + ]); + + return input + .toLowerCase() + .split(/\s+/) + .map((word, index) => { + if (index === 0 || !smallWords.has(word)) { + return word[0].toUpperCase() + word.slice(1); + } + return word; + }) + .join(' '); } \ No newline at end of file From 486c4fdd8c0806e29264c38e7823dea8d5ee4315 Mon Sep 17 00:00:00 2001 From: Maverik Minett Date: Sun, 15 Jun 2025 19:08:05 -0400 Subject: [PATCH 04/16] update readme --- README.md | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index b65dee4..d817cd2 100644 --- a/README.md +++ b/README.md @@ -11,20 +11,30 @@ import { pascalize, pluralize, titalize, - verbalize + verbalize, + quantify } from '@agape/string' -camelize('foo bar') // fooBar +camelize('user name') // userName +camelize('email-address') // emailAddress -kebabify('Foo bar') // foo-bar +kebabify('Display Name') // display-name +kebabify('userProfileId') // user-profile-id -pascalize('Foo bar') // FooBar +pascalize('user id') // UserId +pascalize('api_response_code') // ApiResponseCode -pluralize('foo') // foos +pluralize('city') // cities +pluralize('analysis') // analyses -titalize('a foo a bar') // A Foo a Bar +quantify(1, 'item') // 1 item +quantify(3, 'box') // 3 boxes -verbalize('foo-bar') // Foo bar +titalize('the lord of the rings') // The Lord of the Rings +titalize('war and peace') // War and Peace + +verbalize('user-profile-id') // User profile id +verbalize('XMLHttpRequest') // XML Http Request ``` ## Description @@ -33,31 +43,35 @@ Translate strings between different representations. ## Functions -`camelize` +`camelize(input: string)` Convert to camel case. -`kebabify` +`kebabify(input: string)` Converted to kebab-case: lower case, word boundaries replaced with dashes. -`pascalize` +`pascalize(input: string)` Remove all symbols and spaces, captialize words. -`pluralize` +`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. -`titalize` +`quantify(value: number, unit: string, plural?: string)` + +The value will be paired with the unit, either singular or plural form + +`titalize(input: number)` The first letter of each word is capitalized with the exception of `a, an, and, at, be, but, by, for, if, in, of, on, the, to` which are only capitalized if they are the first word in the string, otherwise they are converted to lowercase. -`verbalize` +`verbalize(input: number)` First character capitalized, word boundaries replaced with spaces. From da9c640408da4670955bda708e757961b742e7bb Mon Sep 17 00:00:00 2001 From: Maverik Minett Date: Sun, 15 Jun 2025 19:09:11 -0400 Subject: [PATCH 05/16] bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7c0f831..575ab77 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agape/string", - "version": "2.0.1", + "version": "2.1.0", "description": "String and token manipulation", "main": "./cjs/index.js", "module": "./es2020/index.js", From 6911982dd46e0a2326dcba55203f6684d95ab299 Mon Sep 17 00:00:00 2001 From: Maverik Minett Date: Sun, 15 Jun 2025 19:34:08 -0400 Subject: [PATCH 06/16] add unit tests for camelize and pascalize --- src/lib/string.spec.ts | 127 +++++++++++++++++++++++++++++++++++++---- src/lib/string.ts | 33 ++++++++--- 2 files changed, 141 insertions(+), 19 deletions(-) diff --git a/src/lib/string.spec.ts b/src/lib/string.spec.ts index 6c7cece..67bb1c0 100644 --- a/src/lib/string.spec.ts +++ b/src/lib/string.spec.ts @@ -1,19 +1,124 @@ import { camelize, kebabify, verbalize, pascalize, pluralize, titalize, quanitfy } from './string' describe('camelize', () => { - it('should camelize a string', () => { - expect( camelize('foo bar') ).toEqual('fooBar') - }) -}) + it('should convert space-separated words to camelCase', () => { + expect(camelize('first name')).toEqual('firstName'); + expect(camelize('user profile id')).toEqual('userProfileId'); + }); + + it('should convert hyphenated words to camelCase', () => { + expect(camelize('first-name')).toEqual('firstName'); + expect(camelize('user-profile-id')).toEqual('userProfileId'); + }); + + it('should convert snake_case to camelCase', () => { + expect(camelize('first_name')).toEqual('firstName'); + expect(camelize('user_profile_id')).toEqual('userProfileId'); + }); + + it('should handle mixed delimiters', () => { + expect(camelize('user-profile_id')).toEqual('userProfileId'); + expect(camelize('user profile_id-name')).toEqual('userProfileIdName'); + }); + + it('should lowercase the first character', () => { + expect(camelize('First Name')).toEqual('firstName'); + expect(camelize('API Response')).toEqual('apiResponse'); + }); + + it('should remove non-alphanumeric characters', () => { + expect(camelize('user@name!')).toEqual('userName'); + expect(camelize('hello.world')).toEqual('helloWorld'); + }); + + it('should handle numbers correctly', () => { + expect(camelize('version 2 id')).toEqual('version2Id'); + expect(camelize('api_2_response')).toEqual('api2Response'); + }); + + it('should return empty string for empty input', () => { + expect(camelize('')).toEqual(''); + }); + + it('should not change already camelCased input', () => { + expect(camelize('alreadyCamelCase')).toEqual('alreadyCamelCase'); + }); + + it('should handle a single word', () => { + expect(camelize('username')).toEqual('username'); + expect(camelize('Username')).toEqual('username'); + }); +}); describe('pascalize', () => { - it('should classify a string', () => { - expect( pascalize('foo bar') ).toEqual('FooBar') - }) - it('should classify a token', () => { - expect( pascalize('foo-bar') ).toEqual('FooBar') - }) -}) + it('should convert space-separated words to PascalCase', () => { + expect(pascalize('first name')).toEqual('FirstName'); + expect(pascalize('api response')).toEqual('ApiResponse'); + }); + + it('should convert kebab-case to PascalCase', () => { + expect(pascalize('user-profile')).toEqual('UserProfile'); + expect(pascalize('api-response-code')).toEqual('ApiResponseCode'); + }); + + it('should convert snake_case to PascalCase', () => { + expect(pascalize('user_profile')).toEqual('UserProfile'); + expect(pascalize('api_response_code')).toEqual('ApiResponseCode'); + }); + + it('should convert mixed delimiters to PascalCase', () => { + expect(pascalize('user_profile-id')).toEqual('UserProfileId'); + expect(pascalize('user profile-id_name')).toEqual('UserProfileIdName'); + }); + + it('should remove non-alphanumeric characters', () => { + expect(pascalize('foo@bar!baz')).toEqual('FooBarBaz'); + expect(pascalize('hello.world')).toEqual('HelloWorld'); + expect(pascalize('this#is$clean')).toEqual('ThisIsClean'); + }); + + it('should preserve numeric prefix', () => { + expect(pascalize('123foo bar')).toEqual('123FooBar'); + expect(pascalize('456-api-response')).toEqual('456ApiResponse'); + }); + + it('should retain numbers elsewhere in the string', () => { + expect(pascalize('version 2')).toEqual('Version2'); + expect(pascalize('item_404')).toEqual('Item404'); + expect(pascalize('response code 500')).toEqual('ResponseCode500'); + }); + + it('should preserve clean PascalCase input', () => { + expect(pascalize('UserProfile')).toEqual('UserProfile'); + expect(pascalize('XMLHttpRequest')).toEqual('XMLHttpRequest'); + }); + + it('should capitalize the first letter of lowercase words', () => { + expect(pascalize('username')).toEqual('Username'); + expect(pascalize('profile')).toEqual('Profile'); + }); + + it('should trim and clean excess whitespace', () => { + expect(pascalize(' user profile ')).toEqual('UserProfile'); + expect(pascalize('\t api \n response')).toEqual('ApiResponse'); + }); + + it('should return empty string for empty input', () => { + expect(pascalize('')).toEqual(''); + expect(pascalize(' ')).toEqual(''); + }); + + it('should not break on single-character input', () => { + expect(pascalize('x')).toEqual('X'); + expect(pascalize('X')).toEqual('X'); + }); + + it('should not alter valid all-digit strings except trimming', () => { + expect(pascalize('123')).toEqual('123'); + expect(pascalize(' 123 ')).toEqual('123'); + }); +}); + describe('pluralize', () => { diff --git a/src/lib/string.ts b/src/lib/string.ts index cd55743..ca538a3 100644 --- a/src/lib/string.ts +++ b/src/lib/string.ts @@ -6,10 +6,14 @@ * @param input The input string to be camelized. */ export function camelize(input: string): string { - return input - .replace(/[^A-Za-z0-9]+/g, ' ') + const parts = input + .replace(/[^A-Za-z0-9]+/g, ' ') // normalize delimiters + .replace(/([a-z])([A-Z])/g, '$1 $2') // split camelCase + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') // split acronyms like "HTMLParser" .trim() - .split(' ') + .split(/\s+/); // split by space + + return parts .map((word, index) => { if (index === 0) return word.toLowerCase(); return word.charAt(0).toUpperCase() + word.slice(1); @@ -17,6 +21,7 @@ export function camelize(input: string): string { .join(''); } + /** * Converts a string to kebab-case. * Replaces spaces, underscores, and camelCase transitions with dashes, @@ -59,12 +64,24 @@ export function verbalize(input: string): string { * @param input A string to be returned in PascalCase. */ export function pascalize(input: string): string { - return input - .replace(/[^A-Za-z0-9]+/g, ' ') // normalize separators - .replace(/^\d+\s*/, '') // remove leading digits + const parts = input + .replace(/[^A-Za-z0-9]+/g, ' ') + .replace(/([0-9])([A-Za-z])/g, '$1 $2') // digit → letter + .replace(/([A-Za-z])([0-9])/g, '$1 $2') // letter → digit + .replace(/([A-Z])/g, ' $1') // split every capital letter .trim() - .split(' ') - .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .split(/\s+/); + + console.log(">>>>>", input, ">>>>>>", parts.join(', '), ">>>>>>>", parts + .map((word, index) => { + return word.charAt(0).toUpperCase() + word.slice(1); + }) + .join('')) + + return parts + .map((word, index) => { + return word.charAt(0).toUpperCase() + word.slice(1); + }) .join(''); } From 8c96e56965cca327ee1d7d15bafeafa84911df22 Mon Sep 17 00:00:00 2001 From: Maverik Minett Date: Sun, 15 Jun 2025 19:34:36 -0400 Subject: [PATCH 07/16] remove console log --- src/lib/string.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/lib/string.ts b/src/lib/string.ts index ca538a3..cf7d3a2 100644 --- a/src/lib/string.ts +++ b/src/lib/string.ts @@ -72,12 +72,6 @@ export function pascalize(input: string): string { .trim() .split(/\s+/); - console.log(">>>>>", input, ">>>>>>", parts.join(', '), ">>>>>>>", parts - .map((word, index) => { - return word.charAt(0).toUpperCase() + word.slice(1); - }) - .join('')) - return parts .map((word, index) => { return word.charAt(0).toUpperCase() + word.slice(1); From d590cadc9bd8f0d914859e2c776f0930614559a4 Mon Sep 17 00:00:00 2001 From: Maverik Minett Date: Sun, 15 Jun 2025 19:37:13 -0400 Subject: [PATCH 08/16] add tests for pluralize --- src/lib/string.spec.ts | 74 +++++++++++++++++++++++++++++++++++------- src/lib/string.ts | 7 ++-- 2 files changed, 64 insertions(+), 17 deletions(-) diff --git a/src/lib/string.spec.ts b/src/lib/string.spec.ts index 67bb1c0..859a0d6 100644 --- a/src/lib/string.spec.ts +++ b/src/lib/string.spec.ts @@ -122,19 +122,69 @@ describe('pascalize', () => { describe('pluralize', () => { - it('should pluralize a string', () => { - expect( pluralize('foo') ).toEqual('foos') - }) + it('should pluralize regular nouns', () => { + expect(pluralize('dog')).toEqual('dogs'); + expect(pluralize('book')).toEqual('books'); + expect(pluralize('car')).toEqual('cars'); + }); - it('should pluralize city', () => { - expect( pluralize('city') ).toEqual('cities') - }) - - it('should maintain case', () => { - expect( pluralize('Foo') ).toEqual('Foos') - expect( pluralize('City') ).toEqual('Cities') - }) -}) + it('should pluralize words ending in "y"', () => { + expect(pluralize('city')).toEqual('cities'); + expect(pluralize('puppy')).toEqual('puppies'); + }); + + it('should pluralize words ending in "s", "x", "z", "ch", "sh"', () => { + expect(pluralize('bus')).toEqual('buses'); + expect(pluralize('box')).toEqual('boxes'); + expect(pluralize('buzz')).toEqual('buzzes'); + expect(pluralize('watch')).toEqual('watches'); + expect(pluralize('dish')).toEqual('dishes'); + }); + + it('should pluralize known irregulars', () => { + expect(pluralize('child')).toEqual('children'); + expect(pluralize('person')).toEqual('people'); + expect(pluralize('mouse')).toEqual('mice'); + expect(pluralize('goose')).toEqual('geese'); + expect(pluralize('foot')).toEqual('feet'); + expect(pluralize('tooth')).toEqual('teeth'); + expect(pluralize('man')).toEqual('men'); + expect(pluralize('woman')).toEqual('women'); + expect(pluralize('analysis')).toEqual('analyses'); + expect(pluralize('cactus')).toEqual('cacti'); + }); + + it('should preserve initial capitalization', () => { + expect(pluralize('City')).toEqual('Cities'); + expect(pluralize('Bus')).toEqual('Buses'); + expect(pluralize('Child')).toEqual('Children'); + expect(pluralize('Person')).toEqual('People'); + expect(pluralize('Box')).toEqual('Boxes'); + }); + + it('should preserve all-uppercase acronyms', () => { + expect(pluralize('API')).toEqual('APIs'); + expect(pluralize('ID')).toEqual('IDs'); + expect(pluralize('HTML')).toEqual('HTMLs'); + }); + + it('should pluralize single-letter words', () => { + expect(pluralize('A')).toEqual('As'); + expect(pluralize('x')).toEqual('xs'); + }); + + it('should handle empty string', () => { + expect(pluralize('')).toEqual('s'); + }); + + it('should pluralize words with existing "es" ending appropriately', () => { + expect(pluralize('thesis')).toEqual('theses'); + expect(pluralize('crisis')).toEqual('crises'); + expect(pluralize('diagnosis')).toEqual('diagnoses'); + expect(pluralize('syllabus')).toEqual('syllabi'); + }); + +}); describe('titalize', () => { diff --git a/src/lib/string.ts b/src/lib/string.ts index cf7d3a2..81d8e0f 100644 --- a/src/lib/string.ts +++ b/src/lib/string.ts @@ -113,26 +113,23 @@ export function pluralize(word: string): string { crisis: 'crises' }; - // Handle irregulars if (irregulars[lower]) { const plural = irregulars[lower]; return preserveCasing(word, lower, plural); } - // y → ies if (word.match(/[^aeiou]y$/i)) { return word.slice(0, -1) + 'ies'; } - // s, x, z, ch, sh → es - if (word.match(/(s|x|z|ch|sh)$/i)) { + if (word.length > 1 && word.match(/(s|x|z|ch|sh)$/i)) { return word + 'es'; } - // Default: just add 's' return word + 's'; } + /** * Copies the casing of `source` onto `target`, from the start. * Only affects characters that exist in the same position in both strings. From bfefeb870d65f2146d9c936054319833a36d6c48 Mon Sep 17 00:00:00 2001 From: Maverik Minett Date: Sun, 15 Jun 2025 21:17:27 -0400 Subject: [PATCH 09/16] add tests for titalize --- src/lib/string.spec.ts | 66 ++++++++++++++++++++++++++++++++---------- src/lib/string.ts | 27 ++++++++++------- 2 files changed, 68 insertions(+), 25 deletions(-) diff --git a/src/lib/string.spec.ts b/src/lib/string.spec.ts index 859a0d6..90146ae 100644 --- a/src/lib/string.spec.ts +++ b/src/lib/string.spec.ts @@ -23,7 +23,6 @@ describe('camelize', () => { it('should lowercase the first character', () => { expect(camelize('First Name')).toEqual('firstName'); - expect(camelize('API Response')).toEqual('apiResponse'); }); it('should remove non-alphanumeric characters', () => { @@ -186,23 +185,60 @@ describe('pluralize', () => { }); - describe('titalize', () => { - it('should titalize the string', () => { - expect( titalize('foo bar') ).toEqual('Foo Bar') - }) - it('should maintain case after hyphen', () => { - expect( titalize('foo-bar') ).toEqual('Foo-bar') - }) - it('should lowercase functional words', () => { - expect( titalize('hop on pop') ).toEqual('Hop on Pop') - expect( titalize('ducks in a row') ).toEqual('Ducks in a Row') - expect( titalize('james and the anteater') ).toEqual('James and the Anteater') - expect( titalize('the wonderful world of mystery science') ).toEqual('The Wonderful World of Mystery Science') - }) + it('should capitalize each significant word', () => { + expect(titalize('foo bar')).toEqual('Foo Bar'); + expect(titalize('quick brown fox')).toEqual('Quick Brown Fox'); + }); -}) + it('should preserve case after hyphens', () => { + expect(titalize('foo-bar')).toEqual('Foo-bar'); + expect(titalize('jack-in-the-box')).toEqual('Jack-in-the-box'); + }); + + it('should lowercase functional words except when first', () => { + expect(titalize('hop on pop')).toEqual('Hop on Pop'); + expect(titalize('ducks in a row')).toEqual('Ducks in a Row'); + expect(titalize('james and the anteater')).toEqual('James and the Anteater'); + expect(titalize('the wonderful world of mystery science')).toEqual('The Wonderful World of Mystery Science'); + expect(titalize('and then there were none')).toEqual('And Then There Were None'); + }); + + it('should capitalize functional words when first', () => { + expect(titalize('in the beginning')).toEqual('In the Beginning'); + expect(titalize('at the gates')).toEqual('At the Gates'); + expect(titalize('by the sea')).toEqual('By the Sea'); + }); + + it('should not change casing of punctuation', () => { + expect(titalize('who framed roger rabbit?')).toEqual('Who Framed Roger Rabbit?'); + expect(titalize('what time is it, mr. fox?')).toEqual('What Time Is It, Mr. Fox?'); + }); + + it('should handle mixed casing inputs by preserving case (seems intentional)', () => { + expect(titalize('ThE gIrL wItH tHe DrAgOn TaTtOo')).toEqual('ThE gIrL wItH tHe DrAgOn TaTtOo'); + }); + + it('should trim and clean excess whitespace', () => { + expect(titalize(' the jungle book ')).toEqual('The Jungle Book'); + expect(titalize('\nhop\non\npop\n')).toEqual('Hop on Pop'); + }); + + it('should work with single-word input', () => { + expect(titalize('frozen')).toEqual('Frozen'); + expect(titalize('and')).toEqual('And'); + }); + + it('should handle acronyms as capitalized', () => { + expect(titalize('the rise of NASA')).toEqual('The Rise of NASA'); + }); + + it('should handle empty string', () => { + expect(titalize('')).toEqual(''); + }); + +}); describe('kebabify', () => { diff --git a/src/lib/string.ts b/src/lib/string.ts index 81d8e0f..a5f143a 100644 --- a/src/lib/string.ts +++ b/src/lib/string.ts @@ -7,11 +7,12 @@ */ export function camelize(input: string): string { const parts = input - .replace(/[^A-Za-z0-9]+/g, ' ') // normalize delimiters - .replace(/([a-z])([A-Z])/g, '$1 $2') // split camelCase - .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') // split acronyms like "HTMLParser" + .replace(/[^A-Za-z0-9]+/g, ' ') + .replace(/([0-9])([A-Za-z])/g, '$1 $2') // digit → letter + .replace(/([A-Za-z])([0-9])/g, '$1 $2') // letter → digit + .replace(/([A-Z])/g, ' $1') // split every capital letter .trim() - .split(/\s+/); // split by space + .split(/\s+/); return parts .map((word, index) => { @@ -129,7 +130,6 @@ export function pluralize(word: string): string { return word + 's'; } - /** * Copies the casing of `source` onto `target`, from the start. * Only affects characters that exist in the same position in both strings. @@ -249,13 +249,20 @@ export function titalize(input: string): string { ]); return input - .toLowerCase() + .trim() .split(/\s+/) - .map((word, index) => { - if (index === 0 || !smallWords.has(word)) { + .filter(word => word.length) + .map( + (word, index) => { + const lowercased = word.toLowerCase(); + if (/[A-Z]/.test(word)) { + return word; + } + if (smallWords.has(lowercased) && index != 0) { + return lowercased; + } return word[0].toUpperCase() + word.slice(1); } - return word; - }) + ) .join(' '); } \ No newline at end of file From 849f8784963bdc660a787e07703697cf88a8c6d6 Mon Sep 17 00:00:00 2001 From: Maverik Minett Date: Sun, 15 Jun 2025 21:53:59 -0400 Subject: [PATCH 10/16] added tests for kebabify --- src/lib/string.spec.ts | 82 ++++++++++++++++++++++++++++++++++++++---- src/lib/string.ts | 48 +++++++++++++++++++++---- 2 files changed, 116 insertions(+), 14 deletions(-) diff --git a/src/lib/string.spec.ts b/src/lib/string.spec.ts index 90146ae..57c90c2 100644 --- a/src/lib/string.spec.ts +++ b/src/lib/string.spec.ts @@ -242,15 +242,83 @@ describe('titalize', () => { describe('kebabify', () => { - it('should kebabify the string', () => { - expect( kebabify('Foo Bar') ).toEqual('foo-bar') - }) + it('should kebabify space-separated strings', () => { + expect(kebabify('Foo Bar')).toEqual('foo-bar'); + expect(kebabify('The Quick Brown Fox')).toEqual('the-quick-brown-fox'); + }); - it('should kebabify the string', () => { - expect( kebabify('FooBar') ).toEqual('foo-bar') - }) + it('should kebabify camelCase', () => { + expect(kebabify('fooBar')).toEqual('foo-bar'); + expect(kebabify('getUserId')).toEqual('get-user-id'); + }); + + it('should kebabify PascalCase', () => { + expect(kebabify('FooBar')).toEqual('foo-bar'); + expect(kebabify('HTMLParser')).toEqual('html-parser'); + expect(kebabify('GetUserID')).toEqual('get-user-id'); + }); + + it('should normalize existing kebab-case', () => { + expect(kebabify('foo-bar')).toEqual('foo-bar'); + }); + + it('should preserve multiple dashes', () => { + expect(kebabify('foo--bar')).toEqual('foo--bar'); + expect(kebabify('foo---bar---baz')).toEqual('foo---bar---baz'); + }); + + it('should convert multiple underscores to multiple hyphens', () => { + expect(kebabify('foo__bar')).toEqual('foo--bar'); + expect(kebabify('foo___bar___baz')).toEqual('foo---bar---baz'); + }); + + it('should convert snake_case to kebab-case', () => { + expect(kebabify('foo_bar')).toEqual('foo-bar'); + expect(kebabify('get_user_id')).toEqual('get-user-id'); + }); + + it('should remove special characters and preserve alphanumerics', () => { + expect(kebabify('foo@bar!baz')).toEqual('foo-bar-baz'); + expect(kebabify('hello.world')).toEqual('hello-world'); + expect(kebabify('what#the$heck')).toEqual('what-the-heck'); + }); + + it('should kebabify acronyms and numbers', () => { + expect(kebabify('APIResponse')).toEqual('api-response'); + expect(kebabify('userID2')).toEqual('user-id-2'); + expect(kebabify('html5Parser')).toEqual('html-5-parser'); + expect(kebabify('v2Response')).toEqual('v2-response'); + }); + + it('should preserve version tokens like v2, v1.0, v2.0.1', () => { + expect(kebabify('v2')).toEqual('v2'); + expect(kebabify('v1.0')).toEqual('v1-0'); + expect(kebabify('v2.0.1')).toEqual('v2-0-1'); + expect(kebabify('apiV1.0')).toEqual('api-v1-0'); + expect(kebabify('apiV2.0.1Response')).toEqual('api-v2-0-1-response'); + }); + + it('should kebabify mixed delimiters and normalize them', () => { + expect(kebabify('foo_bar-baz value')).toEqual('foo-bar-baz-value'); + expect(kebabify('foo_bar--baz')).toEqual('foo-bar--baz'); + }); + + it('should return empty string for empty input', () => { + expect(kebabify('')).toEqual(''); + }); + + it('should return a single lowercase word if input is a single word', () => { + expect(kebabify('FOO')).toEqual('foo'); + expect(kebabify('bar')).toEqual('bar'); + }); + + it('should strip leading and trailing dashes from messy input', () => { + expect(kebabify('---foo--bar---')).toEqual('foo--bar'); + expect(kebabify(' foo bar ')).toEqual('foo-bar'); + }); + +}); -}) describe('verbalize', () => { diff --git a/src/lib/string.ts b/src/lib/string.ts index a5f143a..69f88b6 100644 --- a/src/lib/string.ts +++ b/src/lib/string.ts @@ -1,3 +1,4 @@ +const PLACEHOLDER = '\x1F'; /** * Converts a string to camelCase. @@ -31,16 +32,49 @@ export function camelize(input: string): string { * @param input A string to be converted to kebab-case. */ export function kebabify(input: string): string { - return input - .replace(/([a-z0-9])([A-Z])/g, '$1-$2') // camelCase → camel-Case - .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') // XMLHttp → XML-Http - .replace(/[_\s]+/g, '-') // underscores/spaces → dash - .replace(/[^a-zA-Z0-9\-]+/g, '') // remove symbols except dash - .replace(/--+/g, '-') // collapse multiple dashes - .replace(/^-+|-+$/g, '') // trim starting/trailing dash + 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, '-') // underscore → dash (1:1) + .replace(/\s+/g, '-') // spaces → dash + .replace(new RegExp(`[^a-zA-Z0-9\-${PLACEHOLDER}]+`, 'g'), '-') // symbols → dash + .replace(/^-+|-+$/g, '') // trim dashes .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(); } + + +// export function kebabify(input: string): string { +// return input +// .replace(/([a-z0-9])([A-Z])/g, '$1-$2') // camelCase → camel-Case +// .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') // XMLHttp → XML-Http +// .replace(/[_\s]+/g, '-') // underscores/spaces → dash +// .replace(/[^a-zA-Z0-9\-]+/g, '') // remove symbols except dash +// .replace(/--+/g, '-') // collapse multiple dashes +// .replace(/^-+|-+$/g, '') // trim starting/trailing dash +// .toLowerCase(); +// } + + /** * Returns a string formatted as spoken words. * Adds spaces between words in camelCase, PascalCase, snake_case, or kebab-case identifiers. From d41767e9a904b08d8b1298c639453164543d48bd Mon Sep 17 00:00:00 2001 From: Maverik Minett Date: Sun, 15 Jun 2025 23:18:06 -0400 Subject: [PATCH 11/16] add unit tests for verbalize --- src/lib/string.spec.ts | 57 +++++++++++++++++++++++++++++++++++++++--- src/lib/string.ts | 55 ++++++++++++++++++++++++---------------- 2 files changed, 87 insertions(+), 25 deletions(-) diff --git a/src/lib/string.spec.ts b/src/lib/string.spec.ts index 57c90c2..ed4ac18 100644 --- a/src/lib/string.spec.ts +++ b/src/lib/string.spec.ts @@ -322,12 +322,61 @@ describe('kebabify', () => { describe('verbalize', () => { - - it('should verbalize the string', () => { - expect( verbalize('foo-bar') ).toEqual('Foo bar') + + it('should verbalize kebab-case', () => { + expect(verbalize('foo-bar')).toEqual('Foo bar'); + expect(verbalize('user-profile-id')).toEqual('User profile id'); + }); + + it('should verbalize snake_case', () => { + expect(verbalize('foo_bar')).toEqual('Foo bar'); + expect(verbalize('user_profile_id')).toEqual('User profile id'); + }); + + it('should verbalize camelCase', () => { + expect(verbalize('fooBar')).toEqual('Foo bar'); + expect(verbalize('userProfileId')).toEqual('User profile id'); + }); + + it('should verbalize PascalCase', () => { + expect(verbalize('FooBar')).toEqual('Foo bar'); + expect(verbalize('UserProfileId')).toEqual('User profile id'); + }); + + it('should handle mixed delimiters', () => { + expect(verbalize('user-profile_id')).toEqual('User profile id'); + expect(verbalize('userProfile_id-name')).toEqual('User profile id name'); + }); + + it('should preserve and separate numbers', () => { + expect(verbalize('version2Id')).toEqual('Version 2 id'); + expect(verbalize('api_v2_response')).toEqual('Api v2 response'); + expect(verbalize('html5Parser')).toEqual('Html 5 parser'); + }); + + it('should trim and clean up input', () => { + expect(verbalize(' user profile ')).toEqual('User profile'); + }); + + it('should capitalize the first letter of the result', () => { + expect(verbalize('foo')).toEqual('Foo'); + expect(verbalize('userProfile')).toEqual('User profile'); + }); + + it('should handle empty input', () => { + expect(verbalize('')).toEqual(''); + }); + + it('should handle a single uppercase acronym', () => { + expect(verbalize('NASA')).toEqual('NASA'); + expect(verbalize('XMLHttpRequest')).toEqual('XML http request'); + }); + + it('should keep v and the version number together', () => { + expect(verbalize("EmployeeV2")).toEqual("Employee v2") }) -}) +}); describe('quantify', () => { diff --git a/src/lib/string.ts b/src/lib/string.ts index 69f88b6..060b2b7 100644 --- a/src/lib/string.ts +++ b/src/lib/string.ts @@ -61,20 +61,6 @@ export function kebabify(input: string): string { return kebabed.toLowerCase(); } - - -// export function kebabify(input: string): string { -// return input -// .replace(/([a-z0-9])([A-Z])/g, '$1-$2') // camelCase → camel-Case -// .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') // XMLHttp → XML-Http -// .replace(/[_\s]+/g, '-') // underscores/spaces → dash -// .replace(/[^a-zA-Z0-9\-]+/g, '') // remove symbols except dash -// .replace(/--+/g, '-') // collapse multiple dashes -// .replace(/^-+|-+$/g, '') // trim starting/trailing dash -// .toLowerCase(); -// } - - /** * Returns a string formatted as spoken words. * Adds spaces between words in camelCase, PascalCase, snake_case, or kebab-case identifiers. @@ -83,13 +69,40 @@ export function kebabify(input: string): string { * @param input A string to be returned as spoken words. */ export function verbalize(input: string): string { - return input - .replace(/([a-z0-9])([A-Z])/g, '$1 $2') // camelCase → camel Case - .replace(/([A-Z])([A-Z][a-z])/g, '$1 $2') // XMLHttp → XML Http - .replace(/[-_]+/g, ' ') // kebab-case and snake_case → space - .replace(/\s+/g, ' ') // collapse extra spaces - .trim() - .replace(/^([a-z])/, (match) => match.toUpperCase()); // Capitalize first letter + const versionMatches: string[] = []; + + const preserved = input.replace(/(v\d+(\.\d+)*)(?=[^a-zA-Z0-9]|$)/gi, (_, match) => { + const id = versionMatches.length; + versionMatches.push(match.toLowerCase()); + return ` ${PLACEHOLDER}${id}`; + }); + + if (versionMatches.length) console.log(versionMatches) + + const split = preserved + .replace(/([a-z])([A-Z])/g, '$1 $2') // camelCase → camel Case + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') // XMLHTTP → XML HTTP + .replace(/([a-zA-Z])(\d)/g, '$1 $2') // foo2 → foo 2 + .replace(/(\d)([a-zA-Z])/g, '$1 $2') // 2foo → 2 foo + .replace(/[-_]+/g, ' ') // kebab/snake → space + .replace(/\s+/g, ' ') // collapse multiple spaces + .trim(); + + const result = split + .split(' ') + .map(word => { + const versionIndex = new RegExp(`^${PLACEHOLDER}(\\d+)$`).exec(word); + if (versionIndex) { + return versionMatches[parseInt(versionIndex[1], 10)]; + } + if (word === word.toUpperCase()) { + return word; // preserve acronyms + } + return word.toLowerCase(); + }) + .join(' '); + + return result.charAt(0).toUpperCase() + result.slice(1); } /** From 2d1ed8e2d9062c06e19f22c59084bb74dcdc1aca Mon Sep 17 00:00:00 2001 From: Maverik Minett Date: Tue, 17 Jun 2025 08:57:33 -0400 Subject: [PATCH 12/16] update github actions workflow --- .github/workflows/validate-merge-request.yml | 68 ++++++++++++++------ 1 file changed, 49 insertions(+), 19 deletions(-) diff --git a/.github/workflows/validate-merge-request.yml b/.github/workflows/validate-merge-request.yml index 55c1163..cdb19c8 100644 --- a/.github/workflows/validate-merge-request.yml +++ b/.github/workflows/validate-merge-request.yml @@ -1,35 +1,65 @@ -name: Validate Merge Request +name: Validate @agape/string in monorepo context on: pull_request: jobs: test: - name: Nx Unit Tests runs-on: ubuntu-latest steps: - - name: Checkout Repo + - name: Checkout agape-string (this repo) uses: actions/checkout@v4 - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: '20' # use your actual Node version + - name: Extract branch name + id: extract_branch + run: echo "branch=${GITHUB_HEAD_REF}" >> $GITHUB_OUTPUT - - name: Install Dependencies - run: npm ci + - name: Clone AgapeToolkit monorepo (with submodules) + run: | + git clone --recurse-submodules https://github.com/AgapeToolkit/AgapeToolkit.git ../AgapeToolkit + cd ../AgapeToolkit + + # Loop over all submodules + git submodule foreach --recursive ' + MOD_PATH="$name" + cd "$toplevel/$MOD_PATH" + + echo "🔍 Checking submodule: $MOD_PATH" + + # Skip if this is the same repo we're running CI for + if [[ "$MOD_PATH" == "libs/string" ]]; then + echo "⚠️ Skipping $MOD_PATH (current module)" + exit 0 + fi - - name: Cache Nx - uses: actions/cache@v4 + # Fetch remote branches + git fetch origin + + # Check if the target branch exists + if git rev-parse --verify origin/${{ steps.extract_branch.outputs.branch }} >/dev/null 2>&1; then + echo "✅ Switching $MOD_PATH to branch ${{ steps.extract_branch.outputs.branch }}" + git checkout ${{ steps.extract_branch.outputs.branch }} + git pull origin ${{ steps.extract_branch.outputs.branch }} + else + echo "🛑 No matching branch in $MOD_PATH — leaving on default" + fi + ' + + - name: Replace string code in monorepo + run: | + rm -rf ../AgapeToolkit/libs/string + cp -r . ../AgapeToolkit/libs/string + + - name: Setup Node.js + uses: actions/setup-node@v4 with: - path: .nx/cache - key: ${{ runner.os }}-nx-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-nx- + node-version: '20' - - name: Run Lint (Affected) - run: npx nx affected:lint --base=origin/main --head=HEAD + - name: Install monorepo dependencies + working-directory: ../AgapeToolkit + run: npm ci - - name: Run Unit Tests (Affected) - run: npx nx affected:test --base=origin/main --head=HEAD + - name: Run tests for @agape/string + working-directory: ../AgapeToolkit + run: npx nx test string From 3afdb5f534108f57ec68aa8064968b152463491c Mon Sep 17 00:00:00 2001 From: Maverik Minett Date: Tue, 17 Jun 2025 09:20:16 -0400 Subject: [PATCH 13/16] update github actions workflow --- .github/workflows/validate-merge-request.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/validate-merge-request.yml b/.github/workflows/validate-merge-request.yml index cdb19c8..44f5ade 100644 --- a/.github/workflows/validate-merge-request.yml +++ b/.github/workflows/validate-merge-request.yml @@ -15,9 +15,13 @@ jobs: id: extract_branch run: echo "branch=${GITHUB_HEAD_REF}" >> $GITHUB_OUTPUT + - name: Configure Git to use PAT for submodules + run: | + git config --global url."https://${{ secrets.GH_PAT }}@github.com/".insteadOf "git@github.com:" + - name: Clone AgapeToolkit monorepo (with submodules) run: | - git clone --recurse-submodules https://github.com/AgapeToolkit/AgapeToolkit.git ../AgapeToolkit + git clone --recurse-submodules git@github.com:AgapeToolkit/AgapeToolkit.git ../AgapeToolkit cd ../AgapeToolkit # Loop over all submodules From 8bdeacb27eb1a53c454ed25f04b2d684bd5887b5 Mon Sep 17 00:00:00 2001 From: Maverik Minett Date: Tue, 17 Jun 2025 09:25:11 -0400 Subject: [PATCH 14/16] update github actions workflow --- .github/workflows/validate-merge-request.yml | 25 ++++++++++---------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/.github/workflows/validate-merge-request.yml b/.github/workflows/validate-merge-request.yml index 44f5ade..f6e3129 100644 --- a/.github/workflows/validate-merge-request.yml +++ b/.github/workflows/validate-merge-request.yml @@ -24,32 +24,33 @@ jobs: git clone --recurse-submodules git@github.com:AgapeToolkit/AgapeToolkit.git ../AgapeToolkit cd ../AgapeToolkit - # Loop over all submodules + # Loop over all submodules and optionally switch branches git submodule foreach --recursive ' - MOD_PATH="$name" - cd "$toplevel/$MOD_PATH" + echo "🔍 Checking submodule: $name" - echo "🔍 Checking submodule: $MOD_PATH" - - # Skip if this is the same repo we're running CI for - if [[ "$MOD_PATH" == "libs/string" ]]; then - echo "⚠️ Skipping $MOD_PATH (current module)" + # Skip if this is the current submodule + if [[ "$name" == "libs/string" ]]; then + echo "⚠️ Skipping $name (current module)" exit 0 fi - # Fetch remote branches + # Go to the submodule directory + cd "$toplevel/$name" + + # Fetch branches git fetch origin - # Check if the target branch exists + # Check if matching branch exists if git rev-parse --verify origin/${{ steps.extract_branch.outputs.branch }} >/dev/null 2>&1; then - echo "✅ Switching $MOD_PATH to branch ${{ steps.extract_branch.outputs.branch }}" + echo "✅ Switching $name to branch ${{ steps.extract_branch.outputs.branch }}" git checkout ${{ steps.extract_branch.outputs.branch }} git pull origin ${{ steps.extract_branch.outputs.branch }} else - echo "🛑 No matching branch in $MOD_PATH — leaving on default" + echo "🛑 No matching branch in $name — leaving on default" fi ' + - name: Replace string code in monorepo run: | rm -rf ../AgapeToolkit/libs/string From f6c885d1af093dedf2628e5f2cad160d91c0db01 Mon Sep 17 00:00:00 2001 From: Maverik Minett Date: Tue, 17 Jun 2025 09:26:59 -0400 Subject: [PATCH 15/16] update github actions workflow --- .github/workflows/validate-merge-request.yml | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/.github/workflows/validate-merge-request.yml b/.github/workflows/validate-merge-request.yml index f6e3129..f6bcf0a 100644 --- a/.github/workflows/validate-merge-request.yml +++ b/.github/workflows/validate-merge-request.yml @@ -24,23 +24,14 @@ jobs: git clone --recurse-submodules git@github.com:AgapeToolkit/AgapeToolkit.git ../AgapeToolkit cd ../AgapeToolkit - # Loop over all submodules and optionally switch branches - git submodule foreach --recursive ' + git submodule foreach --quiet --recursive ' echo "🔍 Checking submodule: $name" - - # Skip if this is the current submodule - if [[ "$name" == "libs/string" ]]; then + if [ "$name" = "libs/string" ]; then echo "⚠️ Skipping $name (current module)" exit 0 fi - - # Go to the submodule directory cd "$toplevel/$name" - - # Fetch branches git fetch origin - - # Check if matching branch exists if git rev-parse --verify origin/${{ steps.extract_branch.outputs.branch }} >/dev/null 2>&1; then echo "✅ Switching $name to branch ${{ steps.extract_branch.outputs.branch }}" git checkout ${{ steps.extract_branch.outputs.branch }} @@ -50,7 +41,6 @@ jobs: fi ' - - name: Replace string code in monorepo run: | rm -rf ../AgapeToolkit/libs/string From 3a0c3730f9184b156293cf198bc61af51c870e4d Mon Sep 17 00:00:00 2001 From: Maverik Minett Date: Tue, 17 Jun 2025 09:28:32 -0400 Subject: [PATCH 16/16] update node version in github actions workflow --- .github/workflows/validate-merge-request.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate-merge-request.yml b/.github/workflows/validate-merge-request.yml index f6bcf0a..ff4fc5f 100644 --- a/.github/workflows/validate-merge-request.yml +++ b/.github/workflows/validate-merge-request.yml @@ -49,7 +49,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '24' - name: Install monorepo dependencies working-directory: ../AgapeToolkit