diff --git a/.changeset/dull-meals-post.md b/.changeset/dull-meals-post.md new file mode 100644 index 0000000..1a97070 --- /dev/null +++ b/.changeset/dull-meals-post.md @@ -0,0 +1,5 @@ +--- +'@layerstack/utils': patch +--- + +feat: Add string utils `toCamelCase()`, `toSnakeCase()`, `toKebabCase()`, and `toPascalCase()` diff --git a/.changeset/four-signs-smell.md b/.changeset/four-signs-smell.md new file mode 100644 index 0000000..efa6850 --- /dev/null +++ b/.changeset/four-signs-smell.md @@ -0,0 +1,5 @@ +--- +'@layerstack/tailwind': patch +--- + +fix: Change `culori` dep to devDependencies (only used for generation and not runtime) diff --git a/.changeset/lazy-bottles-rule.md b/.changeset/lazy-bottles-rule.md new file mode 100644 index 0000000..4cfb615 --- /dev/null +++ b/.changeset/lazy-bottles-rule.md @@ -0,0 +1,5 @@ +--- +'@layerstack/tailwind': patch +--- + +fix: Change `tailwindcss` dep to devDependecies diff --git a/packages/svelte-stores/src/lib/localStore.ts b/packages/svelte-stores/src/lib/localStore.ts index 47da353..96f0986 100644 --- a/packages/svelte-stores/src/lib/localStore.ts +++ b/packages/svelte-stores/src/lib/localStore.ts @@ -1,5 +1,4 @@ import { writable } from 'svelte/store'; -import { isFunction } from 'lodash-es'; import { parse, stringify } from '@layerstack/utils'; import { browser } from '@layerstack/utils/env'; @@ -42,10 +41,11 @@ function localStore(key: string, initialValue: Value, options?: LocalStor ? expireObject(previousExpiry, previousExpiry) : previousExpiry; - const expiry = isFunction(options?.expiry) - ? // @ts-expect-error - options?.expiry(prunedPreviousExpiry) // Update expiry on write - : options?.expiry; + const expiry = + typeof options?.expiry === 'function' + ? // @ts-expect-error + options?.expiry(prunedPreviousExpiry) // Update expiry on write + : options?.expiry; previousExpiry = expiry; localStorage.setItem(key, stringify({ value: val, expiry })); diff --git a/packages/svelte-table/src/lib/stores.ts b/packages/svelte-table/src/lib/stores.ts index 3761993..169613d 100644 --- a/packages/svelte-table/src/lib/stores.ts +++ b/packages/svelte-table/src/lib/stores.ts @@ -1,6 +1,5 @@ import { writable } from 'svelte/store'; import type { ComponentEvents } from 'svelte'; -import { isFunction } from 'lodash-es'; import { index } from 'd3-array'; import { sortFunc } from '@layerstack/utils'; @@ -78,7 +77,7 @@ function createState(column: ColumnDef, props?: TableOrderProps, prevState?: Tab : 'asc'; let handler: SortFunc | undefined = undefined; - if (isFunction(column.orderBy)) { + if (typeof column.orderBy === 'function') { handler = column.orderBy; } else if (typeof column.orderBy === 'string') { handler = sortFunc(column.orderBy, direction); diff --git a/packages/svelte-table/src/lib/utils.ts b/packages/svelte-table/src/lib/utils.ts index 3739c6a..b158697 100644 --- a/packages/svelte-table/src/lib/utils.ts +++ b/packages/svelte-table/src/lib/utils.ts @@ -1,4 +1,4 @@ -import { isFunction, get } from 'lodash-es'; +import { get } from 'lodash-es'; import { PeriodType, parseDate } from '@layerstack/utils'; @@ -99,7 +99,7 @@ export function getCellHeader(column: ColumnDef) { export function getCellValue(column: ColumnDef, rowData: any, rowIndex?: number) { let value = undefined; - if (isFunction(column.value)) { + if (typeof column.value === 'function') { value = column.value?.(rowData, rowIndex); } @@ -109,7 +109,7 @@ export function getCellValue(column: ColumnDef, rowData: any, rowIndex?: number) if ( typeof value === 'string' && - !isFunction(column.format) && + typeof column.format !== 'function' && (column.format ?? 'none') in PeriodType ) { // Convert date string to Date instance diff --git a/packages/tailwind/package.json b/packages/tailwind/package.json index 5fd0097..072da6f 100644 --- a/packages/tailwind/package.json +++ b/packages/tailwind/package.json @@ -25,10 +25,12 @@ "@types/d3-array": "^3.2.1", "@types/lodash-es": "^4.17.12", "daisyui": "^4.12.24", + "culori": "^4.0.1", "prettier": "^3.5.3", "rimraf": "6.0.1", "tslib": "^2.8.1", "tsx": "^4.19.4", + "tailwindcss": "^4.1.5", "typescript": "^5.8.3", "vite": "^6.3.5", "vitest": "^3.1.3" @@ -37,11 +39,9 @@ "dependencies": { "@layerstack/utils": "workspace:^", "clsx": "^2.1.1", - "culori": "^4.0.1", "d3-array": "^3.2.4", "lodash-es": "^4.17.21", - "tailwind-merge": "^3.2.0", - "tailwindcss": "^4.1.5" + "tailwind-merge": "^3.2.0" }, "main": "./dist/index.js", "exports": { diff --git a/packages/utils/src/lib/object.ts b/packages/utils/src/lib/object.ts index 0668db9..6df031f 100644 --- a/packages/utils/src/lib/object.ts +++ b/packages/utils/src/lib/object.ts @@ -1,5 +1,6 @@ -import { get, camelCase, mergeWith } from 'lodash-es'; +import { get, mergeWith } from 'lodash-es'; import { entries, fromEntries, keys } from './typeHelpers.js'; +import { toCamelCase } from './string.js'; export function isLiteralObject(obj: any): obj is object { return obj && typeof obj === 'object' && obj.constructor === Object; @@ -11,7 +12,7 @@ export function isEmptyObject(obj: any) { export function camelCaseKeys(obj: any) { return keys(obj).reduce( - (acc, key) => ((acc[camelCase(key ? String(key) : undefined)] = obj[key]), acc), + (acc, key) => ((acc[toCamelCase(key ? String(key) : '')] = obj[key]), acc), {} as any ); } diff --git a/packages/utils/src/lib/rollup.ts b/packages/utils/src/lib/rollup.ts index c27f275..b35eeeb 100644 --- a/packages/utils/src/lib/rollup.ts +++ b/packages/utils/src/lib/rollup.ts @@ -1,5 +1,5 @@ import { rollup } from 'd3-array'; -import { get, isFunction } from 'lodash-es'; +import { get } from 'lodash-es'; export default function ( data: T[], @@ -13,7 +13,7 @@ export default function ( // } const keyFuncs = keys.map((key) => { - if (isFunction(key)) { + if (typeof key === 'function') { return key; } else if (typeof key === 'string') { return (d: any) => get(d, key) || emptyKey; diff --git a/packages/utils/src/lib/string.test.ts b/packages/utils/src/lib/string.test.ts index 24f4267..31b5667 100644 --- a/packages/utils/src/lib/string.test.ts +++ b/packages/utils/src/lib/string.test.ts @@ -1,17 +1,149 @@ -import { describe, it, expect } from 'vitest'; +import { describe, test, expect } from 'vitest'; -import { toTitleCase } from './string.js'; +import { + isUpperCase, + romanize, + toCamelCase, + toKebabCase, + toPascalCase, + toSnakeCase, + toTitleCase, + truncate, +} from './string.js'; + +describe('isUpperCase()', () => { + test.each([ + ['A', true], + ['a', false], + ['THE QUICK BROWN FOX', true], + ['the quick brown fox', false], + ['The Quick Brown Fox', false], + ['The quick brown fox', false], + ])('isUpperCase(%s) => %s', (original, expected) => { + expect(isUpperCase(original)).equal(expected); + }); +}); describe('toTitleCase()', () => { - it('basic', () => { - const original = 'this is a test'; - const expected = 'This is a Test'; + test.each([ + ['A long time ago', 'A Long Time Ago'], // sentence + ['the quick brown fox', 'The Quick Brown Fox'], // lower case + ['THE QUICK BROWN FOX', 'The Quick Brown Fox'], // upper case + ['the_quick_brown_fox', 'The Quick Brown Fox'], // snake case + ['the-quick-brown-fox', 'The Quick Brown Fox'], // kebab case + ['theQuickBrownFox', 'The Quick Brown Fox'], // pascal case + ['the - quick * brown# fox', 'The Quick Brown Fox'], // punctuation + ])('toTitleCase(%s) => %s', (original, expected) => { expect(toTitleCase(original)).equal(expected); }); +}); - it('basic', () => { - const original = 'A long time ago'; - const expected = 'A Long Time Ago'; - expect(toTitleCase(original)).equal(expected); +describe('toCamelCase()', () => { + test.each([ + ['the quick brown fox', 'theQuickBrownFox'], // lower case + ['the_quick_brown_fox', 'theQuickBrownFox'], // snake case + ['the-quick-brown-fox', 'theQuickBrownFox'], // kebab case + ['THE-QUICK-BROWN-FOX', 'theQuickBrownFox'], // snake case (all caps) + ['theQuickBrownFox', 'theQuickBrownFox'], // pascal case + ['thequickbrownfox', 'thequickbrownfox'], // lowercase + ['the - quick * brown# fox', 'theQuickBrownFox'], // punctuation + ['behold theQuickBrownFox', 'beholdTheQuickBrownFox'], + ['Behold theQuickBrownFox', 'beholdTheQuickBrownFox'], + ['The quick brown FOX', 'theQuickBrownFox'], // all caps words are camel-cased + ['theQUickBrownFox', 'theQUickBrownFox'], // all caps substrings >= 4 chars are camel-cased + ['theQUIckBrownFox', 'theQUIckBrownFox'], + ])('toCamelCase(%s) => %s', (original, expected) => { + expect(toCamelCase(original)).equal(expected); + }); +}); + +describe('toSnakeCase()', () => { + test.each([ + ['the quick brown fox', 'the_quick_brown_fox'], // lower case + ['the-quick-brown-fox', 'the_quick_brown_fox'], // kebab case + ['the_quick_brown_fox', 'the_quick_brown_fox'], // snake case + ['theQuickBrownFox', 'the_quick_brown_fox'], // pascal case + ['theQuickBrown Fox', 'the_quick_brown_fox'], // space separated words + ['thequickbrownfox', 'thequickbrownfox'], // no spaces + ['the - quick * brown# fox', 'the_quick_brown_fox'], // punctuation + ['theQUICKBrownFox', 'the_q_u_i_c_k_brown_fox'], // all caps words are snake-cased + ])('toSnakeCase(%s) => %s', (original, expected) => { + expect(toSnakeCase(original)).equal(expected); + }); +}); + +describe('toKebabCase()', () => { + test.each([ + ['the quick brown fox', 'the-quick-brown-fox'], // lower case + ['the-quick-brown-fox', 'the-quick-brown-fox'], // kebab case + ['the_quick_brown_fox', 'the-quick-brown-fox'], // snake case + ['theQuickBrownFox', 'the-quick-brown-fox'], // pascal case + ['theQuickBrown Fox', 'the-quick-brown-fox'], // space separated words + ['thequickbrownfox', 'thequickbrownfox'], // no spaces + ['the - quick * brown# fox', 'the-quick-brown-fox'], // punctuation + ['theQUICKBrownFox', 'the-q-u-i-c-k-brown-fox'], // all caps words are snake-cased + ])('toKebabCase(%s) => %s', (original, expected) => { + expect(toKebabCase(original)).equal(expected); + }); +}); + +describe('toPascalCase()', () => { + test.each([ + ['the quick brown fox', 'TheQuickBrownFox'], // lower case + ['the_quick_brown_fox', 'TheQuickBrownFox'], // snake case + ['the-quick-brown-fox', 'TheQuickBrownFox'], // kebab case + ['theQuickBrownFox', 'TheQuickBrownFox'], // pascal case + ['thequickbrownfox', 'Thequickbrownfox'], // lowercase + ['the - quick * brown# fox', 'TheQuickBrownFox'], // punctuation + ['theQUICKBrownFox', 'TheQUICKBrownFox'], // all caps words are pascal-cased + ])('toPascalCase(%s) => %s', (original, expected) => { + expect(toPascalCase(original)).equal(expected); + }); +}); + +describe('romanize()', () => { + test.each([ + [1, 'I'], + [2, 'II'], + [3, 'III'], + [4, 'IV'], + [5, 'V'], + [6, 'VI'], + [7, 'VII'], + [8, 'VIII'], + [9, 'IX'], + [10, 'X'], + [11, 'XI'], + [12, 'XII'], + [13, 'XIII'], + [14, 'XIV'], + [15, 'XV'], + [16, 'XVI'], + [17, 'XVII'], + [18, 'XVIII'], + [19, 'XIX'], + [20, 'XX'], + [40, 'XL'], + [49, 'XLIX'], + [50, 'L'], + [90, 'XC'], + [100, 'C'], + [400, 'CD'], + [500, 'D'], + [900, 'CM'], + [1000, 'M'], + ])('romanize(%s) => %s', (original, expected) => { + expect(romanize(original)).equal(expected); + }); +}); + +describe('truncate()', () => { + test.each([ + ['the quick brown fox', 9, undefined, 'the quick…'], + ['the quick brown fox', 15, undefined, 'the quick brown…'], + ['the quick brown fox', 15, 3, 'the quick br…fox'], + ['the quick brown fox', 9, Infinity, '…brown fox'], + ])('truncate(%s, %s) => %s', (original, totalChars, endChars, expected) => { + expect(truncate(original, totalChars, endChars)).equal(expected); }); }); diff --git a/packages/utils/src/lib/string.ts b/packages/utils/src/lib/string.ts index 303e6f7..a44dabf 100644 --- a/packages/utils/src/lib/string.ts +++ b/packages/utils/src/lib/string.ts @@ -1,53 +1,80 @@ import { entries } from './typeHelpers.js'; +// any combination of spaces and punctuation characters - http://stackoverflow.com/a/25575009 +const wordSeparatorsRegEx = /[\s\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,\-.\/:;<=>?@\[\]^_`{|}~]+/; + +const uppercaseChars = '[A-Z\u00C0-\u00DC\u00D9-\u00DD]'; // includes accented characters +const capitalsRegEx = new RegExp(uppercaseChars, 'g'); +const allCapitalsRegEx = new RegExp(`^${uppercaseChars}+$`); + +const camelCaseRegEx = /^[a-z\u00E0-\u00FCA-Z\u00C0-\u00DC][\d|a-z\u00E0-\u00FCA-Z\u00C0-\u00DC]*$/; + /** * Check if str only contians upper case letters */ export function isUpperCase(str: string) { - return /^[A-Z]*$/.test(str); + return /^[A-Z ]*$/.test(str); } /** * Returns string with the first letter of each word converted to uppercase (and remainder as lowercase) */ export function toTitleCase(str: string, ignore = ['a', 'an', 'is', 'the']) { - return str - .toLowerCase() - .split(' ') + const withSpaces = isUpperCase(str) ? str : str.replace(/([A-Z])/g, ' $1').trim(); + return withSpaces + .split(wordSeparatorsRegEx) .map((word, index) => { - if (index > 0 && ignore.includes(word)) { + if (index !== 0 && ignore.includes(word)) { return word; } else { - return word.charAt(0).toUpperCase() + word.slice(1); + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); } }) .join(' '); } -/** - * Generates a unique Id, with prefix if provided - */ -const idMap = new Map(); -export function uniqueId(prefix = '') { - let id = (idMap.get(prefix) ?? 0) + 1; - idMap.set(prefix, id); - return prefix + id; +/** Convert string to camel case */ +export function toCamelCase(str: string) { + const words = str.split(wordSeparatorsRegEx); + return words + .map((word, i) => { + if (word === '') { + return ''; + } + const isCamelCase = camelCaseRegEx.test(word) && !allCapitalsRegEx.test(word); + let firstLetter = word[0]; + firstLetter = i > 0 ? firstLetter.toUpperCase() : firstLetter.toLowerCase(); + return firstLetter + (!isCamelCase ? word.slice(1).toLowerCase() : word.slice(1)); + }) + .join(''); } -/** - * Truncate text with option to keep a number of characters on end. Inserts ellipsis between parts - */ -export function truncate(text: string, totalChars: number, endChars: number = 0) { - endChars = Math.min(endChars, totalChars); +/** Convert string to snake case */ +export function toSnakeCase(str: string) { + // Replace capitals with space + lower case equivalent for later parsing + return str + .replace(capitalsRegEx, (match) => ' ' + (match.toLowerCase() || match)) + .split(wordSeparatorsRegEx) + .join('_'); +} - const start = text.slice(0, totalChars - endChars); - const end = endChars > 0 ? text.slice(-endChars) : ''; +/** Convert string to kebab case */ +export function toKebabCase(str: string) { + return str + .replace(capitalsRegEx, (match) => '-' + (match.toLowerCase() || match)) + .split(wordSeparatorsRegEx) + .join('-'); +} - if (start.length + end.length < text.length) { - return start + '…' + end; - } else { - return text; - } +/** Convert string to pascal case */ +export function toPascalCase(str: string) { + return ( + str + .split(wordSeparatorsRegEx) + .map((word) => word[0].toUpperCase() + word.slice(1)) + // .map((word) => toTitleCase(word, [])) + .join('') + ); } /** Get the roman numeral for the given value */ @@ -79,3 +106,29 @@ export function romanize(value: number) { return result; } + +/** + * Truncate text with option to keep a number of characters on end. Inserts ellipsis between parts + */ +export function truncate(text: string, totalChars: number, endChars: number = 0) { + endChars = Math.min(endChars, totalChars); + + const start = text.slice(0, totalChars - endChars); + const end = endChars > 0 ? text.slice(-endChars) : ''; + + if (start.length + end.length < text.length) { + return start + '…' + end; + } else { + return text; + } +} + +/** + * Generates a unique Id, with prefix if provided + */ +const idMap = new Map(); +export function uniqueId(prefix = '') { + let id = (idMap.get(prefix) ?? 0) + 1; + idMap.set(prefix, id); + return prefix + id; +} diff --git a/packages/utils/src/lib/styles.ts b/packages/utils/src/lib/styles.ts index 205d289..ef2e4d4 100644 --- a/packages/utils/src/lib/styles.ts +++ b/packages/utils/src/lib/styles.ts @@ -1,4 +1,5 @@ import { entries } from './typeHelpers.js'; +import { toKebabCase } from './string.js'; /** * Convert object to style string @@ -8,7 +9,7 @@ export function objectToString(styleObj: { [key: string]: string }) { .map(([key, value]) => { if (value) { // Convert camelCase into kaboob-case (ex. (transformOrigin => transform-origin)) - const propertyName = key.replace(/([A-Z])/g, '-$1').toLowerCase(); + const propertyName = toKebabCase(key); return `${propertyName}: ${value};`; } else { return null; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1968cee..9213092 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -243,9 +243,6 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 - culori: - specifier: ^4.0.1 - version: 4.0.1 d3-array: specifier: ^3.2.4 version: 3.2.4 @@ -255,9 +252,6 @@ importers: tailwind-merge: specifier: ^3.2.0 version: 3.3.0 - tailwindcss: - specifier: ^4.1.5 - version: 4.1.6 devDependencies: '@skeletonlabs/tw-plugin': specifier: ^0.4.1 @@ -274,6 +268,9 @@ importers: '@types/lodash-es': specifier: ^4.17.12 version: 4.17.12 + culori: + specifier: ^4.0.1 + version: 4.0.1 daisyui: specifier: ^4.12.24 version: 4.12.24(postcss@8.5.3) @@ -283,6 +280,9 @@ importers: rimraf: specifier: 6.0.1 version: 6.0.1 + tailwindcss: + specifier: ^4.1.5 + version: 4.1.6 tslib: specifier: ^2.8.1 version: 2.8.1