@wordpress/abilities 0.1.1-next.dc3f6d3c1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE.md +788 -0
  3. package/README.md +302 -0
  4. package/build/api.js +189 -0
  5. package/build/api.js.map +7 -0
  6. package/build/index.js +53 -0
  7. package/build/index.js.map +7 -0
  8. package/build/store/actions.js +183 -0
  9. package/build/store/actions.js.map +7 -0
  10. package/build/store/constants.js +58 -0
  11. package/build/store/constants.js.map +7 -0
  12. package/build/store/index.js +77 -0
  13. package/build/store/index.js.map +7 -0
  14. package/build/store/reducer.js +126 -0
  15. package/build/store/reducer.js.map +7 -0
  16. package/build/store/resolvers.js +107 -0
  17. package/build/store/resolvers.js.map +7 -0
  18. package/build/store/selectors.js +64 -0
  19. package/build/store/selectors.js.map +7 -0
  20. package/build/store/tests/actions.test.js +792 -0
  21. package/build/store/tests/actions.test.js.map +7 -0
  22. package/build/store/tests/reducer.test.js +743 -0
  23. package/build/store/tests/reducer.test.js.map +7 -0
  24. package/build/store/tests/resolvers.test.js +520 -0
  25. package/build/store/tests/resolvers.test.js.map +7 -0
  26. package/build/store/tests/selectors.test.js +349 -0
  27. package/build/store/tests/selectors.test.js.map +7 -0
  28. package/build/tests/api.test.js +546 -0
  29. package/build/tests/api.test.js.map +7 -0
  30. package/build/tests/validation.test.js +453 -0
  31. package/build/tests/validation.test.js.map +7 -0
  32. package/build/types.js +19 -0
  33. package/build/types.js.map +7 -0
  34. package/build/validation.js +144 -0
  35. package/build/validation.js.map +7 -0
  36. package/build-module/api.js +146 -0
  37. package/build-module/api.js.map +7 -0
  38. package/build-module/index.js +28 -0
  39. package/build-module/index.js.map +7 -0
  40. package/build-module/store/actions.js +161 -0
  41. package/build-module/store/actions.js.map +7 -0
  42. package/build-module/store/constants.js +24 -0
  43. package/build-module/store/constants.js.map +7 -0
  44. package/build-module/store/index.js +47 -0
  45. package/build-module/store/index.js.map +7 -0
  46. package/build-module/store/reducer.js +112 -0
  47. package/build-module/store/reducer.js.map +7 -0
  48. package/build-module/store/resolvers.js +84 -0
  49. package/build-module/store/resolvers.js.map +7 -0
  50. package/build-module/store/selectors.js +36 -0
  51. package/build-module/store/selectors.js.map +7 -0
  52. package/build-module/store/tests/actions.test.js +804 -0
  53. package/build-module/store/tests/actions.test.js.map +7 -0
  54. package/build-module/store/tests/reducer.test.js +726 -0
  55. package/build-module/store/tests/reducer.test.js.map +7 -0
  56. package/build-module/store/tests/resolvers.test.js +523 -0
  57. package/build-module/store/tests/resolvers.test.js.map +7 -0
  58. package/build-module/store/tests/selectors.test.js +352 -0
  59. package/build-module/store/tests/selectors.test.js.map +7 -0
  60. package/build-module/tests/api.test.js +530 -0
  61. package/build-module/tests/api.test.js.map +7 -0
  62. package/build-module/tests/validation.test.js +451 -0
  63. package/build-module/tests/validation.test.js.map +7 -0
  64. package/build-module/types.js +1 -0
  65. package/build-module/types.js.map +7 -0
  66. package/build-module/validation.js +109 -0
  67. package/build-module/validation.js.map +7 -0
  68. package/build-types/api.d.ts +138 -0
  69. package/build-types/api.d.ts.map +1 -0
  70. package/build-types/index.d.ts +41 -0
  71. package/build-types/index.d.ts.map +1 -0
  72. package/build-types/store/actions.d.ts +76 -0
  73. package/build-types/store/actions.d.ts.map +1 -0
  74. package/build-types/store/constants.d.ts +14 -0
  75. package/build-types/store/constants.d.ts.map +1 -0
  76. package/build-types/store/index.d.ts +7 -0
  77. package/build-types/store/index.d.ts.map +1 -0
  78. package/build-types/store/reducer.d.ts +22 -0
  79. package/build-types/store/reducer.d.ts.map +1 -0
  80. package/build-types/store/resolvers.d.ts +47 -0
  81. package/build-types/store/resolvers.d.ts.map +1 -0
  82. package/build-types/store/selectors.d.ts +37 -0
  83. package/build-types/store/selectors.d.ts.map +1 -0
  84. package/build-types/types.d.ts +163 -0
  85. package/build-types/types.d.ts.map +1 -0
  86. package/build-types/validation.d.ts +23 -0
  87. package/build-types/validation.d.ts.map +1 -0
  88. package/package.json +52 -0
  89. package/src/api.ts +339 -0
  90. package/src/index.ts +65 -0
  91. package/src/store/actions.ts +247 -0
  92. package/src/store/constants.ts +15 -0
  93. package/src/store/index.ts +55 -0
  94. package/src/store/reducer.ts +184 -0
  95. package/src/store/resolvers.ts +151 -0
  96. package/src/store/selectors.ts +82 -0
  97. package/src/store/tests/actions.test.ts +950 -0
  98. package/src/store/tests/reducer.test.ts +861 -0
  99. package/src/store/tests/resolvers.test.ts +642 -0
  100. package/src/store/tests/selectors.test.ts +412 -0
  101. package/src/tests/api.test.ts +655 -0
  102. package/src/tests/validation.test.ts +532 -0
  103. package/src/types.ts +190 -0
  104. package/src/validation.ts +207 -0
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@wordpress/abilities",
3
+ "version": "0.1.1-next.dc3f6d3c1.0",
4
+ "description": "JavaScript client for WordPress Abilities API.",
5
+ "author": "The WordPress Contributors",
6
+ "license": "GPL-2.0-or-later",
7
+ "keywords": [
8
+ "wordpress",
9
+ "abilities",
10
+ "api",
11
+ "ai"
12
+ ],
13
+ "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/abilities/README.md",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/WordPress/gutenberg.git",
17
+ "directory": "packages/abilities"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/WordPress/gutenberg/issues"
21
+ },
22
+ "engines": {
23
+ "node": ">=18.12.0",
24
+ "npm": ">=8.19.2"
25
+ },
26
+ "files": [
27
+ "build",
28
+ "build-module",
29
+ "build-types",
30
+ "src",
31
+ "*.md"
32
+ ],
33
+ "main": "build/index.js",
34
+ "module": "build-module/index.js",
35
+ "react-native": "src/index",
36
+ "wpScript": true,
37
+ "types": "build-types",
38
+ "dependencies": {
39
+ "@wordpress/api-fetch": "^7.35.1-next.dc3f6d3c1.0",
40
+ "@wordpress/core-data": "^7.35.1-next.dc3f6d3c1.0",
41
+ "@wordpress/data": "^10.35.1-next.dc3f6d3c1.0",
42
+ "@wordpress/i18n": "^6.8.1-next.dc3f6d3c1.0",
43
+ "@wordpress/url": "^4.35.1-next.dc3f6d3c1.0",
44
+ "ajv": "^8.17.1",
45
+ "ajv-draft-04": "^1.0.0",
46
+ "ajv-formats": "^3.0.1"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "gitHead": "f73b5e69b34fbaccfb8c47783f4f993059ff1a41"
52
+ }
package/src/api.ts ADDED
@@ -0,0 +1,339 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { dispatch, resolveSelect } from '@wordpress/data';
5
+ import apiFetch from '@wordpress/api-fetch';
6
+ import { addQueryArgs } from '@wordpress/url';
7
+ import { sprintf } from '@wordpress/i18n';
8
+
9
+ /**
10
+ * Internal dependencies
11
+ */
12
+ import { store } from './store';
13
+ import type {
14
+ Ability,
15
+ AbilityCategory,
16
+ AbilityCategoryArgs,
17
+ AbilitiesQueryArgs,
18
+ AbilityInput,
19
+ AbilityOutput,
20
+ } from './types';
21
+ import { validateValueFromSchema } from './validation';
22
+
23
+ /**
24
+ * Get all available abilities with optional filtering.
25
+ *
26
+ * @param args Optional query arguments to filter. Defaults to empty object.
27
+ * @return Promise resolving to array of abilities.
28
+ */
29
+ export async function getAbilities(
30
+ args: AbilitiesQueryArgs = {}
31
+ ): Promise< Ability[] > {
32
+ return await resolveSelect( store ).getAbilities( args );
33
+ }
34
+
35
+ /**
36
+ * Get a specific ability by name.
37
+ *
38
+ * @param name The ability name.
39
+ * @return Promise resolving to the ability or null if not found.
40
+ */
41
+ export async function getAbility( name: string ): Promise< Ability | null > {
42
+ return await resolveSelect( store ).getAbility( name );
43
+ }
44
+
45
+ /**
46
+ * Get all available ability categories.
47
+ *
48
+ * @return Promise resolving to array of categories.
49
+ */
50
+ export async function getAbilityCategories(): Promise< AbilityCategory[] > {
51
+ return await resolveSelect( store ).getAbilityCategories();
52
+ }
53
+
54
+ /**
55
+ * Get a specific ability category by slug.
56
+ *
57
+ * @param slug The category slug.
58
+ * @return Promise resolving to the category or null if not found.
59
+ */
60
+ export async function getAbilityCategory(
61
+ slug: string
62
+ ): Promise< AbilityCategory | null > {
63
+ return await resolveSelect( store ).getAbilityCategory( slug );
64
+ }
65
+
66
+ /**
67
+ * Register a client-side ability.
68
+ *
69
+ * Client abilities are executed locally in the browser and must include
70
+ * a callback function. The ability will be validated by the store action,
71
+ * and an error will be thrown if validation fails.
72
+ *
73
+ * Categories will be automatically fetched from the REST API if they
74
+ * haven't been loaded yet, so you don't need to call getAbilityCategories()
75
+ * before registering abilities.
76
+ *
77
+ * @param ability The ability definition including callback.
78
+ * @return Promise that resolves when registration is complete.
79
+ * @throws {Error} If the ability fails validation.
80
+ *
81
+ * @example
82
+ * ```js
83
+ * await registerAbility({
84
+ * name: 'my-plugin/navigate',
85
+ * label: 'Navigate to URL',
86
+ * description: 'Navigates to a URL within WordPress admin',
87
+ * category: 'navigation',
88
+ * input_schema: {
89
+ * type: 'object',
90
+ * properties: {
91
+ * url: { type: 'string' }
92
+ * },
93
+ * required: ['url']
94
+ * },
95
+ * callback: async ({ url }) => {
96
+ * window.location.href = url;
97
+ * return { success: true };
98
+ * }
99
+ * });
100
+ * ```
101
+ */
102
+ export async function registerAbility( ability: Ability ): Promise< void > {
103
+ await dispatch( store ).registerAbility( ability );
104
+ }
105
+
106
+ /**
107
+ * Unregister an ability from the store.
108
+ *
109
+ * Remove a client-side ability from the store.
110
+ * Note: This will return an error for server-side abilities.
111
+ *
112
+ * @param name The ability name to unregister.
113
+ */
114
+ export function unregisterAbility( name: string ): void {
115
+ dispatch( store ).unregisterAbility( name );
116
+ }
117
+
118
+ /**
119
+ * Register a client-side ability category.
120
+ *
121
+ * Categories registered on the client are stored alongside server-side categories
122
+ * in the same store and can be used when registering client side abilities.
123
+ * This is useful when registering client-side abilities that introduce new
124
+ * categories not defined by the server.
125
+ *
126
+ * Categories will be automatically fetched from the REST API if they haven't been
127
+ * loaded yet to check for duplicates against server-side categories.
128
+ *
129
+ * @param slug Category slug (lowercase alphanumeric with dashes only).
130
+ * @param args Category arguments (label, description, optional meta).
131
+ * @return Promise that resolves when registration is complete.
132
+ * @throws {Error} If the category fails validation.
133
+ *
134
+ * @example
135
+ * ```js
136
+ * // Register a new category for block editor abilities
137
+ * await registerAbilityCategory('block-editor', {
138
+ * label: 'Block Editor',
139
+ * description: 'Abilities for interacting with the WordPress block editor'
140
+ * });
141
+ *
142
+ * // Then register abilities using this category
143
+ * await registerAbility({
144
+ * name: 'my-plugin/insert-block',
145
+ * label: 'Insert Block',
146
+ * description: 'Inserts a block into the editor',
147
+ * category: 'block-editor',
148
+ * callback: async ({ blockType }) => {
149
+ * // Implementation
150
+ * return { success: true };
151
+ * }
152
+ * });
153
+ * ```
154
+ */
155
+ export async function registerAbilityCategory(
156
+ slug: string,
157
+ args: AbilityCategoryArgs
158
+ ): Promise< void > {
159
+ await dispatch( store ).registerAbilityCategory( slug, args );
160
+ }
161
+
162
+ /**
163
+ * Unregister an ability category.
164
+ *
165
+ * Removes a category from the store.
166
+ *
167
+ * @param slug The category slug to unregister.
168
+ *
169
+ * @example
170
+ * ```js
171
+ * unregisterAbilityCategory('block-editor');
172
+ * ```
173
+ */
174
+ export function unregisterAbilityCategory( slug: string ): void {
175
+ dispatch( store ).unregisterAbilityCategory( slug );
176
+ }
177
+
178
+ /**
179
+ * Execute a client-side ability.
180
+ *
181
+ * @param ability The ability to execute.
182
+ * @param input Input parameters for the ability.
183
+ * @return Promise resolving to the ability execution result.
184
+ * @throws Error if validation fails or execution errors.
185
+ */
186
+ async function executeClientAbility(
187
+ ability: Ability,
188
+ input: AbilityInput
189
+ ): Promise< AbilityOutput > {
190
+ if ( ! ability.callback ) {
191
+ throw new Error(
192
+ sprintf(
193
+ 'Client ability %s is missing callback function',
194
+ ability.name
195
+ )
196
+ );
197
+ }
198
+
199
+ // Check permission callback if defined
200
+ if ( ability.permissionCallback ) {
201
+ const hasPermission = await ability.permissionCallback( input );
202
+ if ( ! hasPermission ) {
203
+ const error = new Error(
204
+ sprintf( 'Permission denied for ability: %s', ability.name )
205
+ );
206
+ ( error as any ).code = 'ability_permission_denied';
207
+ throw error;
208
+ }
209
+ }
210
+
211
+ if ( ability.input_schema ) {
212
+ const inputValidation = validateValueFromSchema(
213
+ input,
214
+ ability.input_schema,
215
+ 'input'
216
+ );
217
+ if ( inputValidation !== true ) {
218
+ const error = new Error(
219
+ sprintf(
220
+ 'Ability "%1$s" has invalid input. Reason: %2$s',
221
+ ability.name,
222
+ inputValidation
223
+ )
224
+ );
225
+ ( error as any ).code = 'ability_invalid_input';
226
+ throw error;
227
+ }
228
+ }
229
+
230
+ let result: AbilityOutput;
231
+ try {
232
+ result = await ability.callback( input );
233
+ } catch ( error ) {
234
+ // eslint-disable-next-line no-console
235
+ console.error(
236
+ `Error executing client ability ${ ability.name }:`,
237
+ error
238
+ );
239
+ throw error;
240
+ }
241
+
242
+ if ( ability.output_schema ) {
243
+ const outputValidation = validateValueFromSchema(
244
+ result,
245
+ ability.output_schema,
246
+ 'output'
247
+ );
248
+ if ( outputValidation !== true ) {
249
+ const error = new Error(
250
+ sprintf(
251
+ 'Ability "%1$s" has invalid output. Reason: %2$s',
252
+ ability.name,
253
+ outputValidation
254
+ )
255
+ );
256
+ ( error as any ).code = 'ability_invalid_output';
257
+ throw error;
258
+ }
259
+ }
260
+
261
+ return result;
262
+ }
263
+
264
+ /**
265
+ * Execute a server-side ability.
266
+ *
267
+ * @param ability The ability to execute.
268
+ * @param input Input parameters for the ability.
269
+ * @return Promise resolving to the ability execution result.
270
+ * @throws Error if the API call fails.
271
+ */
272
+ async function executeServerAbility(
273
+ ability: Ability,
274
+ input: AbilityInput
275
+ ): Promise< AbilityOutput > {
276
+ let method = 'POST';
277
+ if ( !! ability.meta?.annotations?.readonly ) {
278
+ method = 'GET';
279
+ } else if (
280
+ !! ability.meta?.annotations?.destructive &&
281
+ !! ability.meta?.annotations?.idempotent
282
+ ) {
283
+ method = 'DELETE';
284
+ }
285
+
286
+ let path = `/wp-abilities/v1/abilities/${ ability.name }/run`;
287
+ const options: {
288
+ method: string;
289
+ data?: { input: AbilityInput };
290
+ } = {
291
+ method,
292
+ };
293
+
294
+ if ( [ 'GET', 'DELETE' ].includes( method ) && input !== null ) {
295
+ // For GET and DELETE requests, pass the input directly.
296
+ path = addQueryArgs( path, { input } );
297
+ } else if ( method === 'POST' && input !== null ) {
298
+ options.data = { input };
299
+ }
300
+
301
+ // Note: Input and output validation happens on the server side for these abilities.
302
+ try {
303
+ return await apiFetch< AbilityOutput >( {
304
+ path,
305
+ ...options,
306
+ } );
307
+ } catch ( error ) {
308
+ // eslint-disable-next-line no-console
309
+ console.error( `Error executing ability ${ ability.name }:`, error );
310
+ throw error;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Execute an ability.
316
+ *
317
+ * Determines whether to execute locally (client abilities) or remotely (server abilities)
318
+ * based on whether the ability has a callback function.
319
+ *
320
+ * @param name The ability name.
321
+ * @param input Optional input parameters for the ability.
322
+ * @return Promise resolving to the ability execution result.
323
+ * @throws Error if the ability is not found or execution fails.
324
+ */
325
+ export async function executeAbility(
326
+ name: string,
327
+ input?: AbilityInput
328
+ ): Promise< AbilityOutput > {
329
+ const ability = await getAbility( name );
330
+ if ( ! ability ) {
331
+ throw new Error( sprintf( 'Ability not found: %s', name ) );
332
+ }
333
+
334
+ if ( ability.callback ) {
335
+ return executeClientAbility( ability, input );
336
+ }
337
+
338
+ return executeServerAbility( ability, input );
339
+ }
package/src/index.ts ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * WordPress Abilities API Client
3
+ *
4
+ * This package provides a client for interacting with the
5
+ * WordPress Abilities API, allowing you to list, retrieve, and execute
6
+ * abilities from client-side code.
7
+ *
8
+ * @package
9
+ */
10
+
11
+ /**
12
+ * Public API functions
13
+ */
14
+ export {
15
+ getAbilities,
16
+ getAbility,
17
+ getAbilityCategories,
18
+ getAbilityCategory,
19
+ executeAbility,
20
+ registerAbility,
21
+ unregisterAbility,
22
+ registerAbilityCategory,
23
+ unregisterAbilityCategory,
24
+ } from './api';
25
+
26
+ /**
27
+ * The store can be used directly with @wordpress/data via selectors
28
+ * in React components with useSelect.
29
+ *
30
+ * @example
31
+ * ```js
32
+ * import { useSelect } from '@wordpress/data';
33
+ * import { store as abilitiesStore } from '@wordpress/abilities';
34
+ *
35
+ * function MyComponent() {
36
+ * const abilities = useSelect(
37
+ * (select) => select(abilitiesStore).getAbilities(),
38
+ * []
39
+ * );
40
+ * // Use abilities...
41
+ * }
42
+ * ```
43
+ */
44
+ export { store } from './store';
45
+
46
+ /**
47
+ * Type definitions
48
+ */
49
+ export type {
50
+ Ability,
51
+ AbilityCategory,
52
+ AbilityCategoryArgs,
53
+ AbilitiesState,
54
+ AbilitiesQueryArgs,
55
+ AbilityCallback,
56
+ PermissionCallback,
57
+ AbilityInput,
58
+ AbilityOutput,
59
+ ValidationError,
60
+ } from './types';
61
+
62
+ /**
63
+ * Validation utilities
64
+ */
65
+ export { validateValueFromSchema } from './validation';
@@ -0,0 +1,247 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { sprintf } from '@wordpress/i18n';
5
+ import { resolveSelect } from '@wordpress/data';
6
+
7
+ /**
8
+ * Internal dependencies
9
+ */
10
+ import type { Ability, AbilityCategory, AbilityCategoryArgs } from '../types';
11
+ import {
12
+ RECEIVE_ABILITIES,
13
+ REGISTER_ABILITY,
14
+ UNREGISTER_ABILITY,
15
+ RECEIVE_CATEGORIES,
16
+ REGISTER_ABILITY_CATEGORY,
17
+ UNREGISTER_ABILITY_CATEGORY,
18
+ STORE_NAME,
19
+ } from './constants';
20
+
21
+ /**
22
+ * Returns an action object used to receive abilities into the store.
23
+ *
24
+ * @param abilities Array of abilities to store.
25
+ * @return Action object.
26
+ */
27
+ export function receiveAbilities( abilities: Ability[] ) {
28
+ return {
29
+ type: RECEIVE_ABILITIES,
30
+ abilities,
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Returns an action object used to receive categories into the store.
36
+ *
37
+ * @param categories Array of categories to store.
38
+ * @return Action object.
39
+ */
40
+ export function receiveCategories( categories: AbilityCategory[] ) {
41
+ return {
42
+ type: RECEIVE_CATEGORIES,
43
+ categories,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Registers an ability in the store.
49
+ *
50
+ * This action validates the ability before registration. If validation fails,
51
+ * an error will be thrown. Categories will be automatically fetched from the
52
+ * REST API if they haven't been loaded yet.
53
+ *
54
+ * @param ability The ability to register.
55
+ * @return Action object or function.
56
+ * @throws {Error} If validation fails.
57
+ */
58
+ export function registerAbility( ability: Ability ) {
59
+ // @ts-expect-error - registry types are not yet available
60
+ return async ( { select, dispatch } ) => {
61
+ if ( ! ability.name ) {
62
+ throw new Error( 'Ability name is required' );
63
+ }
64
+
65
+ // Validate name format matches server implementation
66
+ if ( ! /^[a-z0-9-]+\/[a-z0-9-]+$/.test( ability.name ) ) {
67
+ throw new Error(
68
+ 'Ability name must be a string containing a namespace prefix, i.e. "my-plugin/my-ability". It can only contain lowercase alphanumeric characters, dashes and the forward slash.'
69
+ );
70
+ }
71
+
72
+ if ( ! ability.label ) {
73
+ throw new Error(
74
+ sprintf( 'Ability "%s" must have a label', ability.name )
75
+ );
76
+ }
77
+
78
+ if ( ! ability.description ) {
79
+ throw new Error(
80
+ sprintf( 'Ability "%s" must have a description', ability.name )
81
+ );
82
+ }
83
+
84
+ if ( ! ability.category ) {
85
+ throw new Error(
86
+ sprintf( 'Ability "%s" must have a category', ability.name )
87
+ );
88
+ }
89
+
90
+ // Validate category format
91
+ if ( ! /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test( ability.category ) ) {
92
+ throw new Error(
93
+ sprintf(
94
+ 'Ability "%1$s" has an invalid category. Category must be lowercase alphanumeric with dashes only Got: "%2$s"',
95
+ ability.name,
96
+ ability.category
97
+ )
98
+ );
99
+ }
100
+
101
+ // Ensure categories are loaded before validating
102
+ const categories =
103
+ await resolveSelect( STORE_NAME ).getAbilityCategories();
104
+ const existingCategory = categories.find(
105
+ ( cat: AbilityCategory ) => cat.slug === ability.category
106
+ );
107
+ if ( ! existingCategory ) {
108
+ throw new Error(
109
+ sprintf(
110
+ 'Ability "%1$s" references non-existent category "%2$s". Please register the category first.',
111
+ ability.name,
112
+ ability.category
113
+ )
114
+ );
115
+ }
116
+
117
+ // Client-side abilities must have a callback
118
+ if ( ability.callback && typeof ability.callback !== 'function' ) {
119
+ throw new Error(
120
+ sprintf(
121
+ 'Ability "%s" has an invalid callback. Callback must be a function',
122
+ ability.name
123
+ )
124
+ );
125
+ }
126
+
127
+ // Check if ability is already registered
128
+ const existingAbility = select.getAbility( ability.name );
129
+ if ( existingAbility ) {
130
+ throw new Error(
131
+ sprintf( 'Ability "%s" is already registered', ability.name )
132
+ );
133
+ }
134
+
135
+ // All validation passed, dispatch the registration action
136
+ dispatch( {
137
+ type: REGISTER_ABILITY,
138
+ ability,
139
+ } );
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Returns an action object used to unregister a client-side ability.
145
+ *
146
+ * @param name The name of the ability to unregister.
147
+ * @return Action object.
148
+ */
149
+ export function unregisterAbility( name: string ) {
150
+ return {
151
+ type: UNREGISTER_ABILITY,
152
+ name,
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Registers a client-side ability category in the store.
158
+ *
159
+ * This action validates the category before registration. If validation fails,
160
+ * an error will be thrown. Categories will be automatically fetched from the
161
+ * REST API if they haven't been loaded yet to check for duplicates.
162
+ *
163
+ * @param slug The unique category slug identifier.
164
+ * @param args Category arguments (label, description, optional meta).
165
+ * @return Action object or function.
166
+ * @throws {Error} If validation fails.
167
+ */
168
+ export function registerAbilityCategory(
169
+ slug: string,
170
+ args: AbilityCategoryArgs
171
+ ) {
172
+ // @ts-expect-error - registry types are not yet available
173
+ return async ( { select, dispatch } ) => {
174
+ if ( ! slug ) {
175
+ throw new Error( 'Category slug is required' );
176
+ }
177
+
178
+ // Validate slug format matches server implementation
179
+ if ( ! /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test( slug ) ) {
180
+ throw new Error(
181
+ 'Category slug must contain only lowercase alphanumeric characters and dashes.'
182
+ );
183
+ }
184
+
185
+ // Ensure categories are loaded before checking for duplicates
186
+ await resolveSelect( STORE_NAME ).getAbilityCategories();
187
+ const existingCategory = select.getAbilityCategory( slug );
188
+ if ( existingCategory ) {
189
+ throw new Error(
190
+ sprintf( 'Category "%s" is already registered.', slug )
191
+ );
192
+ }
193
+
194
+ // Validate label presence and type (matches PHP empty() + is_string())
195
+ if ( ! args.label || typeof args.label !== 'string' ) {
196
+ throw new Error(
197
+ 'The category properties must contain a `label` string.'
198
+ );
199
+ }
200
+
201
+ // Validate description presence and type (matches PHP empty() + is_string())
202
+ if ( ! args.description || typeof args.description !== 'string' ) {
203
+ throw new Error(
204
+ 'The category properties must contain a `description` string.'
205
+ );
206
+ }
207
+
208
+ if (
209
+ args.meta !== undefined &&
210
+ ( typeof args.meta !== 'object' || Array.isArray( args.meta ) )
211
+ ) {
212
+ throw new Error(
213
+ 'The category properties should provide a valid `meta` object.'
214
+ );
215
+ }
216
+
217
+ const category: AbilityCategory = {
218
+ slug,
219
+ label: args.label,
220
+ description: args.description,
221
+ meta: {
222
+ ...( args.meta || {} ),
223
+ // Internal implementation note: Client-registered categories will have `meta._clientRegistered` set to `true` to differentiate them from server-fetched categories.
224
+ // This is used internally by the resolver to determine whether to fetch categories from the server.
225
+ _clientRegistered: true,
226
+ },
227
+ };
228
+
229
+ dispatch( {
230
+ type: REGISTER_ABILITY_CATEGORY,
231
+ category,
232
+ } );
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Returns an action object used to unregister a client-side ability category.
238
+ *
239
+ * @param slug The slug of the category to unregister.
240
+ * @return Action object.
241
+ */
242
+ export function unregisterAbilityCategory( slug: string ) {
243
+ return {
244
+ type: UNREGISTER_ABILITY_CATEGORY,
245
+ slug,
246
+ };
247
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Store constants
3
+ */
4
+ export const STORE_NAME = 'core/abilities';
5
+ export const ENTITY_KIND = 'root';
6
+ export const ENTITY_NAME = 'abilities';
7
+ export const ENTITY_NAME_CATEGORIES = 'ability-categories';
8
+
9
+ // Action types
10
+ export const RECEIVE_ABILITIES = 'RECEIVE_ABILITIES';
11
+ export const REGISTER_ABILITY = 'REGISTER_ABILITY';
12
+ export const UNREGISTER_ABILITY = 'UNREGISTER_ABILITY';
13
+ export const RECEIVE_CATEGORIES = 'RECEIVE_CATEGORIES';
14
+ export const REGISTER_ABILITY_CATEGORY = 'REGISTER_ABILITY_CATEGORY';
15
+ export const UNREGISTER_ABILITY_CATEGORY = 'UNREGISTER_ABILITY_CATEGORY';