From e103afb02dc3c814cb95f37a85bc5117cc8458a1 Mon Sep 17 00:00:00 2001 From: Aki Hamano <54422211+t-hamano@users.noreply.github.com> Date: Thu, 14 Dec 2023 10:29:52 +0900 Subject: [PATCH] Components: Move kebabCase() function from block-editor package and mark it as private API (#56758) * Components: Move kebabCase() function from block-editor package and mark it as private API * Update changelog * Fix native app test * Try to fix native app test * Try to fix mobile app test * Try importing kebabCase directly from components * Fix another import * Move changelog entry to Internal section * Change the argument type of kebabCase function to unknown * Fix lint error * Merge duplicate Internal sections * Remove type info in the JSDoc comment --------- Co-authored-by: Marin Atanasov --- .../src/components/colors/utils.js | 9 +- .../src/components/colors/with-colors.js | 4 +- .../src/components/font-sizes/utils.js | 8 +- .../global-styles/use-global-styles-output.js | 14 ++- .../block-editor/src/hooks/font-family.js | 4 +- packages/block-editor/src/hooks/layout.js | 5 +- .../src/hooks/use-typography-props.js | 8 +- packages/block-editor/src/private-apis.js | 2 - .../block-editor/src/private-apis.native.js | 2 - packages/block-editor/src/utils/object.js | 35 ------- .../block-editor/src/utils/test/object.js | 97 +------------------ packages/block-library/src/embed/util.js | 4 +- packages/components/CHANGELOG.md | 1 + packages/components/src/index.native.js | 3 + packages/components/src/lock-unlock.js | 10 ++ .../components/src/private-apis.native.js | 13 +++ packages/components/src/private-apis.ts | 9 +- packages/components/src/utils/strings.ts | 32 +++++- packages/components/src/utils/test/strings.js | 97 ++++++++++++++++++- .../collection-font-variant.js | 9 +- .../library-font-variant.js | 9 +- 21 files changed, 214 insertions(+), 161 deletions(-) create mode 100644 packages/components/src/lock-unlock.js create mode 100644 packages/components/src/private-apis.native.js diff --git a/packages/block-editor/src/components/colors/utils.js b/packages/block-editor/src/components/colors/utils.js index 1c1947bfc947c..d6d51ad001363 100644 --- a/packages/block-editor/src/components/colors/utils.js +++ b/packages/block-editor/src/components/colors/utils.js @@ -5,10 +5,15 @@ import { colord, extend } from 'colord'; import namesPlugin from 'colord/plugins/names'; import a11yPlugin from 'colord/plugins/a11y'; +/** + * WordPress dependencies + */ +import { privateApis as componentsPrivateApis } from '@wordpress/components'; + /** * Internal dependencies */ -import { kebabCase } from '../../utils/object'; +import { unlock } from '../../lock-unlock'; extend( [ namesPlugin, a11yPlugin ] ); @@ -70,6 +75,8 @@ export function getColorClassName( colorContextName, colorSlug ) { return undefined; } + const { kebabCase } = unlock( componentsPrivateApis ); + return `has-${ kebabCase( colorSlug ) }-${ colorContextName }`; } diff --git a/packages/block-editor/src/components/colors/with-colors.js b/packages/block-editor/src/components/colors/with-colors.js index 5946ca90d8bbd..33079f8b409d6 100644 --- a/packages/block-editor/src/components/colors/with-colors.js +++ b/packages/block-editor/src/components/colors/with-colors.js @@ -3,6 +3,7 @@ */ import { useMemo, Component } from '@wordpress/element'; import { compose, createHigherOrderComponent } from '@wordpress/compose'; +import { privateApis as componentsPrivateApis } from '@wordpress/components'; /** * Internal dependencies @@ -14,7 +15,7 @@ import { getMostReadableColor, } from './utils'; import { useSettings } from '../use-settings'; -import { kebabCase } from '../../utils/object'; +import { unlock } from '../../lock-unlock'; /** * Capitalizes the first letter in a string. @@ -79,6 +80,7 @@ const withEditorColorPalette = () => * @return {Component} The component that can be used as a HOC. */ function createColorHOC( colorTypes, withColorPalette ) { + const { kebabCase } = unlock( componentsPrivateApis ); const colorMap = colorTypes.reduce( ( colorObject, colorType ) => { return { ...colorObject, diff --git a/packages/block-editor/src/components/font-sizes/utils.js b/packages/block-editor/src/components/font-sizes/utils.js index 2f874f6665f8f..dff28c7a770d4 100644 --- a/packages/block-editor/src/components/font-sizes/utils.js +++ b/packages/block-editor/src/components/font-sizes/utils.js @@ -1,7 +1,12 @@ +/** + * WordPress dependencies + */ +import { privateApis as componentsPrivateApis } from '@wordpress/components'; + /** * Internal dependencies */ -import { kebabCase } from '../../utils/object'; +import { unlock } from '../../lock-unlock'; /** * Returns the font size object based on an array of named font sizes and the namedFontSize and customFontSize values. @@ -64,5 +69,6 @@ export function getFontSizeClass( fontSizeSlug ) { return; } + const { kebabCase } = unlock( componentsPrivateApis ); return `has-${ kebabCase( fontSizeSlug ) }-font-size`; } diff --git a/packages/block-editor/src/components/global-styles/use-global-styles-output.js b/packages/block-editor/src/components/global-styles/use-global-styles-output.js index 7e99eca355b52..1cd63ef4d03f0 100644 --- a/packages/block-editor/src/components/global-styles/use-global-styles-output.js +++ b/packages/block-editor/src/components/global-styles/use-global-styles-output.js @@ -11,6 +11,7 @@ import { import { useSelect } from '@wordpress/data'; import { useContext, useMemo } from '@wordpress/element'; import { getCSSRules } from '@wordpress/style-engine'; +import { privateApis as componentsPrivateApis } from '@wordpress/components'; /** * Internal dependencies @@ -32,12 +33,9 @@ import { getDuotoneFilter } from '../duotone/utils'; import { getGapCSSValue } from '../../hooks/gap'; import { store as blockEditorStore } from '../../store'; import { LAYOUT_DEFINITIONS } from '../../layouts/definitions'; -import { - getValueFromObjectPath, - kebabCase, - setImmutably, -} from '../../utils/object'; +import { getValueFromObjectPath, setImmutably } from '../../utils/object'; import BlockContext from '../block-context'; +import { unlock } from '../../lock-unlock'; // List of block support features that can have their related styles // generated under their own feature level selector rather than the block's. @@ -72,6 +70,8 @@ function compileStyleValue( uncompiledValue ) { * @return {Array} An array of style declarations. */ function getPresetsDeclarations( blockPresets = {}, mergedSettings ) { + const { kebabCase } = unlock( componentsPrivateApis ); + return PRESET_METADATA.reduce( ( declarations, { path, valueKey, valueFunc, cssVarInfix } ) => { const presetByOrigin = getValueFromObjectPath( @@ -116,6 +116,8 @@ function getPresetsDeclarations( blockPresets = {}, mergedSettings ) { * @return {string} CSS declarations for the preset classes. */ function getPresetsClasses( blockSelector = '*', blockPresets = {} ) { + const { kebabCase } = unlock( componentsPrivateApis ); + return PRESET_METADATA.reduce( ( declarations, { path, cssVarInfix, classes } ) => { if ( ! classes ) { @@ -180,6 +182,7 @@ function getPresetsSvgFilters( blockPresets = {} ) { } function flattenTree( input = {}, prefix, token ) { + const { kebabCase } = unlock( componentsPrivateApis ); let result = []; Object.keys( input ).forEach( ( key ) => { const newKey = prefix + kebabCase( key.replace( '/', '-' ) ); @@ -321,6 +324,7 @@ export function getStylesDeclarations( tree = {}, isTemplate = true ) { + const { kebabCase } = unlock( componentsPrivateApis ); const isRoot = ROOT_BLOCK_SELECTOR === selector; const output = Object.entries( STYLE_PROPERTY ).reduce( ( diff --git a/packages/block-editor/src/hooks/font-family.js b/packages/block-editor/src/hooks/font-family.js index 36266d59adcf2..ae41b7fa34b1f 100644 --- a/packages/block-editor/src/hooks/font-family.js +++ b/packages/block-editor/src/hooks/font-family.js @@ -4,13 +4,14 @@ import { addFilter } from '@wordpress/hooks'; import { hasBlockSupport } from '@wordpress/blocks'; import TokenList from '@wordpress/token-list'; +import { privateApis as componentsPrivateApis } from '@wordpress/components'; /** * Internal dependencies */ import { shouldSkipSerialization } from './utils'; import { TYPOGRAPHY_SUPPORT_KEY } from './typography'; -import { kebabCase } from '../utils/object'; +import { unlock } from '../lock-unlock'; export const FONT_FAMILY_SUPPORT_KEY = 'typography.__experimentalFontFamily'; @@ -67,6 +68,7 @@ function addSaveProps( props, blockType, attributes ) { // Use TokenList to dedupe classes. const classes = new TokenList( props.className ); + const { kebabCase } = unlock( componentsPrivateApis ); classes.add( `has-${ kebabCase( attributes?.fontFamily ) }-font-family` ); const newClassName = classes.value; props.className = newClassName ? newClassName : undefined; diff --git a/packages/block-editor/src/hooks/layout.js b/packages/block-editor/src/hooks/layout.js index 18bb46a87a1b8..54824558cb703 100644 --- a/packages/block-editor/src/hooks/layout.js +++ b/packages/block-editor/src/hooks/layout.js @@ -15,6 +15,7 @@ import { ButtonGroup, ToggleControl, PanelBody, + privateApis as componentsPrivateApis, } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; @@ -27,8 +28,8 @@ import { useSettings } from '../components/use-settings'; import { getLayoutType, getLayoutTypes } from '../layouts'; import { useBlockEditingMode } from '../components/block-editing-mode'; import { LAYOUT_DEFINITIONS } from '../layouts/definitions'; -import { kebabCase } from '../utils/object'; import { useBlockSettings, useStyleOverride } from './utils'; +import { unlock } from '../lock-unlock'; const layoutBlockSupportKey = 'layout'; @@ -48,6 +49,7 @@ function hasLayoutBlockSupport( blockName ) { * @return { Array } Array of CSS classname strings. */ export function useLayoutClasses( blockAttributes = {}, blockName = '' ) { + const { kebabCase } = unlock( componentsPrivateApis ); const rootPaddingAlignment = useSelect( ( select ) => { const { getSettings } = select( blockEditorStore ); return getSettings().__experimentalFeatures @@ -348,6 +350,7 @@ function BlockWithLayoutStyles( { block: BlockListBlock, props } ) { : layout || defaultBlockLayout || {}; const layoutClasses = useLayoutClasses( attributes, name ); + const { kebabCase } = unlock( componentsPrivateApis ); const selectorPrefix = `wp-container-${ kebabCase( name ) }-layout-`; // Higher specificity to override defaults from theme.json. const selector = `.${ selectorPrefix }${ id }.${ selectorPrefix }${ id }`; diff --git a/packages/block-editor/src/hooks/use-typography-props.js b/packages/block-editor/src/hooks/use-typography-props.js index 1ed02d4a5835f..14f5874c1422c 100644 --- a/packages/block-editor/src/hooks/use-typography-props.js +++ b/packages/block-editor/src/hooks/use-typography-props.js @@ -3,6 +3,11 @@ */ import classnames from 'classnames'; +/** + * WordPress dependencies + */ +import { privateApis as componentsPrivateApis } from '@wordpress/components'; + /** * Internal dependencies */ @@ -12,7 +17,7 @@ import { getTypographyFontSizeValue, getFluidTypographyOptionsFromSettings, } from '../components/global-styles/typography-utils'; -import { kebabCase } from '../utils/object'; +import { unlock } from '../lock-unlock'; /* * This utility is intended to assist where the serialization of the typography @@ -29,6 +34,7 @@ import { kebabCase } from '../utils/object'; * @return {Object} Typography block support derived CSS classes & styles. */ export function getTypographyClassesAndStyles( attributes, settings ) { + const { kebabCase } = unlock( componentsPrivateApis ); let typographyStyles = attributes?.style?.typography || {}; const fluidTypographySettings = getFluidTypographyOptionsFromSettings( settings ); diff --git a/packages/block-editor/src/private-apis.js b/packages/block-editor/src/private-apis.js index 9837c206487be..ff86f07aa4caa 100644 --- a/packages/block-editor/src/private-apis.js +++ b/packages/block-editor/src/private-apis.js @@ -5,7 +5,6 @@ import * as globalStyles from './components/global-styles'; import { ExperimentalBlockEditorProvider } from './components/provider'; import { lock } from './lock-unlock'; import { getRichTextValues } from './components/rich-text/get-rich-text-values'; -import { kebabCase } from './utils/object'; import ResizableBoxPopover from './components/resizable-box-popover'; import { ComposedPrivateInserter as PrivateInserter } from './components/inserter'; import { PrivateListView } from './components/list-view'; @@ -36,7 +35,6 @@ lock( privateApis, { ExperimentalBlockEditorProvider, getDuotoneFilter, getRichTextValues, - kebabCase, PrivateInserter, PrivateListView, ResizableBoxPopover, diff --git a/packages/block-editor/src/private-apis.native.js b/packages/block-editor/src/private-apis.native.js index 17676f634b1ca..5555e00477e7b 100644 --- a/packages/block-editor/src/private-apis.native.js +++ b/packages/block-editor/src/private-apis.native.js @@ -3,7 +3,6 @@ */ import * as globalStyles from './components/global-styles'; import { ExperimentalBlockEditorProvider } from './components/provider'; -import { kebabCase } from './utils/object'; import { lock } from './lock-unlock'; /** @@ -12,6 +11,5 @@ import { lock } from './lock-unlock'; export const privateApis = {}; lock( privateApis, { ...globalStyles, - kebabCase, ExperimentalBlockEditorProvider, } ); diff --git a/packages/block-editor/src/utils/object.js b/packages/block-editor/src/utils/object.js index 5238056426417..8f6c82a9c3991 100644 --- a/packages/block-editor/src/utils/object.js +++ b/packages/block-editor/src/utils/object.js @@ -1,38 +1,3 @@ -/** - * External dependencies - */ -import { paramCase } from 'change-case'; - -/** - * Converts any string to kebab case. - * Backwards compatible with Lodash's `_.kebabCase()`. - * Backwards compatible with `_wp_to_kebab_case()`. - * - * @see https://lodash.com/docs/4.17.15#kebabCase - * @see https://developer.wordpress.org/reference/functions/_wp_to_kebab_case/ - * - * @param {string} str String to convert. - * @return {string} Kebab-cased string - */ -export function kebabCase( str ) { - let input = str; - if ( typeof str !== 'string' ) { - input = str?.toString?.() ?? ''; - } - - // See https://github.com/lodash/lodash/blob/b185fcee26b2133bd071f4aaca14b455c2ed1008/lodash.js#L4970 - input = input.replace( /['\u2019]/, '' ); - - return paramCase( input, { - splitRegexp: [ - /(?!(?:1ST|2ND|3RD|[4-9]TH)(?![a-z]))([a-z0-9])([A-Z])/g, // fooBar => foo-bar, 3Bar => 3-bar - /(?!(?:1st|2nd|3rd|[4-9]th)(?![a-z]))([0-9])([a-z])/g, // 3bar => 3-bar - /([A-Za-z])([0-9])/g, // Foo3 => foo-3, foo3 => foo-3 - /([A-Z])([A-Z][a-z])/g, // FOOBar => foo-bar - ], - } ); -} - /** * Immutably sets a value inside an object. Like `lodash#set`, but returning a * new object. Treats nullish initial values as empty objects. Clones any diff --git a/packages/block-editor/src/utils/test/object.js b/packages/block-editor/src/utils/test/object.js index 87f01375df311..28f2fc7381cd8 100644 --- a/packages/block-editor/src/utils/test/object.js +++ b/packages/block-editor/src/utils/test/object.js @@ -1,102 +1,7 @@ /** * Internal dependencies */ -import { kebabCase, setImmutably } from '../object'; - -describe( 'kebabCase', () => { - it( 'separates lowercase letters, followed by uppercase letters', () => { - expect( kebabCase( 'fooBar' ) ).toEqual( 'foo-bar' ); - } ); - - it( 'separates numbers, followed by uppercase letters', () => { - expect( kebabCase( '123FOO' ) ).toEqual( '123-foo' ); - } ); - - it( 'separates numbers, followed by lowercase characters', () => { - expect( kebabCase( '123bar' ) ).toEqual( '123-bar' ); - } ); - - it( 'separates uppercase letters, followed by numbers', () => { - expect( kebabCase( 'FOO123' ) ).toEqual( 'foo-123' ); - } ); - - it( 'separates lowercase letters, followed by numbers', () => { - expect( kebabCase( 'foo123' ) ).toEqual( 'foo-123' ); - } ); - - it( 'separates uppercase groups from capitalized groups', () => { - expect( kebabCase( 'FOOBar' ) ).toEqual( 'foo-bar' ); - } ); - - it( 'removes any non-dash special characters', () => { - expect( - kebabCase( 'foo±§!@#$%^&*()-_=+/?.>,<\\|{}[]`~\'";:bar' ) - ).toEqual( 'foo-bar' ); - } ); - - it( 'removes any spacing characters', () => { - expect( kebabCase( ' foo \t \n \r \f \v bar ' ) ).toEqual( 'foo-bar' ); - } ); - - it( 'groups multiple dashes into a single one', () => { - expect( kebabCase( 'foo---bar' ) ).toEqual( 'foo-bar' ); - } ); - - it( 'returns an empty string unchanged', () => { - expect( kebabCase( '' ) ).toEqual( '' ); - } ); - - it( 'returns an existing kebab case string unchanged', () => { - expect( kebabCase( 'foo-123-bar' ) ).toEqual( 'foo-123-bar' ); - } ); - - it( 'returns an empty string if any nullish type is passed', () => { - expect( kebabCase( undefined ) ).toEqual( '' ); - expect( kebabCase( null ) ).toEqual( '' ); - } ); - - it( 'converts any unexpected non-nullish type to a string', () => { - expect( kebabCase( 12345 ) ).toEqual( '12345' ); - expect( kebabCase( [] ) ).toEqual( '' ); - expect( kebabCase( {} ) ).toEqual( 'object-object' ); - } ); - - /** - * Should cover all test cases of `_wp_to_kebab_case()`. - * - * @see https://developer.wordpress.org/reference/functions/_wp_to_kebab_case/ - * @see https://github.com/WordPress/wordpress-develop/blob/76376fdbc3dc0b3261de377dffc350677345e7ba/tests/phpunit/tests/functions/wpToKebabCase.php#L35-L62 - */ - it.each( [ - [ 'white', 'white' ], - [ 'white+black', 'white-black' ], - [ 'white:black', 'white-black' ], - [ 'white*black', 'white-black' ], - [ 'white.black', 'white-black' ], - [ 'white black', 'white-black' ], - [ 'white black', 'white-black' ], - [ 'white-to-black', 'white-to-black' ], - [ 'white2white', 'white-2-white' ], - [ 'white2nd', 'white-2nd' ], - [ 'white2ndcolor', 'white-2-ndcolor' ], - [ 'white2ndColor', 'white-2nd-color' ], - [ 'white2nd_color', 'white-2nd-color' ], - [ 'white23color', 'white-23-color' ], - [ 'white23', 'white-23' ], - [ '23color', '23-color' ], - [ 'white4th', 'white-4th' ], - [ 'font2xl', 'font-2-xl' ], - [ 'whiteToWhite', 'white-to-white' ], - [ 'whiteTOwhite', 'white-t-owhite' ], - [ 'WHITEtoWHITE', 'whit-eto-white' ], - [ 42, '42' ], - [ "i've done", 'ive-done' ], - [ '#ffffff', 'ffffff' ], - [ '$ffffff', 'ffffff' ], - ] )( 'converts %s properly to %s', ( input, expected ) => { - expect( kebabCase( input ) ).toEqual( expected ); - } ); -} ); +import { setImmutably } from '../object'; describe( 'setImmutably', () => { describe( 'handling falsy values properly', () => { diff --git a/packages/block-library/src/embed/util.js b/packages/block-library/src/embed/util.js index a7a6ea219f277..c591c5d19e2d2 100644 --- a/packages/block-library/src/embed/util.js +++ b/packages/block-library/src/embed/util.js @@ -7,7 +7,7 @@ import memoize from 'memize'; /** * WordPress dependencies */ -import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; +import { privateApis as componentsPrivateApis } from '@wordpress/components'; import { renderToString } from '@wordpress/element'; import { createBlock, @@ -23,7 +23,6 @@ import { ASPECT_RATIOS, WP_EMBED_TYPE } from './constants'; import { unlock } from '../lock-unlock'; const { name: DEFAULT_EMBED_BLOCK } = metadata; -const { kebabCase } = unlock( blockEditorPrivateApis ); /** @typedef {import('@wordpress/blocks').WPBlockVariation} WPBlockVariation */ @@ -283,6 +282,7 @@ export const getAttributesFromPreview = memoize( // If we got a provider name from the API, use it for the slug, otherwise we use the title, // because not all embed code gives us a provider name. const { html, provider_name: providerName } = preview; + const { kebabCase } = unlock( componentsPrivateApis ); const providerNameSlug = kebabCase( ( providerName || title ).toLowerCase() ); diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index fddc4de03d636..acf8112b4111a 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -25,6 +25,7 @@ ### Internal - `DropdownMenuV2Ariakit`: prevent prefix collapsing if all radios or checkboxes are unselected ([#56720](https://github.com/WordPress/gutenberg/pull/56720)). +- Move `kebabCase()` function from `block-editor` package and mark it as private API ([#56758](https://github.com/WordPress/gutenberg/pull/56758)). ### Experimental diff --git a/packages/components/src/index.native.js b/packages/components/src/index.native.js index dc8a77ad77d1e..f2c1591dc3ce2 100644 --- a/packages/components/src/index.native.js +++ b/packages/components/src/index.native.js @@ -141,3 +141,6 @@ export { useMobileGlobalStylesColors, useEditorColorScheme, } from './mobile/global-styles-context/utils'; + +// Private APIs. +export { privateApis } from './private-apis'; diff --git a/packages/components/src/lock-unlock.js b/packages/components/src/lock-unlock.js new file mode 100644 index 0000000000000..1525ece158072 --- /dev/null +++ b/packages/components/src/lock-unlock.js @@ -0,0 +1,10 @@ +/** + * WordPress dependencies + */ +import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis'; + +export const { lock, unlock } = + __dangerousOptInToUnstableAPIsOnlyForCoreModules( + 'I know using unstable features means my theme or plugin will inevitably break in the next version of WordPress.', + '@wordpress/components' + ); diff --git a/packages/components/src/private-apis.native.js b/packages/components/src/private-apis.native.js new file mode 100644 index 0000000000000..659de19f39137 --- /dev/null +++ b/packages/components/src/private-apis.native.js @@ -0,0 +1,13 @@ +/** + * Internal dependencies + */ +import { kebabCase } from './utils/strings'; +import { lock } from './lock-unlock'; + +/** + * Private @wordpress/components APIs. + */ +export const privateApis = {}; +lock( privateApis, { + kebabCase, +} ); diff --git a/packages/components/src/private-apis.ts b/packages/components/src/private-apis.ts index fb4679dbc3423..ba0048407574e 100644 --- a/packages/components/src/private-apis.ts +++ b/packages/components/src/private-apis.ts @@ -42,12 +42,8 @@ import { import { ComponentsContext } from './context/context-system-provider'; import Theme from './theme'; import Tabs from './tabs'; - -export const { lock, unlock } = - __dangerousOptInToUnstableAPIsOnlyForCoreModules( - 'I know using unstable features means my theme or plugin will inevitably break in the next version of WordPress.', - '@wordpress/components' - ); +import { kebabCase } from './utils/strings'; +import { lock } from './lock-unlock'; export const privateApis = {}; lock( privateApis, { @@ -81,4 +77,5 @@ lock( privateApis, { DropdownMenuSeparatorV2Ariakit, DropdownMenuItemLabelV2Ariakit, DropdownMenuItemHelpTextV2Ariakit, + kebabCase, } ); diff --git a/packages/components/src/utils/strings.ts b/packages/components/src/utils/strings.ts index e4d1d8f73bda1..bb43a5e53a405 100644 --- a/packages/components/src/utils/strings.ts +++ b/packages/components/src/utils/strings.ts @@ -2,6 +2,7 @@ * External dependencies */ import removeAccents from 'remove-accents'; +import { paramCase } from 'change-case'; const ALL_UNICODE_DASH_CHARACTERS = new RegExp( `[${ [ @@ -71,12 +72,39 @@ export const normalizeTextString = ( value: string ): string => { .replace( ALL_UNICODE_DASH_CHARACTERS, '-' ); }; +/** + * Converts any string to kebab case. + * Backwards compatible with Lodash's `_.kebabCase()`. + * Backwards compatible with `_wp_to_kebab_case()`. + * + * @see https://lodash.com/docs/4.17.15#kebabCase + * @see https://developer.wordpress.org/reference/functions/_wp_to_kebab_case/ + * + * @param str String to convert. + * @return Kebab-cased string + */ +export function kebabCase( str: unknown ) { + let input = str?.toString?.() ?? ''; + + // See https://github.com/lodash/lodash/blob/b185fcee26b2133bd071f4aaca14b455c2ed1008/lodash.js#L4970 + input = input.replace( /['\u2019]/, '' ); + + return paramCase( input, { + splitRegexp: [ + /(?!(?:1ST|2ND|3RD|[4-9]TH)(?![a-z]))([a-z0-9])([A-Z])/g, // fooBar => foo-bar, 3Bar => 3-bar + /(?!(?:1st|2nd|3rd|[4-9]th)(?![a-z]))([0-9])([a-z])/g, // 3bar => 3-bar + /([A-Za-z])([0-9])/g, // Foo3 => foo-3, foo3 => foo-3 + /([A-Z])([A-Z][a-z])/g, // FOOBar => foo-bar + ], + } ); +} + /** * Escapes the RegExp special characters. * - * @param {string} string Input string. + * @param string Input string. * - * @return {string} Regex-escaped string. + * @return Regex-escaped string. */ export function escapeRegExp( string: string ): string { return string.replace( /[\\^$.*+?()[\]{}|]/g, '\\$&' ); diff --git a/packages/components/src/utils/test/strings.js b/packages/components/src/utils/test/strings.js index 43682a0e2853f..2c7d9641260f5 100644 --- a/packages/components/src/utils/test/strings.js +++ b/packages/components/src/utils/test/strings.js @@ -1,7 +1,102 @@ /** * Internal dependencies */ -import { normalizeTextString } from '../strings'; +import { kebabCase, normalizeTextString } from '../strings'; + +describe( 'kebabCase', () => { + it( 'separates lowercase letters, followed by uppercase letters', () => { + expect( kebabCase( 'fooBar' ) ).toEqual( 'foo-bar' ); + } ); + + it( 'separates numbers, followed by uppercase letters', () => { + expect( kebabCase( '123FOO' ) ).toEqual( '123-foo' ); + } ); + + it( 'separates numbers, followed by lowercase characters', () => { + expect( kebabCase( '123bar' ) ).toEqual( '123-bar' ); + } ); + + it( 'separates uppercase letters, followed by numbers', () => { + expect( kebabCase( 'FOO123' ) ).toEqual( 'foo-123' ); + } ); + + it( 'separates lowercase letters, followed by numbers', () => { + expect( kebabCase( 'foo123' ) ).toEqual( 'foo-123' ); + } ); + + it( 'separates uppercase groups from capitalized groups', () => { + expect( kebabCase( 'FOOBar' ) ).toEqual( 'foo-bar' ); + } ); + + it( 'removes any non-dash special characters', () => { + expect( + kebabCase( 'foo±§!@#$%^&*()-_=+/?.>,<\\|{}[]`~\'";:bar' ) + ).toEqual( 'foo-bar' ); + } ); + + it( 'removes any spacing characters', () => { + expect( kebabCase( ' foo \t \n \r \f \v bar ' ) ).toEqual( 'foo-bar' ); + } ); + + it( 'groups multiple dashes into a single one', () => { + expect( kebabCase( 'foo---bar' ) ).toEqual( 'foo-bar' ); + } ); + + it( 'returns an empty string unchanged', () => { + expect( kebabCase( '' ) ).toEqual( '' ); + } ); + + it( 'returns an existing kebab case string unchanged', () => { + expect( kebabCase( 'foo-123-bar' ) ).toEqual( 'foo-123-bar' ); + } ); + + it( 'returns an empty string if any nullish type is passed', () => { + expect( kebabCase( undefined ) ).toEqual( '' ); + expect( kebabCase( null ) ).toEqual( '' ); + } ); + + it( 'converts any unexpected non-nullish type to a string', () => { + expect( kebabCase( 12345 ) ).toEqual( '12345' ); + expect( kebabCase( [] ) ).toEqual( '' ); + expect( kebabCase( {} ) ).toEqual( 'object-object' ); + } ); + + /** + * Should cover all test cases of `_wp_to_kebab_case()`. + * + * @see https://developer.wordpress.org/reference/functions/_wp_to_kebab_case/ + * @see https://github.com/WordPress/wordpress-develop/blob/76376fdbc3dc0b3261de377dffc350677345e7ba/tests/phpunit/tests/functions/wpToKebabCase.php#L35-L62 + */ + it.each( [ + [ 'white', 'white' ], + [ 'white+black', 'white-black' ], + [ 'white:black', 'white-black' ], + [ 'white*black', 'white-black' ], + [ 'white.black', 'white-black' ], + [ 'white black', 'white-black' ], + [ 'white black', 'white-black' ], + [ 'white-to-black', 'white-to-black' ], + [ 'white2white', 'white-2-white' ], + [ 'white2nd', 'white-2nd' ], + [ 'white2ndcolor', 'white-2-ndcolor' ], + [ 'white2ndColor', 'white-2nd-color' ], + [ 'white2nd_color', 'white-2nd-color' ], + [ 'white23color', 'white-23-color' ], + [ 'white23', 'white-23' ], + [ '23color', '23-color' ], + [ 'white4th', 'white-4th' ], + [ 'font2xl', 'font-2-xl' ], + [ 'whiteToWhite', 'white-to-white' ], + [ 'whiteTOwhite', 'white-t-owhite' ], + [ 'WHITEtoWHITE', 'whit-eto-white' ], + [ 42, '42' ], + [ "i've done", 'ive-done' ], + [ '#ffffff', 'ffffff' ], + [ '$ffffff', 'ffffff' ], + ] )( 'converts %s properly to %s', ( input, expected ) => { + expect( kebabCase( input ) ).toEqual( expected ); + } ); +} ); describe( 'normalizeTextString', () => { it( 'should normalize hyphen-like characters to hyphens', () => { diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/collection-font-variant.js b/packages/edit-site/src/components/global-styles/font-library-modal/collection-font-variant.js index 8c2b36d3adee9..6108ca7669c5b 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/collection-font-variant.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/collection-font-variant.js @@ -1,14 +1,18 @@ /** * WordPress dependencies */ -import { CheckboxControl, Flex } from '@wordpress/components'; +import { + CheckboxControl, + Flex, + privateApis as componentsPrivateApis, +} from '@wordpress/components'; /** * Internal dependencies */ import { getFontFaceVariantName } from './utils'; import FontFaceDemo from './font-demo'; -import { kebabCase } from '../../../../../block-editor/src/utils/object'; +import { unlock } from '../../../lock-unlock'; function CollectionFontVariant( { face, @@ -25,6 +29,7 @@ function CollectionFontVariant( { }; const displayName = font.name + ' ' + getFontFaceVariantName( face ); + const { kebabCase } = unlock( componentsPrivateApis ); const checkboxId = kebabCase( `${ font.slug }-${ getFontFaceVariantName( face ) }` ); diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/library-font-variant.js b/packages/edit-site/src/components/global-styles/font-library-modal/library-font-variant.js index 010f3efdbeb91..d74a5f74f019b 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/library-font-variant.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/library-font-variant.js @@ -2,7 +2,11 @@ * WordPress dependencies */ import { useContext } from '@wordpress/element'; -import { CheckboxControl, Flex } from '@wordpress/components'; +import { + CheckboxControl, + Flex, + privateApis as componentsPrivateApis, +} from '@wordpress/components'; /** * Internal dependencies @@ -10,7 +14,7 @@ import { CheckboxControl, Flex } from '@wordpress/components'; import { getFontFaceVariantName } from './utils'; import { FontLibraryContext } from './context'; import FontFaceDemo from './font-demo'; -import { kebabCase } from '../../../../../block-editor/src/utils/object'; +import { unlock } from '../../../lock-unlock'; function LibraryFontVariant( { face, font } ) { const { isFontActivated, toggleActivateFont } = @@ -34,6 +38,7 @@ function LibraryFontVariant( { face, font } ) { }; const displayName = font.name + ' ' + getFontFaceVariantName( face ); + const { kebabCase } = unlock( componentsPrivateApis ); const checkboxId = kebabCase( `${ font.slug }-${ getFontFaceVariantName( face ) }` );