@shepherdjerred/helm-types 1.1.0 → 1.2.0-dev.891

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.
@@ -1,5 +1,9 @@
1
- import type { JSONSchemaProperty, TypeScriptInterface, TypeProperty } from "./types.js";
2
- import type { HelmValue } from "./schemas.js";
1
+ import type {
2
+ JSONSchemaProperty,
3
+ TypeScriptInterface,
4
+ TypeProperty,
5
+ } from "./types.ts";
6
+ import type { HelmValue } from "./schemas.ts";
3
7
  import {
4
8
  StringSchema,
5
9
  ActualNumberSchema,
@@ -9,8 +13,12 @@ import {
9
13
  ArraySchema,
10
14
  HelmValueSchema,
11
15
  StringBooleanSchema,
12
- } from "./schemas.js";
13
- import { capitalizeFirst, sanitizePropertyName, sanitizeTypeName } from "./utils.js";
16
+ } from "./schemas.ts";
17
+ import {
18
+ capitalizeFirst,
19
+ sanitizePropertyName,
20
+ sanitizeTypeName,
21
+ } from "./utils.ts";
14
22
 
15
23
  /**
16
24
  * Convert JSON schema type to TypeScript type string
@@ -30,7 +38,11 @@ export function jsonSchemaToTypeScript(schema: JSONSchemaProperty): string {
30
38
 
31
39
  // Handle enum
32
40
  if (schema.enum) {
33
- return schema.enum.map((v) => (StringSchema.safeParse(v).success ? `"${String(v)}"` : String(v))).join(" | ");
41
+ return schema.enum
42
+ .map((v) =>
43
+ StringSchema.safeParse(v).success ? `"${String(v)}"` : String(v),
44
+ )
45
+ .join(" | ");
34
46
  }
35
47
 
36
48
  // Handle array type
@@ -40,8 +52,9 @@ export function jsonSchemaToTypeScript(schema: JSONSchemaProperty): string {
40
52
  }
41
53
 
42
54
  // Handle basic types
43
- if (StringSchema.safeParse(schema.type).success) {
44
- switch (schema.type) {
55
+ const stringTypeCheck = StringSchema.safeParse(schema.type);
56
+ if (stringTypeCheck.success) {
57
+ switch (stringTypeCheck.data) {
45
58
  case "string":
46
59
  return "string";
47
60
  case "number":
@@ -65,7 +78,9 @@ export function jsonSchemaToTypeScript(schema: JSONSchemaProperty): string {
65
78
  if (arrayTypeCheck.success) {
66
79
  return arrayTypeCheck.data
67
80
  .map((t: unknown) => {
68
- if (!StringSchema.safeParse(t).success) return "unknown";
81
+ if (!StringSchema.safeParse(t).success) {
82
+ return "unknown";
83
+ }
69
84
  const typeStr = String(t);
70
85
  switch (typeStr) {
71
86
  case "string":
@@ -96,7 +111,10 @@ export function jsonSchemaToTypeScript(schema: JSONSchemaProperty): string {
96
111
  */
97
112
  export function inferTypeFromValue(value: unknown): string | null {
98
113
  // Check null/undefined
99
- if (NullSchema.safeParse(value).success || UndefinedSchema.safeParse(value).success) {
114
+ if (
115
+ NullSchema.safeParse(value).success ||
116
+ UndefinedSchema.safeParse(value).success
117
+ ) {
100
118
  return null;
101
119
  }
102
120
 
@@ -119,7 +137,11 @@ export function inferTypeFromValue(value: unknown): string | null {
119
137
  const stringCheck = StringSchema.safeParse(value);
120
138
  if (stringCheck.success) {
121
139
  const trimmed = stringCheck.data.trim();
122
- if (trimmed !== "" && !isNaN(Number(trimmed)) && isFinite(Number(trimmed))) {
140
+ if (
141
+ trimmed !== "" &&
142
+ !Number.isNaN(Number(trimmed)) &&
143
+ Number.isFinite(Number(trimmed))
144
+ ) {
123
145
  return "number";
124
146
  }
125
147
  }
@@ -145,7 +167,10 @@ export function inferTypeFromValue(value: unknown): string | null {
145
167
  /**
146
168
  * Check if inferred type is compatible with schema type
147
169
  */
148
- export function typesAreCompatible(inferredType: string, schemaType: string): boolean {
170
+ export function typesAreCompatible(
171
+ inferredType: string,
172
+ schemaType: string,
173
+ ): boolean {
149
174
  // Exact match
150
175
  if (inferredType === schemaType) {
151
176
  return true;
@@ -153,7 +178,9 @@ export function typesAreCompatible(inferredType: string, schemaType: string): bo
153
178
 
154
179
  // Check if the inferred type is part of a union in the schema
155
180
  // For example: schemaType might be "number | \"default\"" and inferredType is "string"
156
- const schemaTypes = schemaType.split("|").map((t) => t.trim().replace(/^["']|["']$/g, ""));
181
+ const schemaTypes = schemaType
182
+ .split("|")
183
+ .map((t) => t.trim().replaceAll(/^["']|["']$/g, ""));
157
184
 
158
185
  // If schema is a union, check if inferred type is compatible with any part
159
186
  if (schemaTypes.length > 1) {
@@ -193,248 +220,329 @@ export function typesAreCompatible(inferredType: string, schemaType: string): bo
193
220
  /**
194
221
  * Convert Helm values to TypeScript interface
195
222
  */
196
- export function convertToTypeScriptInterface(
197
- values: HelmValue,
198
- interfaceName: string,
199
- schema?: JSONSchemaProperty | null,
200
- yamlComments?: Map<string, string>,
201
- keyPrefix = "",
202
- ): TypeScriptInterface {
223
+ export function convertToTypeScriptInterface(options: {
224
+ values: HelmValue;
225
+ interfaceName: string;
226
+ schema?: JSONSchemaProperty | null;
227
+ yamlComments?: Map<string, string>;
228
+ keyPrefix?: string;
229
+ }): TypeScriptInterface {
230
+ const keyPrefix = options.keyPrefix ?? "";
203
231
  const properties: Record<string, TypeProperty> = {};
204
- const schemaProps = schema?.properties;
232
+ const schemaProps = options.schema?.properties;
205
233
 
206
- for (const [key, value] of Object.entries(values)) {
234
+ for (const [key, value] of Object.entries(options.values)) {
207
235
  const sanitizedKey = sanitizePropertyName(key);
208
236
  const typeNameSuffix = sanitizeTypeName(key);
209
237
  const propertySchema = schemaProps?.[key];
210
238
  const fullKey = keyPrefix ? `${keyPrefix}.${key}` : key;
211
- const yamlComment = yamlComments?.get(fullKey);
239
+ const yamlComment = options.yamlComments?.get(fullKey);
212
240
 
213
- properties[sanitizedKey] = convertValueToProperty(
241
+ properties[sanitizedKey] = convertValueToProperty({
214
242
  value,
215
- `${interfaceName}${capitalizeFirst(typeNameSuffix)}`,
216
- propertySchema,
217
- key, // Pass the property name for better warnings
243
+ nestedTypeName: `${options.interfaceName}${capitalizeFirst(typeNameSuffix)}`,
244
+ schema: propertySchema,
245
+ propertyName: key,
218
246
  yamlComment,
219
- yamlComments,
247
+ yamlComments: options.yamlComments,
220
248
  fullKey,
221
- );
249
+ });
222
250
  }
223
251
 
224
252
  return {
225
- name: interfaceName,
253
+ name: options.interfaceName,
226
254
  properties,
227
255
  };
228
256
  }
229
257
 
230
- function convertValueToProperty(
231
- value: unknown,
232
- nestedTypeName: string,
233
- schema?: JSONSchemaProperty,
234
- propertyName?: string,
235
- yamlComment?: string,
236
- yamlComments?: Map<string, string>,
237
- fullKey?: string,
258
+ type InferencePropertyContext = {
259
+ value: unknown;
260
+ nestedTypeName: string;
261
+ schema?: JSONSchemaProperty;
262
+ propertyName?: string;
263
+ yamlComment?: string;
264
+ yamlComments?: Map<string, string>;
265
+ fullKey?: string;
266
+ };
267
+
268
+ /**
269
+ * Merge description from schema and YAML comments
270
+ */
271
+ function mergeDescriptions(
272
+ schemaDescription: string | undefined,
273
+ yamlComment: string | undefined,
274
+ ): string | undefined {
275
+ if (yamlComment == null || yamlComment === "") {
276
+ return schemaDescription;
277
+ }
278
+ return schemaDescription != null && schemaDescription !== ""
279
+ ? `${yamlComment}\n\n${schemaDescription}`
280
+ : yamlComment;
281
+ }
282
+
283
+ /**
284
+ * Convert a value to a TypeProperty using JSON schema information
285
+ */
286
+ function convertWithSchema(
287
+ ctx: InferencePropertyContext & { schema: JSONSchemaProperty },
238
288
  ): TypeProperty {
239
- // If we have a JSON schema for this property, prefer it over inference
240
- if (schema) {
241
- // First, infer the type from the actual value for comparison
242
- const inferredType = inferTypeFromValue(value);
243
- const schemaType = jsonSchemaToTypeScript(schema);
244
-
245
- // Check if schema and inferred types are in agreement
246
- if (inferredType && !typesAreCompatible(inferredType, schemaType)) {
247
- const propName = propertyName ? `'${propertyName}': ` : "";
248
- console.warn(
249
- ` ⚠️ Type mismatch for ${propName}Schema says '${schemaType}' but value suggests '${inferredType}' (value: ${String(value).substring(0, 50)})`,
250
- );
251
- }
289
+ const {
290
+ value,
291
+ nestedTypeName,
292
+ schema,
293
+ propertyName,
294
+ yamlComment,
295
+ yamlComments,
296
+ fullKey,
297
+ } = ctx;
298
+
299
+ const inferredType = inferTypeFromValue(value);
300
+ const schemaType = jsonSchemaToTypeScript(schema);
301
+
302
+ if (
303
+ inferredType != null &&
304
+ inferredType !== "" &&
305
+ !typesAreCompatible(inferredType, schemaType)
306
+ ) {
307
+ const propName =
308
+ propertyName != null && propertyName !== "" ? `'${propertyName}': ` : "";
309
+ console.warn(
310
+ ` ⚠️ Type mismatch for ${propName}Schema says '${schemaType}' but value suggests '${inferredType}' (value: ${String(value).slice(0, 50)})`,
311
+ );
312
+ }
252
313
 
253
- // Merge description from schema and YAML comments
254
- let description = schema.description;
255
- if (yamlComment) {
256
- if (description) {
257
- // If both exist, combine them
258
- description = `${yamlComment}\n\n${description}`;
259
- } else {
260
- description = yamlComment;
261
- }
262
- }
263
- const defaultValue = schema.default !== undefined ? schema.default : value;
264
-
265
- // If schema defines it as an object with properties, recurse
266
- const helmValueCheckForProps = HelmValueSchema.safeParse(value);
267
- if (schema.properties && helmValueCheckForProps.success) {
268
- const nestedInterface = convertToTypeScriptInterface(
269
- helmValueCheckForProps.data,
270
- nestedTypeName,
271
- schema,
272
- yamlComments,
273
- fullKey,
274
- );
275
- return {
276
- type: nestedTypeName,
277
- optional: true,
278
- nested: nestedInterface,
279
- description,
280
- default: defaultValue,
281
- };
282
- }
314
+ const description = mergeDescriptions(schema.description, yamlComment);
315
+ const defaultValue = schema.default === undefined ? value : schema.default;
283
316
 
284
- // Otherwise, use the schema type directly
285
- const tsType = schemaType;
286
-
287
- // Handle object types without explicit properties
288
- const helmValueCheckForObject = HelmValueSchema.safeParse(value);
289
- if (tsType === "object" && helmValueCheckForObject.success) {
290
- const nestedInterface = convertToTypeScriptInterface(
291
- helmValueCheckForObject.data,
292
- nestedTypeName,
293
- undefined,
294
- yamlComments,
295
- fullKey,
296
- );
297
- return {
298
- type: nestedTypeName,
299
- optional: true,
300
- nested: nestedInterface,
301
- description,
302
- default: defaultValue,
303
- };
304
- }
317
+ // If schema defines it as an object with properties, recurse
318
+ const helmValueCheckForProps = HelmValueSchema.safeParse(value);
319
+ if (schema.properties && helmValueCheckForProps.success) {
320
+ const nestedInterface = convertToTypeScriptInterface({
321
+ values: helmValueCheckForProps.data,
322
+ interfaceName: nestedTypeName,
323
+ schema,
324
+ yamlComments,
325
+ keyPrefix: fullKey,
326
+ });
327
+ return {
328
+ type: nestedTypeName,
329
+ optional: true,
330
+ nested: nestedInterface,
331
+ description,
332
+ default: defaultValue,
333
+ };
334
+ }
305
335
 
306
- return { type: tsType, optional: true, description, default: defaultValue };
336
+ // Handle object types without explicit properties
337
+ const helmValueCheckForObject = HelmValueSchema.safeParse(value);
338
+ if (schemaType === "object" && helmValueCheckForObject.success) {
339
+ const nestedInterface = convertToTypeScriptInterface({
340
+ values: helmValueCheckForObject.data,
341
+ interfaceName: nestedTypeName,
342
+ yamlComments,
343
+ keyPrefix: fullKey,
344
+ });
345
+ return {
346
+ type: nestedTypeName,
347
+ optional: true,
348
+ nested: nestedInterface,
349
+ description,
350
+ default: defaultValue,
351
+ };
307
352
  }
308
353
 
309
- // Fall back to runtime type inference when no schema is available
310
- // Use Zod schemas for robust type detection
311
- // IMPORTANT: Check for complex types (arrays, objects) BEFORE primitive types with coercion
354
+ return {
355
+ type: schemaType,
356
+ optional: true,
357
+ description,
358
+ default: defaultValue,
359
+ };
360
+ }
312
361
 
313
- // Check for null/undefined first
314
- if (NullSchema.safeParse(value).success || UndefinedSchema.safeParse(value).success) {
315
- return { type: "unknown", optional: true };
362
+ /**
363
+ * Infer array element type from sampled elements
364
+ */
365
+ function inferArrayType(
366
+ nestedTypeName: string,
367
+ arrayValue: unknown[],
368
+ ): TypeProperty {
369
+ if (arrayValue.length === 0) {
370
+ return { type: "unknown[]", optional: true };
316
371
  }
317
372
 
318
- // Check for array (before coercion checks, as coercion can convert arrays to true)
319
- const arrayResult = ArraySchema.safeParse(value);
320
- if (arrayResult.success) {
321
- const arrayValue = arrayResult.data;
322
- if (arrayValue.length === 0) {
323
- return { type: "unknown[]", optional: true };
324
- }
373
+ const elementTypes = new Set<string>();
374
+ const elementTypeProps: TypeProperty[] = [];
375
+ const sampleSize = Math.min(arrayValue.length, 3);
325
376
 
326
- // Sample multiple elements for better type inference
327
- const elementTypes = new Set<string>();
328
- const elementTypeProps: TypeProperty[] = [];
329
- const sampleSize = Math.min(arrayValue.length, 3); // Check up to 3 elements
377
+ for (let i = 0; i < sampleSize; i++) {
378
+ const elementType = convertValueToProperty({
379
+ value: arrayValue[i],
380
+ nestedTypeName,
381
+ });
382
+ elementTypes.add(elementType.type);
383
+ elementTypeProps.push(elementType);
384
+ }
330
385
 
331
- for (let i = 0; i < sampleSize; i++) {
332
- const elementType = convertValueToProperty(arrayValue[i], nestedTypeName);
333
- elementTypes.add(elementType.type);
334
- elementTypeProps.push(elementType);
335
- }
386
+ if (elementTypes.size === 1) {
387
+ return inferUniformArrayType(
388
+ elementTypes,
389
+ elementTypeProps,
390
+ nestedTypeName,
391
+ );
392
+ }
336
393
 
337
- // If all elements have the same type, use that
338
- if (elementTypes.size === 1) {
339
- const elementType = Array.from(elementTypes)[0];
340
- const elementProp = elementTypeProps[0];
341
- if (elementType && elementProp) {
342
- // For object array elements, we need to create a proper interface for the array element
343
- if (elementProp.nested) {
344
- // Create a new interface name for array elements
345
- const arrayElementTypeName = `${nestedTypeName}Element`;
346
- const arrayElementInterface: TypeScriptInterface = {
347
- name: arrayElementTypeName,
348
- properties: elementProp.nested.properties,
349
- };
350
-
351
- return {
352
- type: `${arrayElementTypeName}[]`,
353
- optional: true,
354
- nested: arrayElementInterface,
355
- };
356
- } else {
357
- return {
358
- type: `${elementType}[]`,
359
- optional: true,
360
- };
361
- }
362
- }
363
- }
394
+ const types = [...elementTypes].toSorted();
395
+ if (
396
+ types.length <= 3 &&
397
+ types.every((t) => ["string", "number", "boolean"].includes(t))
398
+ ) {
399
+ return { type: `(${types.join(" | ")})[]`, optional: true };
400
+ }
364
401
 
365
- // If mixed types, use union type for common cases
366
- const types = Array.from(elementTypes).sort();
367
- if (types.length <= 3 && types.every((t) => ["string", "number", "boolean"].includes(t))) {
368
- return {
369
- type: `(${types.join(" | ")})[]`,
370
- optional: true,
371
- };
372
- }
402
+ return { type: "unknown[]", optional: true };
403
+ }
373
404
 
374
- // Otherwise fall back to unknown[]
405
+ /**
406
+ * Build TypeProperty for a uniform-type array
407
+ */
408
+ function inferUniformArrayType(
409
+ elementTypes: Set<string>,
410
+ elementTypeProps: TypeProperty[],
411
+ nestedTypeName: string,
412
+ ): TypeProperty {
413
+ const elementType = [...elementTypes][0];
414
+ const elementProp = elementTypeProps[0];
415
+ if (elementType == null || elementType === "" || !elementProp) {
375
416
  return { type: "unknown[]", optional: true };
376
417
  }
377
418
 
378
- // Check for object (before primitive coercion checks)
379
- const objectResult = HelmValueSchema.safeParse(value);
380
- if (objectResult.success) {
381
- const nestedInterface = convertToTypeScriptInterface(
382
- objectResult.data,
383
- nestedTypeName,
384
- undefined,
385
- yamlComments,
386
- fullKey,
387
- );
419
+ if (elementProp.nested) {
420
+ const arrayElementTypeName = `${nestedTypeName}Element`;
421
+ const arrayElementInterface: TypeScriptInterface = {
422
+ name: arrayElementTypeName,
423
+ properties: elementProp.nested.properties,
424
+ };
388
425
  return {
389
- type: nestedTypeName,
426
+ type: `${arrayElementTypeName}[]`,
390
427
  optional: true,
391
- nested: nestedInterface,
392
- description: yamlComment,
393
- default: value,
428
+ nested: arrayElementInterface,
394
429
  };
395
430
  }
396
431
 
397
- // Now check for primitives - first actual runtime types, then coerced string types
398
- // This prevents objects from being coerced to booleans
432
+ return { type: `${elementType}[]`, optional: true };
433
+ }
399
434
 
400
- // Check for actual runtime boolean (true/false)
435
+ /**
436
+ * Infer a primitive TypeProperty from a runtime value (no schema)
437
+ */
438
+ function inferPrimitiveType(
439
+ value: unknown,
440
+ yamlComment?: string,
441
+ ): TypeProperty {
401
442
  if (ActualBooleanSchema.safeParse(value).success) {
402
- return { type: "boolean", optional: true, description: yamlComment, default: value };
443
+ return {
444
+ type: "boolean",
445
+ optional: true,
446
+ description: yamlComment,
447
+ default: value,
448
+ };
403
449
  }
404
450
 
405
- // Check for actual runtime number
406
451
  if (ActualNumberSchema.safeParse(value).success) {
407
- return { type: "number", optional: true, description: yamlComment, default: value };
452
+ return {
453
+ type: "number",
454
+ optional: true,
455
+ description: yamlComment,
456
+ default: value,
457
+ };
408
458
  }
409
459
 
410
- // Check if it's a string that represents a boolean ("true", "FALSE", etc.)
411
460
  if (StringBooleanSchema.safeParse(value).success) {
412
- return { type: "boolean", optional: true, description: yamlComment, default: value };
461
+ return {
462
+ type: "boolean",
463
+ optional: true,
464
+ description: yamlComment,
465
+ default: value,
466
+ };
413
467
  }
414
468
 
415
- // Check if it's a string that represents a number ("15", "0", etc.)
416
- // Only treat non-empty strings that parse as numbers as numbers
417
469
  const stringCheckForNumber = StringSchema.safeParse(value);
418
470
  if (stringCheckForNumber.success) {
419
471
  const trimmed = stringCheckForNumber.data.trim();
420
- // Don't treat empty strings or purely whitespace as numbers
421
- if (trimmed !== "" && !isNaN(Number(trimmed)) && isFinite(Number(trimmed))) {
422
- return { type: "number", optional: true, description: yamlComment, default: value };
472
+ if (
473
+ trimmed !== "" &&
474
+ !Number.isNaN(Number(trimmed)) &&
475
+ Number.isFinite(Number(trimmed))
476
+ ) {
477
+ return {
478
+ type: "number",
479
+ optional: true,
480
+ description: yamlComment,
481
+ default: value,
482
+ };
423
483
  }
424
484
  }
425
485
 
426
- // Check for plain string (strings that don't look like numbers or booleans)
427
486
  const stringCheckForPlain = StringSchema.safeParse(value);
428
487
  if (stringCheckForPlain.success) {
429
- // Special case: "default" is often used as a sentinel value in Helm charts
430
- // that can be overridden with actual typed values (numbers, booleans, etc.)
431
488
  if (stringCheckForPlain.data === "default") {
432
- return { type: "string | number | boolean", optional: true, description: yamlComment, default: value };
489
+ return {
490
+ type: "string | number | boolean",
491
+ optional: true,
492
+ description: yamlComment,
493
+ default: value,
494
+ };
433
495
  }
434
- return { type: "string", optional: true, description: yamlComment, default: value };
496
+ return {
497
+ type: "string",
498
+ optional: true,
499
+ description: yamlComment,
500
+ default: value,
501
+ };
435
502
  }
436
503
 
437
- // Fallback for any unrecognized type
438
- console.warn(`Unrecognized value type for: ${String(value)}, using 'unknown'`);
504
+ console.warn(
505
+ `Unrecognized value type for: ${String(value)}, using 'unknown'`,
506
+ );
439
507
  return { type: "unknown", optional: true, description: yamlComment };
440
508
  }
509
+
510
+ function convertValueToProperty(opts: InferencePropertyContext): TypeProperty {
511
+ const { value, nestedTypeName, schema, yamlComment, yamlComments, fullKey } =
512
+ opts;
513
+
514
+ if (schema) {
515
+ return convertWithSchema({ ...opts, schema });
516
+ }
517
+
518
+ if (
519
+ NullSchema.safeParse(value).success ||
520
+ UndefinedSchema.safeParse(value).success
521
+ ) {
522
+ return { type: "unknown", optional: true };
523
+ }
524
+
525
+ const arrayResult = ArraySchema.safeParse(value);
526
+ if (arrayResult.success) {
527
+ return inferArrayType(nestedTypeName, arrayResult.data);
528
+ }
529
+
530
+ const objectResult = HelmValueSchema.safeParse(value);
531
+ if (objectResult.success) {
532
+ const nestedInterface = convertToTypeScriptInterface({
533
+ values: objectResult.data,
534
+ interfaceName: nestedTypeName,
535
+ yamlComments,
536
+ keyPrefix: fullKey,
537
+ });
538
+ return {
539
+ type: nestedTypeName,
540
+ optional: true,
541
+ nested: nestedInterface,
542
+ description: yamlComment,
543
+ default: value,
544
+ };
545
+ }
546
+
547
+ return inferPrimitiveType(value, yamlComment);
548
+ }
package/src/utils.ts CHANGED
@@ -50,7 +50,7 @@ export function sanitizePropertyName(key: string): string {
50
50
  // Check if key needs quoting
51
51
  const needsQuoting =
52
52
  reservedKeywords.has(key) ||
53
- /[^a-zA-Z0-9_$]/.test(key) || // Contains special characters
53
+ /[^\w$]/.test(key) || // Contains special characters
54
54
  /^\d/.test(key); // Starts with digit
55
55
 
56
56
  return needsQuoting ? `"${key}"` : key;
@@ -62,7 +62,7 @@ export function sanitizePropertyName(key: string): string {
62
62
  export function sanitizeTypeName(key: string): string {
63
63
  return (
64
64
  key
65
- .replace(/[^a-zA-Z0-9]/g, "") // Remove all special characters
65
+ .replaceAll(/[^a-z0-9]/gi, "") // Remove all special characters
66
66
  .replace(/^\d+/, "") || // Remove leading digits
67
67
  "Property"
68
68
  ); // Fallback if empty