Skip to content

New string package: add kebab-case utility and migrate private API calls - #81294

Open
oandregal wants to merge 11 commits into
trunkfrom
add/kebab-case-package
Open

New string package: add kebab-case utility and migrate private API calls#81294
oandregal wants to merge 11 commits into
trunkfrom
add/kebab-case-package

Conversation

@oandregal

Copy link
Copy Markdown
Member

Follow-up to #81284; see #81230 and #56758.

What?

Introduces a new @wordpress/kebab-case package, and migrates the existing usage of the private utility.

Why?

We need a kebabCase utility that is backwards compatible with Lodash's _.kebabCase() and Core's _wp_to_kebab_case(). This utility was deemed private as of #56758 However, the API is stable, tested and won't change because we've locked its implementation to behave like its server-side equivalent (_wp_to_kebab_case). By making it public, the contract is made explicit.

Additionally, working with private APIs has proven problematic (see #81230, for example), and we should minimize/remove them as much as possible.

How?

  • New package packages/kebab-case: carries the implementation verbatim, plus the full test suite including all _wp_to_kebab_case() parity cases.
  • Private API consumers migrated to direct imports.

Questions

There are two semi-identical implementations that diverge from the canonical semantics:

  • packages/global-styles-engine/src/utils/string.ts.
  • packages/style-engine/src/styles/utils.ts.
Input _wp_to_kebab_case global-styles-engine style-engine
fooBar foo-bar foo-bar foo-bar
white2white white-2-white white-2-white white-2-white
white2nd white-2nd white-2-nd white-2-nd
white2ndColor white-2nd-color white-2-nd-color white-2-nd-color
FOOBar foo-bar foobar foo-bar
WHITEtoWHITE whit-eto-white whiteto-white whit-eto-white
i've done ive-done i've-done i-ve-done
#ffffff ffffff #ffffff ffffff
white+black white-black white+black white-black
foo bar foo-bar -foo-bar- foo-bar

cc @ramonjd for thoughts: are these implementations intentionally divergent from _wp_to_kebab_case? I haven't dug into them or how they are used, and so I may be missing tons of context.

Testing Instructions

  1. Run the unit tests.
  2. Verify no consumer of the private kebabCase remains: git grep -n "kebabCase" -- packages | grep unlock should return nothing.
  3. Smoke-test a few generated-slug surfaces in the editor and confirm nothing changed:
    • Insert an Embed block (e.g. YouTube) and check the block gets the is-provider-youtube class.
    • Apply a text color to a paragraph and check the has-{slug}-color class.
    • In the site editor, open Patterns and choose "Duplicate" on a pattern; inspect the modal overlay for the dataviews-action-modal__duplicate-pattern class.

Use of AI Tools

This PR was written with Claude Code (implementation, tests, and this description), under human direction and review.

@oandregal oandregal self-assigned this Aug 6, 2026
Copilot AI balanced review requested due to automatic review settings August 6, 2026 15:36
@oandregal oandregal added the [Type] Code Quality Issues or PRs that relate to code quality label Aug 6, 2026
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: oandregal <oandregal@git.wordpress.org>
Co-authored-by: youknowriad <youknowriad@git.wordpress.org>
Co-authored-by: tyxla <tyxla@git.wordpress.org>
Co-authored-by: aduth <aduth@git.wordpress.org>
Co-authored-by: ramonjd <ramonopoly@git.wordpress.org>
Co-authored-by: jorgefilipecosta <jorgefilipecosta@git.wordpress.org>
Co-authored-by: ciampo <mciampini@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Size Change: -205 B (0%)

Total Size: 7.82 MB

📦 View Changed
Filename Size Change
build/scripts/block-editor/index.min.js 434 kB -153 B (-0.04%)
build/scripts/block-library/index.min.js 356 kB +153 B (+0.04%)
build/scripts/components/index.min.js 278 kB -48 B (-0.02%)
build/scripts/edit-site/index.min.js 311 kB -55 B (-0.02%)
build/scripts/editor/index.min.js 510 kB -103 B (-0.02%)
build/scripts/media-utils/index.min.js 128 kB +1 B (0%)

compressed-size-action

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces @wordpress/kebab-case as a public utility and migrates consumers away from the Components private API.

Changes:

  • Adds the package implementation, tests, documentation, and metadata.
  • Migrates six packages to direct imports.
  • Updates dependencies, TypeScript references, changelogs, and documentation.

Reviewed changes

Copilot reviewed 40 out of 42 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tsconfig.json References the new package.
packages/kebab-case/tsconfig.json Adds TypeScript configuration.
packages/kebab-case/src/test/index.js Houses parity tests.
packages/kebab-case/src/index.ts Exposes kebabCase.
packages/kebab-case/README.md Documents usage and semantics.
packages/kebab-case/package.json Defines package metadata.
packages/kebab-case/CHANGELOG.md Records the initial release.
packages/global-styles-ui/tsconfig.json Adds the package reference.
packages/global-styles-ui/src/font-library/utils/make-families-from-faces.ts Uses the public utility.
packages/global-styles-ui/src/font-library/utils/index.ts Uses the public utility.
packages/global-styles-ui/package.json Adds the dependency.
packages/global-styles-ui/CHANGELOG.md Records the migration.
packages/editor/tsconfig.json Adds the package reference.
packages/editor/src/components/post-actions/index.js Migrates modal slug generation.
packages/editor/package.json Adds the dependency.
packages/editor/CHANGELOG.md Records the migration.
packages/dataviews/tsconfig.json Adds the package reference.
packages/dataviews/src/components/dataviews-item-actions/index.tsx Replaces the local utility.
packages/dataviews/package.json Adds the dependency.
packages/dataviews/CHANGELOG.md Records the migration.
packages/components/tsconfig.json Adds the package reference.
packages/components/src/utils/test/strings.js Removes relocated tests.
packages/components/src/utils/strings.ts Removes the private implementation.
packages/components/src/private-apis.ts Removes the private export.
packages/components/src/palette-edit/index.tsx Uses the public utility.
packages/components/package.json Adds the dependency.
packages/components/CHANGELOG.md Records the extraction.
packages/block-library/tsconfig.json Adds the package reference.
packages/block-library/src/embed/util.js Migrates provider slug generation.
packages/block-library/package.json Adds the dependency.
packages/block-library/CHANGELOG.md Records the migration.
packages/block-editor/tsconfig.json Adds the package reference.
packages/block-editor/src/hooks/use-typography-props.js Migrates typography slugs.
packages/block-editor/src/hooks/layout.js Migrates layout slugs.
packages/block-editor/src/hooks/font-family.js Migrates font-family slugs.
packages/block-editor/src/components/font-sizes/utils.js Migrates font-size slugs.
packages/block-editor/src/components/colors/with-colors.js Migrates color property slugs.
packages/block-editor/src/components/colors/utils.js Migrates color class slugs.
packages/block-editor/package.json Adds the dependency.
packages/block-editor/CHANGELOG.md Records the migration.
package-lock.json Locks the workspace and dependencies.
docs/manifest.json Registers the package documentation.

Comment thread packages/string/package.json
Comment thread packages/kebab-case/README.md Outdated
@youknowriad

Copy link
Copy Markdown
Contributor

I wonder if this should be a "string" package like we have a "url" package... to potentially absorb similar string utils later.

@aduth

aduth commented Aug 6, 2026

Copy link
Copy Markdown
Member

I wonder if this should be a "string" package like we have a "url" package... to potentially absorb similar string utils later.

Personally I'd hope we would be able to use off-the-shelf libraries like we're using change-case rather than rolling our own except in cases like this one where we have unique requirements that those off-the-shelf options won't work. Maybe we have others, but the risk is that it becomes a dumping ground of bespoke implementations for things we should consider not maintaining ourselves?

@youknowriad

Copy link
Copy Markdown
Contributor

the risk is that it becomes a dumping ground of bespoke implementations for things we should consider not maintaining ourselves?

I don't believe that's a big risk though, we can make this a bundled package with a clear purpose (string manipulation functions that we maintain ourselves) and since it's also a bundled package, breaking changes and all are ok.

@ramonjd

ramonjd commented Aug 6, 2026

Copy link
Copy Markdown
Member

cc @ramonjd for thoughts: are these implementations intentionally divergent from _wp_to_kebab_case? I haven't dug into them or how they are used, and so I may be missing tons of context.

Thanks for the ping. What memories!

Short answer: no nothing was intentional in the style engine aside from focussing on the theme json preset parsing. It probably predates a few use cases we need today.

I see no downside in swapping out for this PR's implementation. I ran some tests locally to check.

During that test, a couple of ASCII related things came up on the parity to _wp_to_kebab_case claim that we might want to make sure of (see playground link for _wp_to_kebab_case() output)

`kebabCase` parity data ASCII

(ASCII)

Two families, both narrow, both fixable in the file this PR is moving.

(a) The apostrophe strip is missing the g flag. input.replace( /['\u2019]/, '' ) removes only the first apostrophe; PHP's str_replace( "'", '', $input_string ) removes all. It also strips (U+2019), which PHP does not.

(b) An ordinal immediately followed by a digit. PHP's $rsOrdLower requires (?=\b|[A-Z_]) after the ordinal; the JS lookahead only excludes a following lowercase letter.

Input _wp_to_kebab_case() lodash kebabCase @wordpress/kebab-case global-styles-engine style-engine
can't stop cant-stop cant-stop cant-stop can't-stop ⚠️ can-t-stop ⚠️
can''t stop cant-stop cant-stop can-t-stop ⚠️ can''t-stop ⚠️ can-t-stop ⚠️
a''a aa aa a-a ⚠️ a''a ⚠️ a-a ⚠️
it’s done it-s-done its-done ⚠️ its-done ⚠️ it’s-done ⚠️ it-s-done
1st1 1-st-1 1-st-1 1st-1 ⚠️ 1-st-1 1-st-1
2nd2 2-nd-2 2-nd-2 2nd-2 ⚠️ 2-nd-2 2-nd-2
level1st1 level-1-st-1 level-1-st-1 level-1st-1 ⚠️ level-1-st-1 level-1-st-1

Non-ASCII

PHP's strtolower() is byte-wise, so multibyte uppercase survives. lodash deburrs. change-case's default stripRegexp (/[^A-Z0-9]+/gi) deletes the characters outright. Core's own test provider contains zero non-ASCII cases, so this has never been pinned on either side.

Input _wp_to_kebab_case() lodash kebabCase @wordpress/kebab-case global-styles-engine style-engine
grün grün grun ⚠️ gr-n ⚠️ grün gr-n ⚠️
Grün grün grun ⚠️ gr-n ⚠️ grün gr-n ⚠️
café café cafe ⚠️ caf ⚠️ café caf ⚠️
naïve naïve naive ⚠️ na-ve ⚠️ naïve na-ve ⚠️
Öffnen Öffnen offnen ⚠️ ffnen ⚠️ öffnen ⚠️ ffnen ⚠️
Ápple Ápple apple ⚠️ pple ⚠️ ápple ⚠️ pple ⚠️
日本語 日本語 日本語 (empty) ⚠️ 日本語 (empty) ⚠️

This is the one place where swapping global-styles-engine to the canonical utility is a regression rather than a fix — but it restores exactly what that code did before the package extraction, so it is not a new class of bug.

As for the code in global-styles-engine, my best assessment is that it might be a regression after #72464 - looks to be coded rather than migrated, so another target for replacement.

Hope that helps!

@jorgefilipecosta

Copy link
Copy Markdown
Member

the risk is that it becomes a dumping ground of bespoke implementations for things we should consider not maintaining ourselves?

I don't believe that's a big risk though, we can make this a bundled package with a clear purpose (string manipulation functions that we maintain ourselves) and since it's also a bundled package, breaking changes and all are ok.

I agree with a string package. Sooner or later we will end-up needing to maintain other string manipulation function like this one where using a third-party lib is not possible because of a specific requirement, having a package with documentation saying that when possible existing third-party packages should be used. And on each function explain why a third-party was not used seems like it may save the effort of creating another package in the future.

oandregal and others added 4 commits August 7, 2026 13:40
Extracts the WP-parity kebabCase utility (backwards compatible with
Lodash's _.kebabCase() and Core's _wp_to_kebab_case()) into a new
dependency-only package (no wpScript), and migrates the two identical
copies:

- @wordpress/components: utils/strings.ts now re-exports kebabCase from
  the new package, so the existing private API surface and all unlock()
  consumers are unchanged. The kebabCase test suite moves to the new
  package.
- @wordpress/dataviews: removes the copy inlined in #81284 and imports
  the package directly.

Follow-up to #81284 and part of the direction discussed in #81230;
the utility originally became a components private API in #56758.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Removes kebabCase from the @wordpress/components private APIs and
updates every consumer to import the new package directly:

- block-editor: colors (utils, with-colors), font-sizes utils, and the
  font-family, layout, and use-typography-props hooks
- block-library: embed util
- editor: post-actions (Menu remains unlocked)
- global-styles-ui: font-library utils
- components itself: palette-edit, and utils/strings.ts no longer
  re-exports it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The package hosts string utilities that WordPress maintains itself,
starting with kebabCase. See the discussion in #81294.
Trunk's ESLint config no longer allows dependency group comments.
@wordpress/string is a public npm package without a WordPress script
build. Without this exclusion, dependency-extraction-webpack-plugin
externalizes it to the nonexistent wp-string handle and wp.string
global, so plugin scripts importing it fail to enqueue.
@oandregal
oandregal force-pushed the add/kebab-case-package branch from 8883f00 to 8b4065c Compare August 7, 2026 12:03
@github-actions github-actions Bot added [Package] Components /packages/components [Package] Editor /packages/editor [Package] Block library /packages/block-library [Package] Block editor /packages/block-editor [Package] DataViews /packages/dataviews labels Aug 7, 2026
The rename from @wordpress/kebab-case left the dependency at
kebab-case's sort position in six consumer package.json files.
Point the entries at the current PR (#81294 instead of the stale
#81285) and move the components entry back into the Unreleased
section; the rebase had merged it into the released 38.0.0 section.
@@ -1,99 +1,4 @@
import { kebabCase, normalizeTextString } from '../strings';

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests have been migrated verbatim to packages/string/src/test/index.js.

Comment on lines -36 to -50
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
],
} );
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has been migrated verbatim to packages/string/src/index.js.

@oandregal oandregal changed the title Add kebab-case package and migrate private API calls New string package: add kebab-case utility and migrate private API calls Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Flaky tests detected in f375665.
Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/31177108322
📝 Reported tests:

As a user I want to be able to add multiple close buttons so that users can close the overlay from different positions in /test/e2e/specs/site-editor/navigation-overlay-template-part.spec.js, passed after 1 failed attempt.
Error: expect(locator).toHaveCount(expected) failed

Locator:  getByRole('button', { name: 'Close' })
Expected: 2
Received: 1
Timeout:  5000ms

Call log:
  - Expect "toHaveCount" with timeout 5000ms
  - waiting for getByRole('button', { name: 'Close' })
    14 × locator resolved to 1 element
       - unexpected value "1"

    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/site-editor/navigation-overlay-template-part.spec.js:198:33

oandregal and others added 3 commits August 7, 2026 14:45
State that kebabCase is designed to match _wp_to_kebab_case() with the
test suite covering every Core test case, rather than claiming blanket
parity, and use only examples that appear in the test suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Declare an explicit "wpScript": false, use "default" instead of
"require" in the exports map, and bump the engines requirement,
matching the conventions of the most recently added bundled packages
(grid, ui, style-runtime).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reference kept the slot that was alphabetical for the former
kebab-case package name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@oandregal

Copy link
Copy Markdown
Member Author

@ramonjd thanks for that additional context and tests.

I want this PR to be focused on extracting the existing utility verbatim. But those additional tests you brought up (also what copilot found) merit looking into them separately. If the client and server implementation are not aligned, they should. Though I suppose it's not a practical issue, otherwise we'd have heard users feedback? Anyway, I don't feel comfortable migrating packages/global-styles-engine/src/utils/string.ts and packages/style-engine/src/styles/utils.ts until that evaluation is performed, so I'll leave them as they are in this PR.

@oandregal

Copy link
Copy Markdown
Member Author

re: kebab-case vs string package. I prepared this initially as kebab-case based on the same thinking Andrew suggested. However, this is not a strong opinion and I'm fine with a more general string name, so I changed it.

I think this is now ready.

@@ -1,5 +1,4 @@
import removeAccents from 'remove-accents';
import { paramCase } from 'change-case';

@youknowriad youknowriad Aug 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about the other functions here in this file, are they good candidates for the string package?

@youknowriad youknowriad left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer string personally but also not strongly opinionated either way. Feel free to move forward either way.

@ciampo

ciampo commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

I share a similar sentiment to @aduth (which reminds me of the many times that I've got a "YAGNI" from @tyxla ) — should we actually create a string package only if/when we really need it?

My instinct tells me that 99% of the functionality that we need can be provided by a trusted 3rd party library, such as https://www.npmjs.com/package/es-toolkit or https://www.npmjs.com/package/@stdlib/string (or via a mix of smaller, more specialized packages, starting from https://www.npmjs.com/package/change-case for this specific scenario?)

@tyxla tyxla left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have strong feelings either way, but as @ciampo pointed out, I always suggest the YAGNI mentality.

To be fair, what turns me away from the string package idea is that we're risking that it becomes a string function kitchen sink, a mini version of Lodash, which I'd personally recommend against.

Comment thread package-lock.json
Comment on lines +54115 to +54118
"engines": {
"node": ">=18.12.0",
"npm": ">=8.19.2"
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we need to regenerate package-lock so this is consistent with the versions the package is actually declaring

Comment thread packages/string/README.md

Converts any string to kebab-case.

The conversion is designed to match WordPress Core's [`_wp_to_kebab_case()`](https://developer.wordpress.org/reference/functions/_wp_to_kebab_case/) function (and, transitively, Lodash's [`_.kebabCase()`](https://lodash.com/docs/4.17.15#kebabCase)): the package's test suite covers every test case of the Core function. These semantics differ from other kebab-case implementations in how numbers are handled (`'white23'` becomes `'white-23'`, `'font2xl'` becomes `'font-2-xl'`, yet `'white4th'` stays `'white-4th'`). WordPress uses these semantics when generating slugs for CSS class names and preset CSS custom properties (`--wp--preset--*`), so use this function whenever a generated slug needs to match one generated by WordPress.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing to keep in mind here: we're conflating kebab case with slug generation, almost as if they're the same thing. Don't we allow the possibility that we want to kebab case something that is not used for a slug?

Comment thread packages/string/README.md

Converts any string to kebab-case.

The conversion is designed to match WordPress Core's [`_wp_to_kebab_case()`](https://developer.wordpress.org/reference/functions/_wp_to_kebab_case/) function (and, transitively, Lodash's [`_.kebabCase()`](https://lodash.com/docs/4.17.15#kebabCase)): the package's test suite covers every test case of the Core function. These semantics differ from other kebab-case implementations in how numbers are handled (`'white23'` becomes `'white-23'`, `'font2xl'` becomes `'font-2-xl'`, yet `'white4th'` stays `'white-4th'`). WordPress uses these semantics when generating slugs for CSS class names and preset CSS custom properties (`--wp--preset--*`), so use this function whenever a generated slug needs to match one generated by WordPress.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we clarify any exceptions to "matching WordPress core's _wp_to_kebab_case()? For example how we handle any accented characters, or how we handle a''a (a-a here vs aa in Core)

"@wordpress/string": "file:../string",
"@wordpress/ui": "file:../ui",
"@wordpress/warning": "file:../warning",
"change-case": "^4.1.2",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like change-case may no longer be a direct dependency after this change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Package] Block editor /packages/block-editor [Package] Block library /packages/block-library [Package] Components /packages/components [Package] DataViews /packages/dataviews [Package] Editor /packages/editor [Type] Code Quality Issues or PRs that relate to code quality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants