diff --git a/CHANGELOG.md b/CHANGELOG.md index 15ceac9e..d105cf82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Bug Fixes +- [#634](https://github.com/nevware21/ts-utils/issues/634) Normalize camel-case words, share word boundaries across camel, kebab and snake case, preserve Unicode word characters, and remove leading and trailing separators. `strLetterCase` and `strCapitalizeWords` also recognize Unicode word characters. These changes can alter existing output; Unicode classification is approximate on engines without Unicode property escapes. `strCapitalizeWords` continues to lowercase existing camel-case boundaries. + # v0.16.0 July 18th, 2026 ## Changelog diff --git a/lib/src/string/conversion.ts b/lib/src/string/conversion.ts index f2be6f79..b530841f 100644 --- a/lib/src/string/conversion.ts +++ b/lib/src/string/conversion.ts @@ -6,36 +6,108 @@ * Licensed under the MIT license. */ -import { EMPTY } from "../internal/constants"; import { asString } from "./as_string"; -import { strReplace } from "./replace"; -import { strTrim } from "./trim"; import { strLower, strUpper } from "./upper_lower"; -/** - * @internal - * @ignore - * Internal function to escape and convert the case - * @param value - The value to be converted - * @param newPrefix - The new prefix to add between matches - * @param upperWord - Identify whether to uppercase the match - * @returns The converted string - */ +let _wordChar: RegExp; +let _numberChar: RegExp; +let _markChar: RegExp; + +function _isWordChar(value: string): boolean { + if (!_wordChar) { + try { + _wordChar = new RegExp("^[\\p{L}\\p{N}\\p{M}]$", "u"); + _numberChar = new RegExp("^\\p{N}$", "u"); + _markChar = new RegExp("^\\p{M}$", "u"); + } catch (_e) { + _wordChar = /^(?:[a-zA-Z0-9\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u02ff\u0370-\u052f\u0590-\u1fff\u2c00-\u2dff\u3040-\u30fa\u30fc-\ud7ff\uf900-\ufdcf\ufdf0-\uffef]|[\u0300-\u036f])$/; + _numberChar = /^[0-9\u0660-\u0669\u06f0-\u06f9\uff10-\uff19]$/; + _markChar = /^[\u0300-\u036f]$/; + } + } + + return _wordChar.test(value) || (value.length === 2 && strLower(value) !== strUpper(value)); +} + +function _charAt(value: string, index: number): string { + let first = value.charCodeAt(index); + let second = value.charCodeAt(index + 1); + return first >= 0xd800 && first <= 0xdbff && second >= 0xdc00 && second <= 0xdfff ? + value.substring(index, index + 2) : value.charAt(index); +} + +function _appendWord(result: string, value: string, start: number, end: number, newPrefix: string, upperWord?: boolean): string { + let word = strLower(value.substring(start, end)); + if (result) { + if (upperWord) { + let first = _charAt(word, 0); + word = strUpper(first) + word.substring(first.length); + } + result += newPrefix; + } + return result + word; +} + /*#__NO_SIDE_EFFECTS__*/ function _convertCase(value: T, newPrefix: string, upperWord?: boolean): string { - return strReplace(strTrim(asString(value)), /((_|\W)+(\w){0,1}|([a-z])([A-Z]))/g, - (_match, _g1, _g2, wordStart, upperPrefix, upperLetter) => { - let convertMatch = wordStart || upperLetter|| EMPTY; - if (upperWord) { - convertMatch = strUpper(convertMatch); + let input = asString(value); + let result = ""; + let start = -1; + let prevType = 0; + let run = 0; + let upperStart = -1; + let previous = 0; + + for (let idx = 0; idx < input.length;) { + let chr = _charAt(input, idx); + if (!_isWordChar(chr)) { + if (start !== -1) { + result = _appendWord(result, input, start, idx, newPrefix, upperWord); + start = -1; + } + prevType = 0; + run = 0; + } else { + let lower = strLower(chr); + let upper = strUpper(chr); + let type = lower !== upper ? (chr === upper ? 2 : 1) : (_numberChar.test(chr) ? 3 : 4); + if (start === -1) { + start = idx; + } else if (type === 2 && ((prevType === 1 && + (run > 1 || (upperStart === start && previous === start + _charAt(input, start).length))) || prevType === 3)) { + result = _appendWord(result, input, start, idx, newPrefix, upperWord); + start = idx; + } else if (type === 1 && prevType === 2 && run > 1 && upperStart === start) { + result = _appendWord(result, input, start, previous, newPrefix, upperWord); + start = previous; } - return (upperPrefix || EMPTY) + newPrefix + convertMatch; - }); + + if (!_markChar.test(chr)) { + if (type !== prevType) { + run = 0; + if (type === 2) { + upperStart = idx; + } + } + run++; + prevType = type; + previous = idx; + } + } + idx += chr.length; + } + + if (start !== -1) { + result = _appendWord(result, input, start, input.length, newPrefix, upperWord); + } + return result; } /** * Convert the provided value to a `Letter Cased` string, where the start of each word is * capitalized, all non-word character and spaces are retained. + * Letters, numbers and combining marks form words; on engines without Unicode property + * escapes an approximate character range is used. * If the value is not a string it will be converted. * @since 0.9.0 * @group String @@ -54,15 +126,25 @@ function _convertCase(value: T, newPrefix: string, upperWord?: boolean): stri */ /*#__NO_SIDE_EFFECTS__*/ export function strLetterCase(value: T): string { - return strReplace(asString(value), /(_|\b)\w/g, strUpper); + let input = asString(value); + let result = ""; + let inWord = false; + for (let idx = 0; idx < input.length;) { + let chr = _charAt(input, idx); + let word = _isWordChar(chr); + result += word && !inWord ? strUpper(chr) : chr; + inWord = word; + idx += chr.length; + } + return result; } /** * Convert the provided value to `Capitalized Words`, where each detected word starts with an * uppercase character and the remaining characters of that word are lowercased. * - * Word boundaries are detected using standard `\b` boundaries and `_` separators, so existing - * separators (spaces, punctuation, `_`, and `-`) are retained. + * Separators (spaces, punctuation, `_`, and `-`) are retained. The value is lowercased before + * detecting words, so existing camel-case boundaries are not retained. * If the value is not a string it will be converted. * @since 0.14.0 * @group String @@ -88,7 +170,10 @@ export function strCapitalizeWords(value: T): string { /** * Convert the provided value to `camelCased` string, you can optionally specify whether the * first character is upper cased (lowercase by default) from kebab `-` or snake `_` case. - * All whitespace characters are removed + * Words are lowercased before joining. Separators are removed, and camel-case boundaries + * split after a lowercase word of at least two letters or a digit, and between an acronym and a + * following capitalized word. A single lowercase letter followed by uppercase letters stays in one word. + * Unicode word characters use the same rules as {@link strLetterCase}. * If the value is not a string it will be converted. * @since 0.9.0 * @group String @@ -121,13 +206,18 @@ export function strCapitalizeWords(value: T): string { export function strCamelCase(value: T, upperFirst?: boolean): string { let result = _convertCase(value, "", true); - return strReplace(result, /^\w/, upperFirst ? strUpper : strLower); + if (upperFirst && result) { + let first = _charAt(result, 0); + result = strUpper(first) + result.substring(first.length); + } + return result; } /** * Convert the provided value to `kebab-cased` string, you can optionally specify whther the result * is all uppercased by passing `true` as the optional `scream` argument, otherwise the entire result - * will be lowercased. + * will be lowercased. Word boundaries follow {@link strCamelCase}; leading and trailing separators + * are removed. * If the value is not a string it will be converted. * @since 0.9.0 * @group String @@ -141,14 +231,14 @@ export function strCamelCase(value: T, upperFirst?: boolean): string { * strKebabCase(undefined); // "undefined" * strKebabCase("hello darkness"); // "hello-darkness" * strKebabCase("hello_darkness"); // "hello-darkness" - * strKebabCase("_hello_darkness"); // "-hello-darkness" - * strKebabCase("hello darkness, my old friend."); // "hello-darkness-my-old-friend-" + * strKebabCase("_hello_darkness"); // "hello-darkness" + * strKebabCase("hello darkness, my old friend."); // "hello-darkness-my-old-friend" * * // Add optional SCREAM flag * strKebabCase("hello darkness", true); // "HELLO-DARKNESS" * strKebabCase("hello_darkness", true); // "HELLO-DARKNESS" - * strKebabCase("_hello_darkness", true); // "-HELLO-DARKNESS" - * strKebabCase("hello darkness, my old friend.", true); // "HELLO-DARKNESS-MY-OLD-FRIEND-" + * strKebabCase("_hello_darkness", true); // "HELLO-DARKNESS" + * strKebabCase("hello darkness, my old friend.", true); // "HELLO-DARKNESS-MY-OLD-FRIEND" * ``` */ /*#__NO_SIDE_EFFECTS__*/ @@ -160,7 +250,8 @@ export function strKebabCase(value: T, scream?: boolean): string { /** * Convert the provided value to `snake_cased` string, you can optionally specify whther the result * is all uppercased by passing `true` as the optional `scream` argument, otherwise the entire result - * will be lowercased. + * will be lowercased. Word boundaries follow {@link strCamelCase}; leading and trailing separators + * are removed. * If the value is not a string it will be converted. * @since 0.9.0 * @group String @@ -174,18 +265,18 @@ export function strKebabCase(value: T, scream?: boolean): string { * strSnakeCase(undefined); // "undefined" * strSnakeCase("hello darkness"); // "hello_darkness" * strSnakeCase("hello_darkness"); // "hello_darkness" - * strSnakeCase("_hello_darkness"); // "_hello_darkness" + * strSnakeCase("_hello_darkness"); // "hello_darkness" * strSnakeCase("hello-darkness"); // "hello_darkness" - * strSnakeCase("-hello-darkness"); // "_hello_darkness" - * strSnakeCase("hello darkness, my old friend."); // "hello_darkness_my_old_friend_" + * strSnakeCase("-hello-darkness"); // "hello_darkness" + * strSnakeCase("hello darkness, my old friend."); // "hello_darkness_my_old_friend" * * // Use optional Scream flag * strSnakeCase("hello darkness", true); // "HELLO_DARKNESS" * strSnakeCase("hello_darkness", true); // "HELLO_DARKNESS" - * strSnakeCase("_hello_darkness", true); // "_HELLO_DARKNESS" + * strSnakeCase("_hello_darkness", true); // "HELLO_DARKNESS" * strSnakeCase("hello-darkness", true); // "HELLO_DARKNESS" - * strSnakeCase("-hello-darkness", true); // "_HELLO_DARKNESS" - * strSnakeCase("hello darkness, my old friend.", true); // "HELLO_DARKNESS_MY_OLD_FRIEND_" + * strSnakeCase("-hello-darkness", true); // "HELLO_DARKNESS" + * strSnakeCase("hello darkness, my old friend.", true); // "HELLO_DARKNESS_MY_OLD_FRIEND" * ``` */ /*#__NO_SIDE_EFFECTS__*/ diff --git a/lib/test/src/common/string/conversion.test.ts b/lib/test/src/common/string/conversion.test.ts index 98d537b9..a8141f23 100644 --- a/lib/test/src/common/string/conversion.test.ts +++ b/lib/test/src/common/string/conversion.test.ts @@ -25,15 +25,15 @@ describe("strLetterCase", () => { it("Reverse kebabcase Basic", () => { assert.equal(strLetterCase(strKebabCase("hello darkness")), "Hello-Darkness"); assert.equal(strLetterCase(strKebabCase("hello_darkness")), "Hello-Darkness"); - assert.equal(strLetterCase(strKebabCase("_hello_darkness")), "-Hello-Darkness"); - assert.equal(strLetterCase(strKebabCase("hello darkness, my old friend.")), "Hello-Darkness-My-Old-Friend-"); + assert.equal(strLetterCase(strKebabCase("_hello_darkness")), "Hello-Darkness"); + assert.equal(strLetterCase(strKebabCase("hello darkness, my old friend.")), "Hello-Darkness-My-Old-Friend"); }); it("Reverse snakecase Basic", () => { assert.equal(strLetterCase(strSnakeCase("hello darkness")), "Hello_Darkness"); assert.equal(strLetterCase(strSnakeCase("hello_darkness")), "Hello_Darkness"); - assert.equal(strLetterCase(strSnakeCase("_hello_darkness")), "_Hello_Darkness"); - assert.equal(strLetterCase(strSnakeCase("hello darkness, my old friend.")), "Hello_Darkness_My_Old_Friend_"); + assert.equal(strLetterCase(strSnakeCase("_hello_darkness")), "Hello_Darkness"); + assert.equal(strLetterCase(strSnakeCase("hello darkness, my old friend.")), "Hello_Darkness_My_Old_Friend"); }); it("Reverse camel case Basic", () => { @@ -62,15 +62,15 @@ describe("strCapitalizeWords", () => { it("Reverse kebabcase Basic", () => { assert.equal(strCapitalizeWords(strKebabCase("hello darkness")), "Hello-Darkness"); assert.equal(strCapitalizeWords(strKebabCase("hello_darkness")), "Hello-Darkness"); - assert.equal(strCapitalizeWords(strKebabCase("_hello_darkness")), "-Hello-Darkness"); - assert.equal(strCapitalizeWords(strKebabCase("hello darkness, my old friend.")), "Hello-Darkness-My-Old-Friend-"); + assert.equal(strCapitalizeWords(strKebabCase("_hello_darkness")), "Hello-Darkness"); + assert.equal(strCapitalizeWords(strKebabCase("hello darkness, my old friend.")), "Hello-Darkness-My-Old-Friend"); }); it("Reverse snakecase Basic", () => { assert.equal(strCapitalizeWords(strSnakeCase("hello darkness")), "Hello_Darkness"); assert.equal(strCapitalizeWords(strSnakeCase("hello_darkness")), "Hello_Darkness"); - assert.equal(strCapitalizeWords(strSnakeCase("_hello_darkness")), "_Hello_Darkness"); - assert.equal(strCapitalizeWords(strSnakeCase("hello darkness, my old friend.")), "Hello_Darkness_My_Old_Friend_"); + assert.equal(strCapitalizeWords(strSnakeCase("_hello_darkness")), "Hello_Darkness"); + assert.equal(strCapitalizeWords(strSnakeCase("hello darkness, my old friend.")), "Hello_Darkness_My_Old_Friend"); }); it("Differs from strLetterCase by normalizing word casing", () => { @@ -136,15 +136,15 @@ describe("strKebabCase", () => { it("Basic", () => { assert.equal(strKebabCase("hello darkness"), "hello-darkness"); assert.equal(strKebabCase("hello_darkness"), "hello-darkness"); - assert.equal(strKebabCase("_hello_darkness"), "-hello-darkness"); - assert.equal(strKebabCase("hello darkness, my old friend."), "hello-darkness-my-old-friend-"); + assert.equal(strKebabCase("_hello_darkness"), "hello-darkness"); + assert.equal(strKebabCase("hello darkness, my old friend."), "hello-darkness-my-old-friend"); }); it("Scream Basic", () => { assert.equal(strKebabCase("hello darkness", true), "HELLO-DARKNESS"); assert.equal(strKebabCase("hello_darkness", true), "HELLO-DARKNESS"); - assert.equal(strKebabCase("_hello_darkness", true), "-HELLO-DARKNESS"); - assert.equal(strKebabCase("hello darkness, my old friend.", true), "HELLO-DARKNESS-MY-OLD-FRIEND-"); + assert.equal(strKebabCase("_hello_darkness", true), "HELLO-DARKNESS"); + assert.equal(strKebabCase("hello darkness, my old friend.", true), "HELLO-DARKNESS-MY-OLD-FRIEND"); }); it("Reverse Camelcase Basic", () => { @@ -157,8 +157,8 @@ describe("strKebabCase", () => { it("Reverse Snakecase Basic", () => { assert.equal(strKebabCase(strSnakeCase("hello darkness")), "hello-darkness"); assert.equal(strKebabCase(strSnakeCase("hello_darkness")), "hello-darkness"); - assert.equal(strKebabCase(strSnakeCase("_hello_darkness")), "-hello-darkness"); - assert.equal(strKebabCase(strSnakeCase("hello darkness, my old friend.")), "hello-darkness-my-old-friend-"); + assert.equal(strKebabCase(strSnakeCase("_hello_darkness")), "hello-darkness"); + assert.equal(strKebabCase(strSnakeCase("hello darkness, my old friend.")), "hello-darkness-my-old-friend"); }); }); @@ -168,22 +168,81 @@ describe("strSnakeCase", () => { assert.equal(strSnakeCase(undefined), "undefined"); }); + describe("case conversion word boundaries", () => { + it("normalizes mixed case and all-uppercase words", () => { + assert.equal(strCamelCase("hELLo dARKness-my old_friend"), "helloDarknessMyOldFriend"); + assert.equal(strKebabCase("hELLo dARKness-my old_friend"), "hello-darkness-my-old-friend"); + assert.equal(strSnakeCase("hELLo dARKness-my old_friend"), "hello_darkness_my_old_friend"); + assert.equal(strCamelCase("ABC def"), "abcDef"); + assert.equal(strCamelCase("HELLO WORLD"), "helloWorld"); + assert.equal(strCamelCase("EFTPOS PURCHASE 1234"), "eftposPurchase1234"); + assert.equal(strCamelCase("HELLO WORLD", true), "HelloWorld"); + }); + + it("recognizes camel case, acronyms and digit-to-uppercase boundaries consistently", () => { + assert.equal(strKebabCase("ABCDef"), "abc-def"); + assert.equal(strKebabCase("XMLHttpRequest"), "xml-http-request"); + assert.equal(strSnakeCase("XMLHttpRequest"), "xml_http_request"); + assert.equal(strCamelCase("XMLHttpRequest"), "xmlHttpRequest"); + assert.equal(strKebabCase("fooBar2Baz"), "foo-bar2-baz"); + assert.equal(strSnakeCase("fooBar2Baz", true), "FOO_BAR2_BAZ"); + assert.equal(strCamelCase("fooBar2Baz"), "fooBar2Baz"); + assert.equal(strKebabCase("aBC"), "abc"); + }); + + it("converts each word at separator and internal boundaries", () => { + assert.equal(strCamelCase("--XMLHttpRequest__ΟΣ TEST--"), "xmlHttpRequestΟςTest"); + assert.equal(strCamelCase("--XMLHttpRequest__ΟΣ TEST--", true), "XmlHttpRequestΟςTest"); + assert.equal(strKebabCase("--XMLHttpRequest__ΟΣ TEST--"), "xml-http-request-ος-test"); + assert.equal(strSnakeCase("--XMLHttpRequest__ΟΣ TEST--", true), "XML_HTTP_REQUEST_ΟΣ_TEST"); + assert.equal(strCamelCase("ΟΣ"), "ος"); + }); + + it("preserves Unicode letters, numbers and combining marks", () => { + assert.equal(strCamelCase("café crème"), "caféCrème"); + assert.equal(strKebabCase("café crème"), "café-crème"); + assert.equal(strSnakeCase("über straße"), "über_straße"); + assert.equal(strCamelCase("über straße"), "überStraße"); + assert.equal(strCamelCase("über straße", true), "ÜberStraße"); + assert.equal(strCapitalizeWords("café crème"), "Café Crème"); + assert.equal(strLetterCase("café crème"), "Café Crème"); + assert.equal(strCamelCase("cafe\u0301 crème"), "cafe\u0301Crème"); + assert.equal(strKebabCase("cafe\u0301Crème"), "cafe\u0301-crème"); + assert.equal(strKebabCase("foo١Bar"), "foo١-bar"); + assert.equal(strKebabCase("東京・大阪"), "東京-大阪"); + assert.equal(strKebabCase("hello😀world"), "hello-world"); + assert.equal(strCamelCase("𐐀𐐨 test"), "𐐨𐐨Test"); + }); + + it("removes edge separators while preserving capitalization separators", () => { + assert.equal(strKebabCase("__init__"), "init"); + assert.equal(strKebabCase("--foo--"), "foo"); + assert.equal(strSnakeCase("O'Brien & Sons, Ltd."), "o_brien_sons_ltd"); + assert.equal(strCamelCase("O'Brien & Sons, Ltd."), "oBrienSonsLtd"); + assert.equal(strKebabCase("---"), ""); + assert.equal(strSnakeCase("___", true), ""); + assert.equal(strCamelCase(""), ""); + assert.equal(strCapitalizeWords("fooBarBaz"), "Foobarbaz"); + assert.equal(strCapitalizeWords("foo_bar-baz qux"), "Foo_Bar-Baz Qux"); + }); + }); + it("Basic", () => { assert.equal(strSnakeCase("hello darkness"), "hello_darkness"); assert.equal(strSnakeCase("hello_darkness"), "hello_darkness"); - assert.equal(strSnakeCase("_hello_darkness"), "_hello_darkness"); + assert.equal(strSnakeCase("_hello_darkness"), "hello_darkness"); assert.equal(strSnakeCase("hello-darkness"), "hello_darkness"); - assert.equal(strSnakeCase("-hello-darkness"), "_hello_darkness"); - assert.equal(strSnakeCase("hello darkness, my old friend."), "hello_darkness_my_old_friend_"); + assert.equal(strSnakeCase("-hello-darkness"), "hello_darkness"); + assert.equal(strSnakeCase("hello darkness, my old friend."), "hello_darkness_my_old_friend"); }); it("Scream Basic", () => { assert.equal(strSnakeCase("hello darkness", true), "HELLO_DARKNESS"); assert.equal(strSnakeCase("hello_darkness", true), "HELLO_DARKNESS"); - assert.equal(strSnakeCase("_hello_darkness", true), "_HELLO_DARKNESS"); + assert.equal(strSnakeCase("_hello_darkness", true), "HELLO_DARKNESS"); assert.equal(strSnakeCase("hello-darkness", true), "HELLO_DARKNESS"); - assert.equal(strSnakeCase("-hello-darkness", true), "_HELLO_DARKNESS"); - assert.equal(strSnakeCase("hello darkness, my old friend.", true), "HELLO_DARKNESS_MY_OLD_FRIEND_"); + assert.equal(strSnakeCase("-hello-darkness", true), "HELLO_DARKNESS"); + assert.equal(strSnakeCase("hello darkness, my old friend.", true), "HELLO_DARKNESS_MY_OLD_FRIEND"); }); it("reverse camelcase Basic", () => { @@ -198,9 +257,9 @@ describe("strSnakeCase", () => { it("reverse kebab Basic", () => { assert.equal(strSnakeCase(strKebabCase("hello darkness")), "hello_darkness"); assert.equal(strSnakeCase(strKebabCase("hello_darkness")), "hello_darkness"); - assert.equal(strSnakeCase(strKebabCase("_hello_darkness")), "_hello_darkness"); + assert.equal(strSnakeCase(strKebabCase("_hello_darkness")), "hello_darkness"); assert.equal(strSnakeCase(strKebabCase("hello-darkness")), "hello_darkness"); - assert.equal(strSnakeCase(strKebabCase("-hello-darkness")), "_hello_darkness"); - assert.equal(strSnakeCase(strKebabCase("hello darkness, my old friend.")), "hello_darkness_my_old_friend_"); + assert.equal(strSnakeCase(strKebabCase("-hello-darkness")), "hello_darkness"); + assert.equal(strSnakeCase(strKebabCase("hello darkness, my old friend.")), "hello_darkness_my_old_friend"); }); });