@pattern-stack/frontend-patterns 0.2.0-alpha.0 → 0.2.0-alpha.1

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.
@@ -8,11 +8,15 @@
8
8
 
9
9
  import { promises as fs } from 'fs'
10
10
  import { join, dirname } from 'path'
11
+ import { fileURLToPath } from 'url'
11
12
  import { loadOpenAPISpec, parseOpenAPI } from '../../src/codegen/openapi/parser.js'
12
13
  import { generateTypes } from '../../src/codegen/openapi/type-generator.js'
13
14
  import { generateAPIClient } from '../../src/codegen/openapi/client-generator.js'
14
15
  import { generateHooks } from '../../src/codegen/openapi/hook-generator.js'
15
16
 
17
+ const __filename = fileURLToPath(import.meta.url)
18
+ const __dirname = dirname(__filename)
19
+
16
20
  interface GenerateHooksOptions {
17
21
  output: string
18
22
  prefix?: string
@@ -121,6 +125,11 @@ export async function generateHooksCommand(source: string, options: GenerateHook
121
125
  await writeFile(join(options.output, 'hooks', 'keys.ts'), hooks.keys)
122
126
  await writeFile(join(options.output, 'hooks', 'types.ts'), hooks.types)
123
127
  await writeFile(join(options.output, 'hooks', 'index.ts'), hooks.index)
128
+
129
+ // Copy bulk-types.ts to output directory (referenced by hooks/types.ts)
130
+ const bulkTypesPath = join(__dirname, '../../src/codegen/openapi/bulk-types.ts')
131
+ const bulkTypesContent = await fs.readFile(bulkTypesPath, 'utf8')
132
+ await writeFile(join(options.output, 'bulk-types.ts'), bulkTypesContent)
124
133
 
125
134
  // Write confidence report if available
126
135
  if (hooks.report) {
@@ -154,10 +163,10 @@ export { queryKeys } from './hooks'`
154
163
 
155
164
  await writeFile(join(options.output, 'index.ts'), mainIndex)
156
165
 
157
- // Generate usage example
166
+ // Generate usage example (as .tsx since it contains JSX)
158
167
  const usageExample = generateUsageExample(spec.info.title, options)
159
- await writeFile(join(options.output, 'example.ts'), usageExample)
160
-
168
+ await writeFile(join(options.output, 'example.tsx'), usageExample)
169
+
161
170
  console.log()
162
171
  console.log('✅ Generation complete!')
163
172
  console.log(`📦 Generated files in: ${options.output}`)
@@ -165,7 +174,7 @@ export { queryKeys } from './hooks'`
165
174
  console.log('🚀 Quick start:')
166
175
  console.log(`import { createAPIClient, useGetUsers } from './${options.output}'`)
167
176
  console.log()
168
- console.log('💡 See example.ts for detailed usage instructions')
177
+ console.log('💡 See example.tsx for detailed usage instructions')
169
178
 
170
179
  } catch (error) {
171
180
  console.error('❌ Generation failed:')
@@ -0,0 +1,367 @@
1
+ import { singularize, pluralize, isPlural } from '../naming-utils.js';
2
+
3
+ describe('singularize()', () => {
4
+ describe('regular plurals', () => {
5
+ it('should convert cats to cat', () => {
6
+ expect(singularize('cats')).toBe('cat');
7
+ });
8
+
9
+ it('should convert dogs to dog', () => {
10
+ expect(singularize('dogs')).toBe('dog');
11
+ });
12
+
13
+ it('should convert users to user', () => {
14
+ expect(singularize('users')).toBe('user');
15
+ });
16
+
17
+ it('should convert accounts to account', () => {
18
+ expect(singularize('accounts')).toBe('account');
19
+ });
20
+ });
21
+
22
+ describe('-ies plurals', () => {
23
+ it('should convert activities to activity', () => {
24
+ expect(singularize('activities')).toBe('activity');
25
+ });
26
+
27
+ it('should convert categories to category', () => {
28
+ expect(singularize('categories')).toBe('category');
29
+ });
30
+
31
+ it('should convert companies to company', () => {
32
+ expect(singularize('companies')).toBe('company');
33
+ });
34
+
35
+ it('should convert opportunities to opportunity', () => {
36
+ expect(singularize('opportunities')).toBe('opportunity');
37
+ });
38
+ });
39
+
40
+ describe('-es plurals', () => {
41
+ it('should convert boxes to box', () => {
42
+ expect(singularize('boxes')).toBe('box');
43
+ });
44
+
45
+ it('should convert watches to watch', () => {
46
+ expect(singularize('watches')).toBe('watch');
47
+ });
48
+
49
+ it('should convert dishes to dish', () => {
50
+ expect(singularize('dishes')).toBe('dish');
51
+ });
52
+
53
+ it('should convert benches to bench', () => {
54
+ expect(singularize('benches')).toBe('bench');
55
+ });
56
+ });
57
+
58
+ describe('-ses plurals (tricky cases)', () => {
59
+ it('should convert addresses to address', () => {
60
+ expect(singularize('addresses')).toBe('address');
61
+ });
62
+
63
+ it('should convert businesses to business', () => {
64
+ expect(singularize('businesses')).toBe('business');
65
+ });
66
+
67
+ it('should convert processes to process', () => {
68
+ expect(singularize('processes')).toBe('process');
69
+ });
70
+ });
71
+
72
+ describe('irregular plurals', () => {
73
+ it('should convert people to person', () => {
74
+ expect(singularize('people')).toBe('person');
75
+ });
76
+
77
+ it('should convert children to child', () => {
78
+ expect(singularize('children')).toBe('child');
79
+ });
80
+
81
+ it('should convert men to man', () => {
82
+ expect(singularize('men')).toBe('man');
83
+ });
84
+
85
+ it('should convert women to woman', () => {
86
+ expect(singularize('women')).toBe('woman');
87
+ });
88
+ });
89
+
90
+ describe('already singular', () => {
91
+ it('should keep data as data', () => {
92
+ expect(singularize('data')).toBe('data');
93
+ });
94
+
95
+ it('should keep metadata as metadata', () => {
96
+ expect(singularize('metadata')).toBe('metadata');
97
+ });
98
+
99
+ it('should keep user as user', () => {
100
+ expect(singularize('user')).toBe('user');
101
+ });
102
+
103
+ it('should keep account as account', () => {
104
+ expect(singularize('account')).toBe('account');
105
+ });
106
+ });
107
+
108
+ describe('edge cases', () => {
109
+ it('should keep status as status', () => {
110
+ expect(singularize('status')).toBe('status');
111
+ });
112
+
113
+ it('should keep class as class', () => {
114
+ expect(singularize('class')).toBe('class');
115
+ });
116
+
117
+ it('should keep bus as bus', () => {
118
+ expect(singularize('bus')).toBe('bus');
119
+ });
120
+
121
+ it('should convert pass to pass (not pas)', () => {
122
+ expect(singularize('pass')).toBe('pass');
123
+ });
124
+
125
+ it('should handle empty string', () => {
126
+ expect(singularize('')).toBe('');
127
+ });
128
+
129
+ it('should handle single character', () => {
130
+ expect(singularize('a')).toBe('a');
131
+ });
132
+ });
133
+ });
134
+
135
+ describe('pluralize()', () => {
136
+ describe('regular plurals', () => {
137
+ it('should convert cat to cats', () => {
138
+ expect(pluralize('cat')).toBe('cats');
139
+ });
140
+
141
+ it('should convert dog to dogs', () => {
142
+ expect(pluralize('dog')).toBe('dogs');
143
+ });
144
+
145
+ it('should convert user to users', () => {
146
+ expect(pluralize('user')).toBe('users');
147
+ });
148
+
149
+ it('should convert account to accounts', () => {
150
+ expect(pluralize('account')).toBe('accounts');
151
+ });
152
+ });
153
+
154
+ describe('-y endings (consonant + y)', () => {
155
+ it('should convert activity to activities', () => {
156
+ expect(pluralize('activity')).toBe('activities');
157
+ });
158
+
159
+ it('should convert category to categories', () => {
160
+ expect(pluralize('category')).toBe('categories');
161
+ });
162
+
163
+ it('should convert company to companies', () => {
164
+ expect(pluralize('company')).toBe('companies');
165
+ });
166
+
167
+ it('should convert opportunity to opportunities', () => {
168
+ expect(pluralize('opportunity')).toBe('opportunities');
169
+ });
170
+ });
171
+
172
+ describe('vowel + y endings', () => {
173
+ it('should convert day to days', () => {
174
+ expect(pluralize('day')).toBe('days');
175
+ });
176
+
177
+ it('should convert key to keys', () => {
178
+ expect(pluralize('key')).toBe('keys');
179
+ });
180
+
181
+ it('should convert boy to boys', () => {
182
+ expect(pluralize('boy')).toBe('boys');
183
+ });
184
+
185
+ it('should convert array to arrays', () => {
186
+ expect(pluralize('array')).toBe('arrays');
187
+ });
188
+ });
189
+
190
+ describe('-x, -ch, -sh, -s, -ss endings', () => {
191
+ it('should convert box to boxes', () => {
192
+ expect(pluralize('box')).toBe('boxes');
193
+ });
194
+
195
+ it('should convert watch to watches', () => {
196
+ expect(pluralize('watch')).toBe('watches');
197
+ });
198
+
199
+ it('should convert dish to dishes', () => {
200
+ expect(pluralize('dish')).toBe('dishes');
201
+ });
202
+
203
+ it('should convert bench to benches', () => {
204
+ expect(pluralize('bench')).toBe('benches');
205
+ });
206
+
207
+ it('should convert address to addresses', () => {
208
+ expect(pluralize('address')).toBe('addresses');
209
+ });
210
+
211
+ it('should convert business to businesses', () => {
212
+ expect(pluralize('business')).toBe('businesses');
213
+ });
214
+
215
+ it('should convert class to classes', () => {
216
+ expect(pluralize('class')).toBe('classes');
217
+ });
218
+ });
219
+
220
+ describe('irregular plurals', () => {
221
+ it('should convert person to people', () => {
222
+ expect(pluralize('person')).toBe('people');
223
+ });
224
+
225
+ it('should convert child to children', () => {
226
+ expect(pluralize('child')).toBe('children');
227
+ });
228
+
229
+ it('should convert man to men', () => {
230
+ expect(pluralize('man')).toBe('men');
231
+ });
232
+
233
+ it('should convert woman to women', () => {
234
+ expect(pluralize('woman')).toBe('women');
235
+ });
236
+ });
237
+
238
+ describe('already plural (no double-pluralize)', () => {
239
+ it('should keep accounts as accounts', () => {
240
+ expect(pluralize('accounts')).toBe('accounts');
241
+ });
242
+
243
+ it('should keep users as users', () => {
244
+ expect(pluralize('users')).toBe('users');
245
+ });
246
+
247
+ it('should keep activities as activities', () => {
248
+ expect(pluralize('activities')).toBe('activities');
249
+ });
250
+
251
+ it('should keep boxes as boxes', () => {
252
+ expect(pluralize('boxes')).toBe('boxes');
253
+ });
254
+ });
255
+
256
+ describe('edge cases', () => {
257
+ it('should handle empty string', () => {
258
+ expect(pluralize('')).toBe('');
259
+ });
260
+
261
+ it('should handle single character', () => {
262
+ expect(pluralize('a')).toBe('as');
263
+ });
264
+
265
+ it('should keep status as status (special case)', () => {
266
+ expect(pluralize('status')).toBe('status');
267
+ });
268
+ });
269
+ });
270
+
271
+ describe('isPlural()', () => {
272
+ describe('should return true for plural words', () => {
273
+ it('should identify cats as plural', () => {
274
+ expect(isPlural('cats')).toBe(true);
275
+ });
276
+
277
+ it('should identify activities as plural', () => {
278
+ expect(isPlural('activities')).toBe(true);
279
+ });
280
+
281
+ it('should identify boxes as plural', () => {
282
+ expect(isPlural('boxes')).toBe(true);
283
+ });
284
+
285
+ it('should identify watches as plural', () => {
286
+ expect(isPlural('watches')).toBe(true);
287
+ });
288
+
289
+ it('should identify addresses as plural', () => {
290
+ expect(isPlural('addresses')).toBe(true);
291
+ });
292
+
293
+ it('should identify people as plural', () => {
294
+ expect(isPlural('people')).toBe(true);
295
+ });
296
+
297
+ it('should identify children as plural', () => {
298
+ expect(isPlural('children')).toBe(true);
299
+ });
300
+
301
+ it('should identify users as plural', () => {
302
+ expect(isPlural('users')).toBe(true);
303
+ });
304
+
305
+ it('should identify accounts as plural', () => {
306
+ expect(isPlural('accounts')).toBe(true);
307
+ });
308
+ });
309
+
310
+ describe('should return false for singular words', () => {
311
+ it('should identify cat as singular', () => {
312
+ expect(isPlural('cat')).toBe(false);
313
+ });
314
+
315
+ it('should identify activity as singular', () => {
316
+ expect(isPlural('activity')).toBe(false);
317
+ });
318
+
319
+ it('should identify box as singular', () => {
320
+ expect(isPlural('box')).toBe(false);
321
+ });
322
+
323
+ it('should identify user as singular', () => {
324
+ expect(isPlural('user')).toBe(false);
325
+ });
326
+
327
+ it('should identify account as singular', () => {
328
+ expect(isPlural('account')).toBe(false);
329
+ });
330
+ });
331
+
332
+ describe('should return false for edge cases', () => {
333
+ it('should identify status as singular', () => {
334
+ expect(isPlural('status')).toBe(false);
335
+ });
336
+
337
+ it('should identify class as singular', () => {
338
+ expect(isPlural('class')).toBe(false);
339
+ });
340
+
341
+ it('should identify bus as singular', () => {
342
+ expect(isPlural('bus')).toBe(false);
343
+ });
344
+
345
+ it('should identify process as singular', () => {
346
+ expect(isPlural('process')).toBe(false);
347
+ });
348
+
349
+ it('should identify data as singular', () => {
350
+ expect(isPlural('data')).toBe(false);
351
+ });
352
+
353
+ it('should identify metadata as singular', () => {
354
+ expect(isPlural('metadata')).toBe(false);
355
+ });
356
+ });
357
+
358
+ describe('should handle empty/minimal input', () => {
359
+ it('should return false for empty string', () => {
360
+ expect(isPlural('')).toBe(false);
361
+ });
362
+
363
+ it('should return false for single character', () => {
364
+ expect(isPlural('a')).toBe(false);
365
+ });
366
+ });
367
+ });
@@ -6,6 +6,9 @@
6
6
  *
7
7
  * Part of FRO-12: API Client Generator
8
8
  */
9
+ import { singularize, pluralize, isPlural } from './naming-utils.js';
10
+ import { AUTH_ACTIONS, HEALTH_ENDPOINTS, USER_PROFILE_ENDPOINTS, SINGLETON_RESOURCES, COLLECTION_ACTIONS } from './naming-constants.js';
11
+
9
12
  export class APIClientGenerator {
10
13
  options;
11
14
  constructor(options = {}) {
@@ -39,7 +42,7 @@ export class APIClientGenerator {
39
42
  generateAxiosClient() {
40
43
  return `/**
41
44
  * Axios API Client
42
- *
45
+ *
43
46
  * Auto-generated API client with interceptors and error handling
44
47
  */
45
48
 
@@ -146,7 +149,7 @@ export class APIClient {
146
149
  generateFetchClient() {
147
150
  return `/**
148
151
  * Fetch API Client
149
- *
152
+ *
150
153
  * Auto-generated API client using native fetch with error handling
151
154
  */
152
155
 
@@ -166,7 +169,7 @@ export class APIClient {
166
169
  ): Promise<T> {
167
170
  try {
168
171
  const fullUrl = this.buildUrl(url, options.params)
169
-
172
+
170
173
  const fetchOptions: RequestInit = {
171
174
  method,
172
175
  headers: {
@@ -205,7 +208,7 @@ export class APIClient {
205
208
 
206
209
  private buildUrl(path: string, params?: Record<string, any>): string {
207
210
  const url = new URL(path, this.config.baseUrl)
208
-
211
+
209
212
  if (params) {
210
213
  Object.entries(params).forEach(([key, value]) => {
211
214
  if (value !== undefined && value !== null) {
@@ -325,7 +328,7 @@ export class APIClient {
325
328
  private shouldRetry(error: AxiosError): boolean {
326
329
  const retryCount = (error.config as any)?._retryCount || 0
327
330
  const maxRetries = this.config.retries || ${this.options.retries}
328
-
331
+
329
332
  return (
330
333
  retryCount < maxRetries &&
331
334
  (!error.response || error.response.status >= 500) &&
@@ -335,11 +338,11 @@ export class APIClient {
335
338
 
336
339
  private async retryRequest(config: any): Promise<any> {
337
340
  config._retryCount = (config._retryCount || 0) + 1
338
-
341
+
339
342
  // Exponential backoff
340
343
  const delay = Math.pow(2, config._retryCount) * 1000
341
344
  await new Promise(resolve => setTimeout(resolve, delay))
342
-
345
+
343
346
  return this.client.request(config)
344
347
  }`;
345
348
  }
@@ -500,7 +503,7 @@ export class APIClient {
500
503
  const authTypes = this.generateAuthTypes();
501
504
  return `/**
502
505
  * API Client Types
503
- *
506
+ *
504
507
  * Configuration and utility types for the generated API client
505
508
  */
506
509
 
@@ -556,7 +559,7 @@ export interface APIError {
556
559
  generateConfiguration() {
557
560
  return `/**
558
561
  * API Client Configuration
559
- *
562
+ *
560
563
  * Default configuration and factory functions
561
564
  */
562
565
 
@@ -580,7 +583,7 @@ export const defaultConfig: Partial<APIClientConfig> = {
580
583
  generateIndexFile() {
581
584
  return `/**
582
585
  * Generated API Client
583
- *
586
+ *
584
587
  * Auto-generated from OpenAPI specification
585
588
  */
586
589
 
@@ -593,20 +596,85 @@ export { createAPIClient, defaultConfig } from './config'`;
593
596
  generateFileHeader(title) {
594
597
  return `/**
595
598
  * ${title}
596
- *
599
+ *
597
600
  * Auto-generated from OpenAPI specification
598
601
  * Do not edit manually
599
602
  */`;
600
603
  }
601
604
  getMethodName(endpoint) {
602
- if (endpoint.operationId) {
603
- return this.camelCase(endpoint.operationId);
604
- }
605
- const pathParts = endpoint.path
606
- .split('/')
607
- .filter(part => part && !part.startsWith('{'))
608
- .map(part => this.capitalize(part));
609
- return this.camelCase(`${endpoint.method}_${pathParts.join('_')}`);
605
+ // Use semantic naming instead of raw operationId
606
+ return this.generateSemanticMethodName(endpoint);
607
+ }
608
+ generateSemanticMethodName(endpoint) {
609
+ // Extract resource from path
610
+ const pathSegments = endpoint.path.split('/').filter(s => s && !s.startsWith('{') && !['api', 'v1', 'v2'].includes(s));
611
+ const urlSegments = endpoint.path.split('/');
612
+ const hasPathParam = endpoint.path.includes('{');
613
+ const lastSegment = pathSegments[pathSegments.length - 1]?.replace(/-/g, '_') || 'resource';
614
+ const prevSegment = pathSegments[pathSegments.length - 2]?.replace(/-/g, '_');
615
+
616
+ // Auth endpoints - use action name directly
617
+ if (AUTH_ACTIONS.includes(lastSegment.toLowerCase())) {
618
+ return this.camelCase(lastSegment);
619
+ }
620
+
621
+ // Health/status singletons
622
+ if (HEALTH_ENDPOINTS.includes(lastSegment.toLowerCase())) {
623
+ return this.camelCase(lastSegment);
624
+ }
625
+
626
+ // User profile singletons
627
+ if (USER_PROFILE_ENDPOINTS.includes(lastSegment.toLowerCase())) {
628
+ return this.camelCase(`get_current_user`);
629
+ }
630
+
631
+ // Metadata endpoints - include parent resource to avoid collision
632
+ if (lastSegment.toLowerCase() === 'metadata' && prevSegment) {
633
+ return hasPathParam
634
+ ? this.camelCase(`get_${singularize(prevSegment)}_metadata`)
635
+ : this.camelCase(`get_${prevSegment}_metadata`);
636
+ }
637
+
638
+ // Collection actions (e.g., /activities/search)
639
+ if (COLLECTION_ACTIONS.includes(lastSegment.toLowerCase()) && prevSegment) {
640
+ return this.camelCase(`${lastSegment}_${prevSegment}`);
641
+ }
642
+
643
+ // Determine resource name - for nested paths, include context
644
+ let resource = lastSegment;
645
+ if (pathSegments.length > 2 && hasPathParam) {
646
+ // e.g., /accounts/{id}/stages/allowed → get_account_allowed_stages
647
+ const parentResource = pathSegments.find((s, i) => {
648
+ const nextSeg = urlSegments[urlSegments.findIndex(u => u === s) + 1];
649
+ return nextSeg?.startsWith('{');
650
+ });
651
+ if (parentResource && parentResource !== lastSegment) {
652
+ resource = `${singularize(parentResource)}_${lastSegment}`;
653
+ }
654
+ }
655
+
656
+ // Generate name based on HTTP method
657
+ switch (endpoint.method) {
658
+ case 'get':
659
+ return hasPathParam
660
+ ? this.camelCase(`get_${singularize(resource)}`)
661
+ : this.camelCase(`list_${resource}`);
662
+ case 'post':
663
+ // Check for custom actions like /accounts/{id}/stage
664
+ const lastUrl = urlSegments[urlSegments.length - 1];
665
+ const prevUrl = urlSegments[urlSegments.length - 2];
666
+ if (!lastUrl.startsWith('{') && prevUrl?.startsWith('{')) {
667
+ return this.camelCase(`${lastUrl}_${singularize(prevSegment || resource)}`);
668
+ }
669
+ return this.camelCase(`create_${singularize(resource)}`);
670
+ case 'put':
671
+ case 'patch':
672
+ return this.camelCase(`update_${singularize(resource)}`);
673
+ case 'delete':
674
+ return this.camelCase(`delete_${singularize(resource)}`);
675
+ default:
676
+ return this.camelCase(`${endpoint.method}_${resource}`);
677
+ }
610
678
  }
611
679
  generatePathWithParams(endpoint) {
612
680
  return endpoint.path.replace(/{([^}]+)}/g, '${$1}');
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Confidence Scorer (Stub)
3
+ *
4
+ * Scores generated hook names for quality/confidence.
5
+ * This is a minimal stub that returns high confidence for all names.
6
+ */
7
+
8
+ export class ConfidenceScorer {
9
+ constructor() {
10
+ this.scoringRules = [];
11
+ }
12
+
13
+ /**
14
+ * Score a generated hook name
15
+ * @returns {{ hookName: string, operationId: string, confidence: string, score: number, suggestions: string[] }}
16
+ */
17
+ scoreHookName(hookName, operationId, path, method) {
18
+ // Simple heuristic: shorter names are better
19
+ const lengthScore = Math.max(0, 100 - hookName.length);
20
+
21
+ // Check for common anti-patterns
22
+ const hasApiV1 = hookName.toLowerCase().includes('apiv1');
23
+ const hasUnderscore = hookName.includes('_');
24
+ const isVeryLong = hookName.length > 40;
25
+
26
+ let score = lengthScore;
27
+ if (hasApiV1) score -= 30;
28
+ if (hasUnderscore) score -= 10;
29
+ if (isVeryLong) score -= 20;
30
+
31
+ // Normalize to 0-100
32
+ score = Math.max(0, Math.min(100, score));
33
+
34
+ // Determine confidence level
35
+ let confidence;
36
+ if (score >= 70) confidence = 'high';
37
+ else if (score >= 40) confidence = 'medium';
38
+ else confidence = 'low';
39
+
40
+ return {
41
+ hookName,
42
+ operationId,
43
+ path,
44
+ method,
45
+ confidence,
46
+ score,
47
+ suggestions: confidence === 'low' ? [this.suggestBetterName(path, method)] : []
48
+ };
49
+ }
50
+
51
+ suggestBetterName(path, method) {
52
+ // Extract resource from path
53
+ const segments = path.split('/').filter(s => s && !s.startsWith('{') && !['api', 'v1'].includes(s));
54
+ const resource = segments[segments.length - 1]?.replace(/-/g, '') || 'resource';
55
+
56
+ // Capitalize first letter
57
+ const capitalizedResource = resource.charAt(0).toUpperCase() + resource.slice(1);
58
+
59
+ switch (method) {
60
+ case 'get':
61
+ return path.includes('{') ? `useGet${capitalizedResource}` : `use${capitalizedResource}`;
62
+ case 'post':
63
+ return `useCreate${capitalizedResource}`;
64
+ case 'put':
65
+ case 'patch':
66
+ return `useUpdate${capitalizedResource}`;
67
+ case 'delete':
68
+ return `useDelete${capitalizedResource}`;
69
+ default:
70
+ return `use${capitalizedResource}`;
71
+ }
72
+ }
73
+
74
+ generateReport(scoredNames) {
75
+ const high = scoredNames.filter(s => s.confidence === 'high').length;
76
+ const medium = scoredNames.filter(s => s.confidence === 'medium').length;
77
+ const low = scoredNames.filter(s => s.confidence === 'low').length;
78
+
79
+ return `
80
+ Hook Name Quality Report
81
+ ========================
82
+ Total hooks: ${scoredNames.length}
83
+ High confidence: ${high} (${Math.round(high/scoredNames.length*100)}%)
84
+ Medium confidence: ${medium} (${Math.round(medium/scoredNames.length*100)}%)
85
+ Low confidence: ${low} (${Math.round(low/scoredNames.length*100)}%)
86
+
87
+ ${low > 0 ? 'Low confidence hooks that may need review:\n' +
88
+ scoredNames.filter(s => s.confidence === 'low')
89
+ .map(s => ` - ${s.hookName} → suggested: ${s.suggestions[0]}`)
90
+ .join('\n') : 'All hooks have acceptable names!'}
91
+ `;
92
+ }
93
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Hook Configuration Manager (Stub)
3
+ *
4
+ * Provides name overrides and pattern configuration for hook generation.
5
+ * This is a minimal stub to unblock the hook generator.
6
+ */
7
+
8
+ export class HookConfigManager {
9
+ constructor(configPath) {
10
+ this.configPath = configPath;
11
+ this.overrides = {};
12
+ this.patterns = {
13
+ deduplicateSegments: true
14
+ };
15
+ this.reviewedNames = [];
16
+ this.statistics = {};
17
+ }
18
+
19
+ async load() {
20
+ // No-op for stub - would load from config file
21
+ return;
22
+ }
23
+
24
+ async save() {
25
+ // No-op for stub - would save to config file
26
+ return;
27
+ }
28
+
29
+ getOverride(operationId) {
30
+ return this.overrides[operationId] || null;
31
+ }
32
+
33
+ getPatterns() {
34
+ return this.patterns;
35
+ }
36
+
37
+ getReviewedNames() {
38
+ return this.reviewedNames;
39
+ }
40
+
41
+ updateStatistics(stats) {
42
+ this.statistics = { ...this.statistics, ...stats };
43
+ }
44
+
45
+ setOverride(operationId, hookName) {
46
+ this.overrides[operationId] = hookName;
47
+ }
48
+ }
@@ -6,8 +6,10 @@
6
6
  *
7
7
  * Part of FRO-3: React Hook Generator
8
8
  */
9
- import { HookConfigManager } from './hook-config';
10
- import { ConfidenceScorer } from './confidence-scorer';
9
+ import { HookConfigManager } from './hook-config.js';
10
+ import { ConfidenceScorer } from './confidence-scorer.js';
11
+ import { singularize, pluralize, isPlural } from './naming-utils.js';
12
+ import { AUTH_ACTIONS, HEALTH_ENDPOINTS, USER_PROFILE_ENDPOINTS, SINGLETON_RESOURCES, COLLECTION_ACTIONS, SPECIAL_ACTIONS } from './naming-constants.js';
11
13
  export class ReactHookGenerator {
12
14
  options;
13
15
  configManager;
@@ -203,13 +205,13 @@ export class ReactHookGenerator {
203
205
  return ` onMutate: async (newData) => {
204
206
  // Cancel outgoing refetches
205
207
  await queryClient.cancelQueries({ queryKey: queryKeys.all })
206
-
208
+
207
209
  // Snapshot previous value
208
210
  const previousData = queryClient.getQueryData(queryKeys.all)
209
-
211
+
210
212
  // Optimistically update cache
211
213
  queryClient.setQueryData(queryKeys.all, (old: any) => [...(old || []), newData])
212
-
214
+
213
215
  return { previousData }
214
216
  },
215
217
  onError: (err, newData, context) => {
@@ -220,14 +222,14 @@ export class ReactHookGenerator {
220
222
  case 'patch':
221
223
  return ` onMutate: async (updatedData) => {
222
224
  await queryClient.cancelQueries({ queryKey: queryKeys.all })
223
-
225
+
224
226
  const previousData = queryClient.getQueryData(queryKeys.all)
225
-
227
+
226
228
  // Update specific item in cache
227
- queryClient.setQueryData(queryKeys.all, (old: any) =>
229
+ queryClient.setQueryData(queryKeys.all, (old: any) =>
228
230
  old?.map((item: any) => item.id === updatedData.id ? { ...item, ...updatedData } : item)
229
231
  )
230
-
232
+
231
233
  return { previousData }
232
234
  },
233
235
  onError: (err, updatedData, context) => {
@@ -236,14 +238,14 @@ export class ReactHookGenerator {
236
238
  case 'delete':
237
239
  return ` onMutate: async (id) => {
238
240
  await queryClient.cancelQueries({ queryKey: queryKeys.all })
239
-
241
+
240
242
  const previousData = queryClient.getQueryData(queryKeys.all)
241
-
243
+
242
244
  // Remove item from cache
243
- queryClient.setQueryData(queryKeys.all, (old: any) =>
245
+ queryClient.setQueryData(queryKeys.all, (old: any) =>
244
246
  old?.filter((item: any) => item.id !== id)
245
247
  )
246
-
248
+
247
249
  return { previousData }
248
250
  },
249
251
  onError: (err, id, context) => {
@@ -275,18 +277,36 @@ export class ReactHookGenerator {
275
277
  // Group endpoints by resource/tag
276
278
  const groupedEndpoints = this.groupEndpointsByResource(endpoints);
277
279
  for (const [resource, resourceEndpoints] of Object.entries(groupedEndpoints)) {
280
+ // Sanitize resource name to be a valid JS identifier
281
+ const sanitizedResource = this.sanitizeIdentifier(resource);
282
+ console.log(`DEBUG: resource="${resource}" -> sanitizedResource="${sanitizedResource}"`);
278
283
  keys.push(` // ${resource} keys`);
279
- keys.push(` ${resource}: () => [...queryKeys.all, '${resource}'] as const,`);
284
+ keys.push(` ${sanitizedResource}: () => [...queryKeys.all, '${sanitizedResource}'] as const,`);
285
+ const usedKeys = new Set([sanitizedResource]); // Track used keys to avoid duplicates
280
286
  for (const endpoint of resourceEndpoints) {
281
287
  if (endpoint.method !== 'get')
282
288
  continue;
283
289
  const operationName = this.getOperationName(endpoint);
284
- const keyName = this.camelCase(operationName.replace(/^get/, ''));
290
+ let keyName = this.camelCase(operationName.replace(/^get/, ''));
291
+ // Skip if keyName matches sanitizedResource (base key already handles this)
292
+ if (keyName === sanitizedResource) {
293
+ continue;
294
+ }
295
+ // If keyName already used, add suffix to disambiguate
296
+ if (usedKeys.has(keyName)) {
297
+ const isList = !endpoint.path.includes('{');
298
+ keyName = isList ? `${keyName}List` : `${keyName}Detail`;
299
+ }
300
+ // Still duplicate after suffix? Skip to avoid compilation error
301
+ if (usedKeys.has(keyName)) {
302
+ continue;
303
+ }
304
+ usedKeys.add(keyName);
285
305
  if (this.hasRequiredParams(endpoint)) {
286
- keys.push(` ${keyName}: (params: any) => [...queryKeys.${resource}(), '${keyName}', params] as const,`);
306
+ keys.push(` ${keyName}: (params: Record<string, unknown>) => [...queryKeys.${sanitizedResource}(), '${keyName}', params] as const,`);
287
307
  }
288
308
  else {
289
- keys.push(` ${keyName}: () => [...queryKeys.${resource}(), '${keyName}'] as const,`);
309
+ keys.push(` ${keyName}: () => [...queryKeys.${sanitizedResource}(), '${keyName}'] as const,`);
290
310
  }
291
311
  }
292
312
  keys.push('');
@@ -336,7 +356,7 @@ export class ReactHookGenerator {
336
356
  generateFileHeader(title) {
337
357
  return `/**
338
358
  * ${title}
339
- *
359
+ *
340
360
  * Auto-generated React hooks from OpenAPI specification
341
361
  * Do not edit manually - regenerate using the hook generator
342
362
  */`;
@@ -422,6 +442,8 @@ export class ReactHookGenerator {
422
442
  /^(?<action>\w+?)_api_v\d+_(?<path>.+?)_(?<method>get|post|put|patch|delete)$/i,
423
443
  // Pattern: action_resource_path_method
424
444
  /^(?<action>\w+?)_(?<resource>\w+?)_(?<path>.+?)_(?<method>get|post|put|patch|delete)$/i,
445
+ // Pattern: simple action_action_method (e.g., ready_ready_get, health_health_get)
446
+ /^(?<action>\w+?)_\1_(?<method>get|post|put|patch|delete)$/i,
425
447
  // Pattern: simple action_resource
426
448
  /^(?<action>\w+?)_(?<resource>\w+?)$/i,
427
449
  ];
@@ -439,7 +461,12 @@ export class ReactHookGenerator {
439
461
  // Check if it's a custom action
440
462
  const urlSegments = endpoint.path.split('/');
441
463
  const lastSegment = urlSegments[urlSegments.length - 1];
442
- const isCustomAction = !lastSegment.startsWith('{') && urlSegments[urlSegments.length - 2]?.startsWith('{');
464
+ const prevSegment = urlSegments[urlSegments.length - 2];
465
+ // Custom action: either after a path param (e.g., /accounts/{id}/archive)
466
+ // or a known action verb on a collection (e.g., /activities/search)
467
+ const isResourceAction = !lastSegment.startsWith('{') && prevSegment?.startsWith('{');
468
+ const isCollectionAction = !lastSegment.startsWith('{') && !prevSegment?.startsWith('{') && COLLECTION_ACTIONS.includes(lastSegment);
469
+ const isCustomAction = isResourceAction || isCollectionAction;
443
470
  // Extract or infer the action verb
444
471
  const actionVerb = this.extractActionVerb(components.action || operationId, endpoint.method);
445
472
  // Extract or infer the resource
@@ -449,7 +476,7 @@ export class ReactHookGenerator {
449
476
  'resource';
450
477
  return {
451
478
  action: actionVerb,
452
- resource: resource,
479
+ resource: isCollectionAction ? prevSegment : resource,
453
480
  path: components.path || '',
454
481
  method: endpoint.method,
455
482
  isCustomAction,
@@ -463,10 +490,10 @@ export class ReactHookGenerator {
463
490
  extractActionVerb(actionPart, method) {
464
491
  // Common action verbs to recognize
465
492
  const actionVerbs = [
466
- 'login', 'logout', 'register', 'signup', 'signin',
467
- 'health', 'status', 'check',
493
+ ...AUTH_ACTIONS,
494
+ ...HEALTH_ENDPOINTS,
468
495
  'list', 'get', 'create', 'update', 'delete',
469
- 'reassign', 'assign', 'settle', 'reconcile',
496
+ ...COLLECTION_ACTIONS,
470
497
  'approve', 'reject', 'archive', 'restore',
471
498
  'sync', 'refresh', 'reset', 'verify', 'validate'
472
499
  ];
@@ -501,11 +528,7 @@ export class ReactHookGenerator {
501
528
  * Check if this is a special endpoint that should keep its action verb
502
529
  */
503
530
  isSpecialEndpoint(parsed) {
504
- const specialActions = [
505
- 'login', 'logout', 'register', 'signup', 'signin',
506
- 'health', 'status', 'check', 'verify', 'validate'
507
- ];
508
- return specialActions.includes(parsed.action);
531
+ return SPECIAL_ACTIONS.includes(parsed.action);
509
532
  }
510
533
  /**
511
534
  * Format name for custom action endpoints
@@ -519,17 +542,18 @@ export class ReactHookGenerator {
519
542
  return customActionName;
520
543
  }
521
544
  // Otherwise combine resource and action
522
- return `${this.singularize(resource)}_${customActionName}`;
545
+ return `${singularize(resource)}_${customActionName}`;
523
546
  }
524
- return `${action}_${this.singularize(resource)}`;
547
+ return `${action}_${singularize(resource)}`;
525
548
  }
526
549
  /**
527
550
  * Format name for special endpoints (login, health, etc.)
528
551
  */
529
552
  formatSpecialEndpointName(parsed) {
530
553
  const { action, resource } = parsed;
531
- // For endpoints like "login", "health", just return the action
532
- if (['login', 'logout', 'health', 'status'].includes(action)) {
554
+ // For simple singleton endpoints, just return the action
555
+ const simpleActions = [...AUTH_ACTIONS.filter(a => ['login', 'logout'].includes(a)), ...HEALTH_ENDPOINTS.filter(h => ['health', 'ready', 'status'].includes(h)), ...USER_PROFILE_ENDPOINTS];
556
+ if (simpleActions.includes(action)) {
533
557
  return action;
534
558
  }
535
559
  // For other special endpoints, combine with resource if meaningful
@@ -544,19 +568,19 @@ export class ReactHookGenerator {
544
568
  formatStandardOperationName(parsed, endpoint) {
545
569
  const { resource } = parsed;
546
570
  // Determine if it's a list or single resource operation
547
- const isList = endpoint.method === 'get' && !endpoint.path.includes('{');
571
+ const isList = endpoint.method === 'get' && !endpoint.path.includes('{') && !this.isSingletonEndpoint(resource, endpoint.path);
548
572
  switch (endpoint.method) {
549
573
  case 'get':
550
- return isList ? this.pluralize(resource) : this.singularize(resource);
574
+ return isList ? pluralize(resource) : singularize(resource);
551
575
  case 'post':
552
- return `create_${this.singularize(resource)}`;
576
+ return `create_${singularize(resource)}`;
553
577
  case 'put':
554
578
  case 'patch':
555
- return `update_${this.singularize(resource)}`;
579
+ return `update_${singularize(resource)}`;
556
580
  case 'delete':
557
- return `delete_${this.singularize(resource)}`;
581
+ return `delete_${singularize(resource)}`;
558
582
  default:
559
- return this.singularize(resource);
583
+ return singularize(resource);
560
584
  }
561
585
  }
562
586
  generateCleanName(endpoint) {
@@ -578,7 +602,7 @@ export class ReactHookGenerator {
578
602
  const action = lastSegment.replace(/-/g, '_');
579
603
  return `${mainResource}_${action}`;
580
604
  }
581
- // For nested resources (e.g., /categories/{id}/subcategories),
605
+ // For nested resources (e.g., /categories/{id}/subcategories),
582
606
  // just use the last resource name
583
607
  if (pathParts.length > 2 && segments.some(s => s.startsWith('{'))) {
584
608
  // This is a nested resource, just use the last part
@@ -591,7 +615,7 @@ export class ReactHookGenerator {
591
615
  const isList = !endpoint.path.includes('{');
592
616
  // Only pluralize if it's a list and not already plural
593
617
  if (isList && !cleanResource.endsWith('s') && !cleanResource.endsWith('ies')) {
594
- return this.pluralize(cleanResource);
618
+ return pluralize(cleanResource);
595
619
  }
596
620
  return cleanResource;
597
621
  case 'post':
@@ -605,28 +629,22 @@ export class ReactHookGenerator {
605
629
  return `${endpoint.method}_${cleanResource}`;
606
630
  }
607
631
  }
608
- pluralize(word) {
609
- // Simple pluralization rules
610
- if (word.endsWith('y') && !['ay', 'ey', 'iy', 'oy', 'uy'].includes(word.slice(-2))) {
611
- return word.slice(0, -1) + 'ies';
632
+ isSingletonEndpoint(resource, path) {
633
+ // Check if resource matches singleton pattern
634
+ const lowerResource = resource.toLowerCase().replace(/_/g, '');
635
+ if (SINGLETON_RESOURCES.some(s => lowerResource === s || lowerResource.endsWith(s))) {
636
+ return true;
612
637
  }
613
- if (word.endsWith('s') || word.endsWith('x') || word.endsWith('ch') || word.endsWith('sh')) {
614
- return word + 'es';
638
+
639
+ // Check path patterns for common singletons
640
+ const pathSegments = path.split('/').filter(s => s && !s.startsWith('{'));
641
+ const lastSegment = pathSegments[pathSegments.length - 1]?.toLowerCase();
642
+
643
+ if (SINGLETON_RESOURCES.includes(lastSegment)) {
644
+ return true;
615
645
  }
616
- return word + 's';
617
- }
618
- singularize(word) {
619
- // Simple singularization rules
620
- if (word.endsWith('ies')) {
621
- return word.slice(0, -3) + 'y';
622
- }
623
- if (word.endsWith('ses') || word.endsWith('xes') || word.endsWith('ches') || word.endsWith('shes')) {
624
- return word.slice(0, -2);
625
- }
626
- if (word.endsWith('s') && !word.endsWith('ss')) {
627
- return word.slice(0, -1);
628
- }
629
- return word;
646
+
647
+ return false;
630
648
  }
631
649
  hasRequiredParams(endpoint) {
632
650
  return endpoint.parameters.some(p => p.required) ||
@@ -663,6 +681,15 @@ export class ReactHookGenerator {
663
681
  }
664
682
  }
665
683
  isListEndpoint(endpoint) {
684
+ // Extract resource for singleton check
685
+ const pathSegments = endpoint.path.split('/').filter(s => s && !s.startsWith('{'));
686
+ const resource = pathSegments[pathSegments.length - 1]?.replace(/-/g, '_') || '';
687
+
688
+ // Singleton endpoints are never list endpoints
689
+ if (this.isSingletonEndpoint(resource, endpoint.path)) {
690
+ return false;
691
+ }
692
+
666
693
  return endpoint.path.includes('list') ||
667
694
  !endpoint.path.includes('{') ||
668
695
  (endpoint.summary?.toLowerCase().includes('list') ?? false) ||
@@ -671,7 +698,9 @@ export class ReactHookGenerator {
671
698
  groupEndpointsByResource(endpoints) {
672
699
  const groups = {};
673
700
  for (const endpoint of endpoints) {
674
- const resource = endpoint.tags?.[0] || 'default';
701
+ const tag = endpoint.tags?.[0] || 'default';
702
+ // Sanitize tag to be a valid JS identifier (camelCase, no spaces)
703
+ const resource = this.sanitizeIdentifier(tag);
675
704
  if (!groups[resource]) {
676
705
  groups[resource] = [];
677
706
  }
@@ -679,8 +708,17 @@ export class ReactHookGenerator {
679
708
  }
680
709
  return groups;
681
710
  }
711
+ sanitizeIdentifier(str) {
712
+ // Convert to camelCase and remove invalid characters
713
+ return str
714
+ .replace(/[^a-zA-Z0-9\s]/g, '') // Remove special chars except spaces
715
+ .split(/\s+/) // Split on whitespace
716
+ .map((word, i) => i === 0 ? word.toLowerCase() : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
717
+ .join('');
718
+ }
682
719
  getRelatedQueryTags(endpoint) {
683
- const resource = endpoint.tags?.[0] || 'default';
720
+ const tag = endpoint.tags?.[0] || 'default';
721
+ const resource = this.sanitizeIdentifier(tag);
684
722
  return [resource, 'all'];
685
723
  }
686
724
  camelCase(str) {
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Naming Constants
3
+ *
4
+ * Shared constants for magic strings used across hook and client generators.
5
+ * These constants help identify special endpoints that get unique naming treatment.
6
+ */
7
+
8
+ /**
9
+ * Authentication action endpoints
10
+ * These endpoints are named directly without CRUD prefixes (e.g., 'login' not 'createLogin')
11
+ */
12
+ export const AUTH_ACTIONS = [
13
+ 'login',
14
+ 'logout',
15
+ 'register',
16
+ 'signup',
17
+ 'signin',
18
+ 'refresh'
19
+ ];
20
+
21
+ /**
22
+ * Health/status check endpoints
23
+ * These are singleton endpoints that return system status
24
+ */
25
+ export const HEALTH_ENDPOINTS = [
26
+ 'health',
27
+ 'ready',
28
+ 'status',
29
+ 'info',
30
+ 'version'
31
+ ];
32
+
33
+ /**
34
+ * User profile endpoints
35
+ * These endpoints reference the current/authenticated user
36
+ */
37
+ export const USER_PROFILE_ENDPOINTS = [
38
+ 'me',
39
+ 'self',
40
+ 'profile',
41
+ 'current'
42
+ ];
43
+
44
+ /**
45
+ * Singleton resources
46
+ * Resources that return a single item, not a collection
47
+ * Includes all auth, health, and user profile endpoints plus common singleton patterns
48
+ */
49
+ export const SINGLETON_RESOURCES = [
50
+ 'me',
51
+ 'self',
52
+ 'current',
53
+ 'profile',
54
+ 'health',
55
+ 'ready',
56
+ 'status',
57
+ 'info',
58
+ 'version',
59
+ 'metadata',
60
+ 'config',
61
+ 'settings',
62
+ 'preferences',
63
+ 'summary',
64
+ 'stats',
65
+ 'statistics',
66
+ 'dashboard',
67
+ 'schema',
68
+ 'spec',
69
+ 'openapi'
70
+ ];
71
+
72
+ /**
73
+ * Collection action verbs
74
+ * Actions that operate on collections (e.g., /activities/search)
75
+ */
76
+ export const COLLECTION_ACTIONS = [
77
+ 'search',
78
+ 'export',
79
+ 'import',
80
+ 'bulk',
81
+ 'batch',
82
+ 'count',
83
+ 'stats',
84
+ 'validate'
85
+ ];
86
+
87
+ /**
88
+ * Special actions - combination of auth, health, and user profile endpoints
89
+ * These endpoints get special naming treatment and keep their action verb
90
+ */
91
+ export const SPECIAL_ACTIONS = [
92
+ ...AUTH_ACTIONS,
93
+ ...HEALTH_ENDPOINTS,
94
+ ...USER_PROFILE_ENDPOINTS,
95
+ 'check',
96
+ 'verify',
97
+ 'validate'
98
+ ];
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Shared naming utilities for OpenAPI code generation
3
+ * Handles singular/plural conversions with proper edge case handling
4
+ */
5
+
6
+ /**
7
+ * Map of irregular plural forms
8
+ */
9
+ export const IRREGULAR_PLURALS = {
10
+ person: 'people',
11
+ child: 'children',
12
+ data: 'data', // already plural
13
+ media: 'media', // already plural
14
+ metadata: 'metadata', // already plural
15
+ people: 'people', // already plural
16
+ children: 'children', // already plural
17
+ };
18
+
19
+ /**
20
+ * Map of irregular singular forms (reverse lookup)
21
+ */
22
+ const IRREGULAR_SINGULARS = {
23
+ people: 'person',
24
+ children: 'child',
25
+ data: 'data', // already singular
26
+ media: 'media', // already singular
27
+ metadata: 'metadata', // already singular
28
+ person: 'person', // already singular
29
+ child: 'child', // already singular
30
+ };
31
+
32
+ /**
33
+ * Words that naturally end in 's' but are singular
34
+ */
35
+ const SINGULAR_EXCEPTIONS = [
36
+ 'status',
37
+ 'class',
38
+ 'address',
39
+ 'access',
40
+ 'success',
41
+ 'process',
42
+ 'bus',
43
+ 'focus',
44
+ 'virus',
45
+ ];
46
+
47
+ /**
48
+ * Check if a word is already plural
49
+ * @param {string} word - The word to check
50
+ * @returns {boolean} - True if the word is plural
51
+ */
52
+ export function isPlural(word) {
53
+ // Check irregular plurals first
54
+ if (IRREGULAR_PLURALS[word.toLowerCase()]) {
55
+ return IRREGULAR_PLURALS[word.toLowerCase()] === word.toLowerCase();
56
+ }
57
+
58
+ // Check singular exceptions - these end in 's' but are not plural
59
+ if (SINGULAR_EXCEPTIONS.includes(word.toLowerCase())) {
60
+ return false;
61
+ }
62
+
63
+ // Check common plural endings
64
+ if (word.endsWith('ies') || word.endsWith('es') || word.endsWith('s')) {
65
+ return true;
66
+ }
67
+
68
+ return false;
69
+ }
70
+
71
+ /**
72
+ * Convert plural word to singular
73
+ * @param {string} word - The word to singularize
74
+ * @returns {string} - The singular form of the word
75
+ */
76
+ export function singularize(word) {
77
+ // Handle empty or short words
78
+ if (!word || word.length <= 1) {
79
+ return word;
80
+ }
81
+
82
+ // Check irregular singulars first
83
+ const lowerWord = word.toLowerCase();
84
+ if (IRREGULAR_SINGULARS[lowerWord]) {
85
+ return IRREGULAR_SINGULARS[lowerWord];
86
+ }
87
+
88
+ // Already singular (check exceptions)
89
+ if (SINGULAR_EXCEPTIONS.includes(lowerWord)) {
90
+ return word;
91
+ }
92
+
93
+ // -ies → -y (activities → activity)
94
+ if (word.endsWith('ies')) {
95
+ return word.slice(0, -3) + 'y';
96
+ }
97
+
98
+ // -ses → -s (processes → process, but 'process' is already singular)
99
+ // -xes → -x (boxes → box)
100
+ // -ches → -ch (watches → watch)
101
+ // -shes → -sh (dishes → dish)
102
+ if (word.endsWith('ses') || word.endsWith('xes') || word.endsWith('ches') || word.endsWith('shes')) {
103
+ return word.slice(0, -2);
104
+ }
105
+
106
+ // Regular -s ending (cats → cat)
107
+ // But avoid words ending in -ss (class, success, etc.)
108
+ if (word.endsWith('s') && !word.endsWith('ss') && word.length > 1) {
109
+ return word.slice(0, -1);
110
+ }
111
+
112
+ return word;
113
+ }
114
+
115
+ /**
116
+ * Convert singular word to plural
117
+ * @param {string} word - The word to pluralize
118
+ * @returns {string} - The plural form of the word
119
+ */
120
+ export function pluralize(word) {
121
+ // Handle empty or short words
122
+ if (!word || word.length <= 1) {
123
+ return word;
124
+ }
125
+
126
+ // Check if already plural
127
+ if (isPlural(word)) {
128
+ return word;
129
+ }
130
+
131
+ // Check irregular plurals first
132
+ const lowerWord = word.toLowerCase();
133
+ if (IRREGULAR_PLURALS[lowerWord]) {
134
+ return IRREGULAR_PLURALS[lowerWord];
135
+ }
136
+
137
+ // -y ending (not vowel+y) → -ies (activity → activities)
138
+ if (word.endsWith('y') && !['ay', 'ey', 'iy', 'oy', 'uy'].includes(word.slice(-2))) {
139
+ return word.slice(0, -1) + 'ies';
140
+ }
141
+
142
+ // -x, -ch, -sh endings → add 'es' (box → boxes, watch → watches, dish → dishes)
143
+ if (word.endsWith('x') || word.endsWith('ch') || word.endsWith('sh')) {
144
+ return word + 'es';
145
+ }
146
+
147
+ // Regular: add 's' (cat → cats, dog → dogs)
148
+ return word + 's';
149
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@pattern-stack/frontend-patterns",
3
3
  "description": "Production-ready React frontend template with atomic architecture patterns. Build ultra-lean applications by importing shared UI foundation patterns.",
4
4
  "private": false,
5
- "version": "0.2.0-alpha.0",
5
+ "version": "0.2.0-alpha.1",
6
6
  "keywords": [
7
7
  "react",
8
8
  "typescript",