@workos/oagen-emitters 0.3.0 → 0.5.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.
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lint.yml +1 -1
- package/.github/workflows/release-please.yml +2 -2
- package/.github/workflows/release.yml +1 -1
- package/.husky/pre-push +11 -0
- package/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +35 -224
- package/dist/index.d.mts +12 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -12737
- package/dist/plugin-BSop9f9z.mjs +21471 -0
- package/dist/plugin-BSop9f9z.mjs.map +1 -0
- package/dist/plugin.d.mts +7 -0
- package/dist/plugin.d.mts.map +1 -0
- package/dist/plugin.mjs +2 -0
- package/docs/sdk-architecture/dotnet.md +336 -0
- package/oagen.config.ts +5 -343
- package/package.json +10 -34
- package/smoke/sdk-dotnet.ts +45 -12
- package/src/dotnet/client.ts +89 -0
- package/src/dotnet/enums.ts +323 -0
- package/src/dotnet/fixtures.ts +236 -0
- package/src/dotnet/index.ts +248 -0
- package/src/dotnet/manifest.ts +36 -0
- package/src/dotnet/models.ts +320 -0
- package/src/dotnet/naming.ts +368 -0
- package/src/dotnet/resources.ts +943 -0
- package/src/dotnet/tests.ts +713 -0
- package/src/dotnet/type-map.ts +228 -0
- package/src/dotnet/wrappers.ts +197 -0
- package/src/go/client.ts +35 -3
- package/src/go/enums.ts +4 -0
- package/src/go/index.ts +15 -7
- package/src/go/models.ts +6 -1
- package/src/go/naming.ts +5 -17
- package/src/go/resources.ts +534 -73
- package/src/go/tests.ts +39 -3
- package/src/go/type-map.ts +8 -3
- package/src/go/wrappers.ts +79 -21
- package/src/index.ts +15 -0
- package/src/kotlin/client.ts +58 -0
- package/src/kotlin/enums.ts +189 -0
- package/src/kotlin/index.ts +92 -0
- package/src/kotlin/manifest.ts +55 -0
- package/src/kotlin/models.ts +486 -0
- package/src/kotlin/naming.ts +229 -0
- package/src/kotlin/overrides.ts +25 -0
- package/src/kotlin/resources.ts +998 -0
- package/src/kotlin/tests.ts +1133 -0
- package/src/kotlin/type-map.ts +123 -0
- package/src/kotlin/wrappers.ts +168 -0
- package/src/node/client.ts +84 -7
- package/src/node/field-plan.ts +12 -14
- package/src/node/fixtures.ts +39 -3
- package/src/node/index.ts +1 -0
- package/src/node/models.ts +281 -37
- package/src/node/resources.ts +319 -95
- package/src/node/tests.ts +108 -29
- package/src/node/type-map.ts +1 -31
- package/src/node/utils.ts +96 -6
- package/src/node/wrappers.ts +31 -1
- package/src/php/client.ts +11 -3
- package/src/php/models.ts +0 -33
- package/src/php/naming.ts +2 -21
- package/src/php/resources.ts +275 -19
- package/src/php/tests.ts +118 -18
- package/src/php/type-map.ts +16 -2
- package/src/php/wrappers.ts +7 -2
- package/src/plugin.ts +50 -0
- package/src/python/client.ts +50 -32
- package/src/python/enums.ts +35 -10
- package/src/python/index.ts +35 -27
- package/src/python/models.ts +139 -2
- package/src/python/naming.ts +2 -22
- package/src/python/resources.ts +234 -17
- package/src/python/tests.ts +260 -16
- package/src/python/type-map.ts +16 -2
- package/src/ruby/client.ts +238 -0
- package/src/ruby/enums.ts +149 -0
- package/src/ruby/index.ts +93 -0
- package/src/ruby/manifest.ts +35 -0
- package/src/ruby/models.ts +360 -0
- package/src/ruby/naming.ts +187 -0
- package/src/ruby/rbi.ts +313 -0
- package/src/ruby/resources.ts +799 -0
- package/src/ruby/tests.ts +459 -0
- package/src/ruby/type-map.ts +97 -0
- package/src/ruby/wrappers.ts +161 -0
- package/src/shared/model-utils.ts +357 -16
- package/src/shared/naming-utils.ts +83 -0
- package/src/shared/non-spec-services.ts +13 -0
- package/src/shared/resolved-ops.ts +75 -1
- package/src/shared/wrapper-utils.ts +12 -1
- package/test/dotnet/client.test.ts +121 -0
- package/test/dotnet/enums.test.ts +193 -0
- package/test/dotnet/errors.test.ts +9 -0
- package/test/dotnet/manifest.test.ts +82 -0
- package/test/dotnet/models.test.ts +258 -0
- package/test/dotnet/resources.test.ts +387 -0
- package/test/dotnet/tests.test.ts +202 -0
- package/test/entrypoint.test.ts +89 -0
- package/test/go/client.test.ts +6 -6
- package/test/go/resources.test.ts +156 -7
- package/test/kotlin/models.test.ts +135 -0
- package/test/kotlin/resources.test.ts +210 -0
- package/test/kotlin/tests.test.ts +176 -0
- package/test/node/client.test.ts +74 -0
- package/test/node/models.test.ts +134 -1
- package/test/node/resources.test.ts +343 -34
- package/test/node/utils.test.ts +140 -0
- package/test/php/client.test.ts +2 -1
- package/test/php/models.test.ts +5 -4
- package/test/php/resources.test.ts +103 -0
- package/test/php/tests.test.ts +67 -0
- package/test/plugin.test.ts +50 -0
- package/test/python/client.test.ts +56 -0
- package/test/python/models.test.ts +99 -0
- package/test/python/resources.test.ts +294 -0
- package/test/python/tests.test.ts +91 -0
- package/test/ruby/client.test.ts +81 -0
- package/test/ruby/resources.test.ts +386 -0
- package/test/shared/resolved-ops.test.ts +122 -0
- package/tsdown.config.ts +1 -1
- package/dist/index.mjs.map +0 -1
- package/scripts/generate-php.js +0 -13
- package/scripts/git-push-with-published-oagen.sh +0 -21
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import type { Operation, Service, EmitterContext } from '@workos/oagen';
|
|
2
|
+
import { toPascalCase, toSnakeCase } from '@workos/oagen';
|
|
3
|
+
import { buildResolvedLookup, lookupMethodName, lookupResolved, getMountTarget } from '../shared/resolved-ops.js';
|
|
4
|
+
import { stripUrnPrefix } from '../shared/naming-utils.js';
|
|
5
|
+
|
|
6
|
+
/** PascalCase class/type name. */
|
|
7
|
+
export function className(name: string): string {
|
|
8
|
+
return toPascalCase(stripUrnPrefix(name));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Display name for a model type, including consumer-friendly aliases. */
|
|
12
|
+
export function modelClassName(name: string): string {
|
|
13
|
+
switch (name) {
|
|
14
|
+
case 'EmailChangeConfirmationUser':
|
|
15
|
+
case 'UserlandUser':
|
|
16
|
+
return 'User';
|
|
17
|
+
default:
|
|
18
|
+
return className(name);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** PascalCase file name (without extension). */
|
|
23
|
+
export function fileName(name: string): string {
|
|
24
|
+
return toPascalCase(stripUrnPrefix(name));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** snake_case file name for fixtures/test data. */
|
|
28
|
+
export function fixtureFileName(name: string): string {
|
|
29
|
+
return toSnakeCase(stripUrnPrefix(name));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** PascalCase method name. */
|
|
33
|
+
export function methodName(name: string): string {
|
|
34
|
+
return toPascalCase(name);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** PascalCase property name. */
|
|
38
|
+
export function fieldName(name: string): string {
|
|
39
|
+
return toPascalCase(name);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** PascalCase directory name for service modules. */
|
|
43
|
+
export function moduleName(name: string): string {
|
|
44
|
+
return toPascalCase(name);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** PascalCase property name for service accessors on the client. */
|
|
48
|
+
export function servicePropertyName(name: string): string {
|
|
49
|
+
return className(name);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Resolve the effective service name using resolved operations. */
|
|
53
|
+
export function resolveServiceName(service: Service, ctx: EmitterContext): string {
|
|
54
|
+
return resolveClassName(service, ctx);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Build a map from IR service name to resolved service name. */
|
|
58
|
+
export function buildServiceNameMap(services: Service[], ctx: EmitterContext): Map<string, string> {
|
|
59
|
+
const map = new Map<string, string>();
|
|
60
|
+
for (const service of services) {
|
|
61
|
+
map.set(service.name, resolveServiceName(service, ctx));
|
|
62
|
+
}
|
|
63
|
+
return map;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Resolve the output directory for a service. */
|
|
67
|
+
export function resolveServiceDir(resolvedServiceName: string): string {
|
|
68
|
+
return moduleName(resolvedServiceName);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function trimAsyncSuffix(name: string): string {
|
|
72
|
+
return name.endsWith('Async') ? name.slice(0, -5) : name;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Append Async once for TAP-style method names. */
|
|
76
|
+
export function appendAsyncSuffix(name: string): string {
|
|
77
|
+
return name.endsWith('Async') ? name : `${name}Async`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Resolve the stable method stem for an operation (without any Async suffix). */
|
|
81
|
+
export function resolveMethodStem(op: Operation, _service: Service, ctx: EmitterContext): string {
|
|
82
|
+
const lookup = buildResolvedLookup(ctx);
|
|
83
|
+
const resolved = lookupMethodName(op, lookup);
|
|
84
|
+
if (resolved) {
|
|
85
|
+
return trimMountedResourceFromMethod(methodName(trimAsyncSuffix(resolved)), resolveClassName(_service, ctx));
|
|
86
|
+
}
|
|
87
|
+
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
88
|
+
const existing = ctx.overlayLookup?.methodByOperation?.get(httpKey);
|
|
89
|
+
if (existing) {
|
|
90
|
+
return trimMountedResourceFromMethod(
|
|
91
|
+
methodName(trimAsyncSuffix(existing.methodName)),
|
|
92
|
+
resolveClassName(_service, ctx),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return trimMountedResourceFromMethod(methodName(trimAsyncSuffix(op.name)), resolveClassName(_service, ctx));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Resolve the SDK method name for an operation. */
|
|
99
|
+
export function resolveMethodName(op: Operation, service: Service, ctx: EmitterContext): string {
|
|
100
|
+
const stem = resolveMethodStem(op, service, ctx);
|
|
101
|
+
const resolved = lookupResolved(op, buildResolvedLookup(ctx));
|
|
102
|
+
if (resolved?.urlBuilder ?? false) {
|
|
103
|
+
return stem;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return appendAsyncSuffix(stem);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Resolve the SDK class name for a service. */
|
|
110
|
+
export function resolveClassName(service: Service, ctx: EmitterContext): string {
|
|
111
|
+
for (const r of ctx.resolvedOperations ?? []) {
|
|
112
|
+
if (r.service.name === service.name) return className(r.mountOn);
|
|
113
|
+
}
|
|
114
|
+
if (ctx.overlayLookup?.methodByOperation) {
|
|
115
|
+
for (const op of service.operations) {
|
|
116
|
+
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
117
|
+
const existing = ctx.overlayLookup.methodByOperation.get(httpKey);
|
|
118
|
+
if (existing) return className(existing.className);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return className(service.name);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Build a map from IR service name to mount-target directory name. */
|
|
125
|
+
export function buildMountDirMap(ctx: EmitterContext): Map<string, string> {
|
|
126
|
+
const map = new Map<string, string>();
|
|
127
|
+
for (const service of ctx.spec.services) {
|
|
128
|
+
const target = getMountTarget(service, ctx);
|
|
129
|
+
map.set(service.name, moduleName(target));
|
|
130
|
+
}
|
|
131
|
+
return map;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function splitPascalWords(name: string): string[] {
|
|
135
|
+
return name.match(/[A-Z]+(?:[a-z]+|(?=[A-Z]|$))|[A-Z]?[a-z]+|[0-9]+/g) ?? [name];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function singularize(word: string): string {
|
|
139
|
+
if (word.endsWith('ies') && word.length > 3) {
|
|
140
|
+
return `${word.slice(0, -3)}y`;
|
|
141
|
+
}
|
|
142
|
+
if (word.endsWith('s') && !word.endsWith('ss')) {
|
|
143
|
+
return word.slice(0, -1);
|
|
144
|
+
}
|
|
145
|
+
return word;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function wordsMatch(left: string, right: string): boolean {
|
|
149
|
+
return singularize(left.toLowerCase()) === singularize(right.toLowerCase());
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function trimMountedResourceFromMethod(method: string, mountName: string): string {
|
|
153
|
+
const methodWords = splitPascalWords(method);
|
|
154
|
+
if (methodWords.length < 2) return method;
|
|
155
|
+
|
|
156
|
+
const mountWords = splitPascalWords(className(mountName));
|
|
157
|
+
if (mountWords.length === 0) return method;
|
|
158
|
+
|
|
159
|
+
let matched = 0;
|
|
160
|
+
while (
|
|
161
|
+
matched < mountWords.length &&
|
|
162
|
+
matched + 1 < methodWords.length &&
|
|
163
|
+
wordsMatch(methodWords[matched + 1], mountWords[matched])
|
|
164
|
+
) {
|
|
165
|
+
matched++;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (matched === 0) return method;
|
|
169
|
+
|
|
170
|
+
return [methodWords[0], ...methodWords.slice(matched + 1)].join('');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Service type name for the class declaration. */
|
|
174
|
+
export function serviceTypeName(name: string): string {
|
|
175
|
+
// Preserve pluralization for C# service names (OrganizationsService, not OrganizationService)
|
|
176
|
+
return `${className(name)}Service`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** camelCase for local variables. */
|
|
180
|
+
export function localName(name: string): string {
|
|
181
|
+
const pascal = toPascalCase(name);
|
|
182
|
+
if (!pascal) return pascal;
|
|
183
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Escape a value as a C# literal. */
|
|
187
|
+
export function csLiteral(value: string | number | boolean): string {
|
|
188
|
+
if (typeof value === 'string') return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
189
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
190
|
+
return String(value);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Map a wire field name to the WorkOSClient property expression. */
|
|
194
|
+
export function clientFieldExpression(field: string): string {
|
|
195
|
+
switch (field) {
|
|
196
|
+
case 'client_id':
|
|
197
|
+
return 'ClientId';
|
|
198
|
+
case 'client_secret':
|
|
199
|
+
return 'ApiKey';
|
|
200
|
+
default:
|
|
201
|
+
return fieldName(field);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Convert an HTTP method string to the C# HttpMethod static property name. */
|
|
206
|
+
export function httpMethodCs(method: string): string {
|
|
207
|
+
const m = method.toLowerCase();
|
|
208
|
+
switch (m) {
|
|
209
|
+
case 'get':
|
|
210
|
+
return 'Get';
|
|
211
|
+
case 'post':
|
|
212
|
+
return 'Post';
|
|
213
|
+
case 'put':
|
|
214
|
+
return 'Put';
|
|
215
|
+
case 'patch':
|
|
216
|
+
return 'Patch';
|
|
217
|
+
case 'delete':
|
|
218
|
+
return 'Delete';
|
|
219
|
+
case 'head':
|
|
220
|
+
return 'Head';
|
|
221
|
+
case 'options':
|
|
222
|
+
return 'Options';
|
|
223
|
+
default:
|
|
224
|
+
return 'Get';
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Return the name of the Service base-class helper that handles the given
|
|
230
|
+
* HTTP method (e.g., `GetAsync`, `PostAsync`). Used by the resource emitter
|
|
231
|
+
* to produce one-line service methods instead of inlined WorkOSRequest blocks.
|
|
232
|
+
*/
|
|
233
|
+
export function httpMethodHelperName(method: string): string {
|
|
234
|
+
const m = method.toLowerCase();
|
|
235
|
+
switch (m) {
|
|
236
|
+
case 'get':
|
|
237
|
+
return 'GetAsync';
|
|
238
|
+
case 'post':
|
|
239
|
+
return 'PostAsync';
|
|
240
|
+
case 'put':
|
|
241
|
+
return 'PutAsync';
|
|
242
|
+
case 'patch':
|
|
243
|
+
return 'PatchAsync';
|
|
244
|
+
case 'delete':
|
|
245
|
+
return 'DeleteAsync';
|
|
246
|
+
default:
|
|
247
|
+
return 'GetAsync';
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Escape XML special characters for use in XML doc comments. */
|
|
252
|
+
export function escapeXml(s: string): string {
|
|
253
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Distill a deprecation message from a spec description. Looks for common
|
|
258
|
+
* WorkOS patterns ("Deprecated. Use X.", "Use X instead.", etc.) and falls
|
|
259
|
+
* back to a generic message scoped to the item kind.
|
|
260
|
+
*
|
|
261
|
+
* Output is suitable for inlining into `[System.Obsolete("...")]`.
|
|
262
|
+
*/
|
|
263
|
+
export function deprecationMessage(
|
|
264
|
+
description: string | undefined | null,
|
|
265
|
+
kind: 'field' | 'parameter' | 'operation' | 'value',
|
|
266
|
+
): string {
|
|
267
|
+
const generic = `This ${kind} is deprecated.`;
|
|
268
|
+
if (!description) return generic;
|
|
269
|
+
|
|
270
|
+
const text = description.replace(/\s+/g, ' ').trim();
|
|
271
|
+
if (!text) return generic;
|
|
272
|
+
|
|
273
|
+
// Match: "Deprecated. Use `foo` instead." / "Deprecated: use Foo."
|
|
274
|
+
const deprecatedClause = text.match(/Deprecated[.:][\s]*(.*?)(?:\.|$)/i);
|
|
275
|
+
if (deprecatedClause?.[1]?.trim()) {
|
|
276
|
+
return `Deprecated. ${deprecatedClause[1].trim().replace(/\.$/, '')}.`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Match: "Use `foo` instead." anywhere in the description
|
|
280
|
+
const useInstead = text.match(/Use\s+`?([^`.\s]+)`?\s+instead/i);
|
|
281
|
+
if (useInstead) {
|
|
282
|
+
return `${generic.replace(/\.$/, '')} Use \`${useInstead[1]}\` instead.`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return generic;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Escape a C# string literal for use inside `[System.Obsolete("...")]`.
|
|
290
|
+
* Doubles embedded quotes and escapes backslashes.
|
|
291
|
+
*/
|
|
292
|
+
export function escapeCsAttributeString(s: string): string {
|
|
293
|
+
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Emit an XML doc summary block from a possibly multi-line spec description.
|
|
298
|
+
* The first non-empty line becomes the `<summary>`. If there are additional
|
|
299
|
+
* non-empty lines, they are emitted as `<remarks>...</remarks>` so users get
|
|
300
|
+
* the full context in tooling (IntelliSense / `dotnet help`) instead of just
|
|
301
|
+
* the first sentence.
|
|
302
|
+
*
|
|
303
|
+
* Returns an empty array if `description` is null/empty so callers can spread
|
|
304
|
+
* the result unconditionally.
|
|
305
|
+
*/
|
|
306
|
+
export function emitXmlDoc(description: string | undefined | null, indent: string): string[] {
|
|
307
|
+
if (!description) return [];
|
|
308
|
+
const lines = description
|
|
309
|
+
.split('\n')
|
|
310
|
+
.map((l) => l.trim())
|
|
311
|
+
.filter((l) => l);
|
|
312
|
+
if (lines.length === 0) return [];
|
|
313
|
+
|
|
314
|
+
const out: string[] = [];
|
|
315
|
+
out.push(`${indent}/// <summary>${escapeXml(lines[0])}</summary>`);
|
|
316
|
+
if (lines.length > 1) {
|
|
317
|
+
out.push(`${indent}/// <remarks>`);
|
|
318
|
+
for (const remark of lines.slice(1)) {
|
|
319
|
+
out.push(`${indent}/// ${escapeXml(remark)}`);
|
|
320
|
+
}
|
|
321
|
+
out.push(`${indent}/// </remarks>`);
|
|
322
|
+
}
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Convert a snake_case or camelCase name to a human-readable string.
|
|
328
|
+
* Preserves acronyms (SSO, API, URL, JWT, OIDC, …) as uppercase tokens and
|
|
329
|
+
* lowercases the rest so generated XML docs read naturally.
|
|
330
|
+
*/
|
|
331
|
+
export function humanize(name: string): string {
|
|
332
|
+
// Split on underscores and at camelCase boundaries, keeping acronym runs
|
|
333
|
+
// intact: `SSOConnection` → ["SSO", "Connection"]; `listUsers` → ["list",
|
|
334
|
+
// "Users"]; `api_key` → ["api", "key"].
|
|
335
|
+
const parts = name.split('_').flatMap((segment) =>
|
|
336
|
+
segment
|
|
337
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
338
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
339
|
+
.split(/\s+/)
|
|
340
|
+
.filter(Boolean),
|
|
341
|
+
);
|
|
342
|
+
return parts.map((p) => (isAcronymToken(p) ? p : p.toLowerCase())).join(' ');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** True when a single token looks like a runtime acronym (2+ uppercase letters). */
|
|
346
|
+
function isAcronymToken(token: string): boolean {
|
|
347
|
+
if (token.length < 2) return false;
|
|
348
|
+
return /^[A-Z0-9]+$/.test(token);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Return the English indefinite article ("a" or "an") appropriate for the
|
|
353
|
+
* given humanized phrase. Uses the first spoken letter of the first word
|
|
354
|
+
* and accounts for common silent-h / vowel-like-consonant cases so
|
|
355
|
+
* generated docs read correctly.
|
|
356
|
+
*/
|
|
357
|
+
export function articleFor(phrase: string): string {
|
|
358
|
+
const trimmed = phrase.trim();
|
|
359
|
+
if (!trimmed) return 'a';
|
|
360
|
+
const firstWord = trimmed.split(/\s+/)[0];
|
|
361
|
+
if (!firstWord) return 'a';
|
|
362
|
+
// Acronyms are voiced letter-by-letter — use the first letter's sound.
|
|
363
|
+
if (isAcronymToken(firstWord)) {
|
|
364
|
+
return /^[AEFHILMNORSX]/.test(firstWord) ? 'an' : 'a';
|
|
365
|
+
}
|
|
366
|
+
const first = firstWord.toLowerCase();
|
|
367
|
+
return /^[aeiou]/.test(first) ? 'an' : 'a';
|
|
368
|
+
}
|