@voxgig/apidef 6.1.1 → 6.3.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/bin/voxgig-apidef +1 -1
- package/dist/apidef.js +9 -0
- package/dist/apidef.js.map +1 -1
- package/dist/builder/entity/entity.js +10 -12
- package/dist/builder/entity/entity.js.map +1 -1
- package/dist/builder/entity/info.js +5 -1
- package/dist/builder/entity/info.js.map +1 -1
- package/dist/guide/guide.js +7 -9
- package/dist/guide/guide.js.map +1 -1
- package/dist/guide/heuristic01.js +43 -3
- package/dist/guide/heuristic01.js.map +1 -1
- package/dist/parse.js +9 -1
- package/dist/parse.js.map +1 -1
- package/dist/transform/field.js +21 -10
- package/dist/transform/field.js.map +1 -1
- package/dist/transform/operation.js +7 -1
- package/dist/transform/operation.js.map +1 -1
- package/dist/transform/top.js +70 -0
- package/dist/transform/top.js.map +1 -1
- package/dist/types.d.ts +28 -1
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -1
- package/dist/utility.d.ts +3 -1
- package/dist/utility.js +179 -29
- package/dist/utility.js.map +1 -1
- package/model/{apidef.jsonic → apidef.aontu} +6 -0
- package/package.json +9 -3
- package/src/apidef.ts +12 -0
- package/src/builder/entity/entity.ts +10 -20
- package/src/builder/entity/info.ts +5 -1
- package/src/guide/guide.ts +6 -7
- package/src/guide/heuristic01.ts +46 -3
- package/src/parse.ts +9 -1
- package/src/transform/field.ts +21 -14
- package/src/transform/operation.ts +7 -1
- package/src/transform/top.ts +72 -0
- package/src/types.ts +9 -1
- package/src/utility.ts +189 -29
- package/README.md +0 -2
- /package/model/{guide.jsonic → guide.aontu} +0 -0
package/src/guide/heuristic01.ts
CHANGED
|
@@ -78,6 +78,10 @@ const IS_ENTCMP_PATH_RATE = 0.41
|
|
|
78
78
|
|
|
79
79
|
const METHOD_IDOP: Record<string, string> = {
|
|
80
80
|
GET: 'load',
|
|
81
|
+
// QUERY (RFC 10008) is a safe, idempotent read carrying its filter in the
|
|
82
|
+
// request body — a "GET with a body". Treat it as a load; ResolveOperation
|
|
83
|
+
// promotes it to `list` when the response is a collection.
|
|
84
|
+
QUERY: 'load',
|
|
81
85
|
POST: 'create',
|
|
82
86
|
PUT: 'update',
|
|
83
87
|
DELETE: 'remove',
|
|
@@ -88,6 +92,7 @@ const METHOD_IDOP: Record<string, string> = {
|
|
|
88
92
|
|
|
89
93
|
const METHOD_CONSIDER_ORDER: Record<string, number> = {
|
|
90
94
|
'GET': 100,
|
|
95
|
+
'QUERY': 150,
|
|
91
96
|
'POST': 200,
|
|
92
97
|
'PUT': 300,
|
|
93
98
|
'PATCH': 400,
|
|
@@ -226,7 +231,8 @@ function MeasurePath(spec: TaskSpec) {
|
|
|
226
231
|
(pathdef.patch ? 1 : 0) +
|
|
227
232
|
(pathdef.delete ? 1 : 0) +
|
|
228
233
|
(pathdef.head ? 1 : 0) +
|
|
229
|
-
(pathdef.options ? 1 : 0)
|
|
234
|
+
(pathdef.options ? 1 : 0) +
|
|
235
|
+
(pathdef.query ? 1 : 0)
|
|
230
236
|
)
|
|
231
237
|
|
|
232
238
|
}
|
|
@@ -1098,10 +1104,16 @@ function ResolveTransform(spec: TaskSpec) {
|
|
|
1098
1104
|
debugpath(pathStr, methodName, 'TRANSFORM-RES', keysof(resprops))
|
|
1099
1105
|
|
|
1100
1106
|
if (resprops) {
|
|
1101
|
-
|
|
1107
|
+
// Only unwrap `body.<entity>` when the entity-named response property is
|
|
1108
|
+
// itself a structured value (object/array/ref/composed schema) that could
|
|
1109
|
+
// actually contain the entity. A scalar property that merely shares the
|
|
1110
|
+
// entity's name (e.g. an entity `advice` whose own fields include a
|
|
1111
|
+
// string field `advice`) is a FIELD of the entity, not a wrapper around
|
|
1112
|
+
// it: the response IS the entity, so it must stay `body` (the default).
|
|
1113
|
+
if (isEntityWrapperProp(resprops[entdesc.origname])) {
|
|
1102
1114
|
transform.res = '`body.' + entdesc.origname + '`'
|
|
1103
1115
|
}
|
|
1104
|
-
else if (resprops[entdesc.name]) {
|
|
1116
|
+
else if (isEntityWrapperProp(resprops[entdesc.name])) {
|
|
1105
1117
|
transform.res = '`body.' + entdesc.name + '`'
|
|
1106
1118
|
}
|
|
1107
1119
|
}
|
|
@@ -1343,6 +1355,30 @@ function getResponseSchema(response: any) {
|
|
|
1343
1355
|
}
|
|
1344
1356
|
|
|
1345
1357
|
|
|
1358
|
+
// A response property only "wraps" the entity when it is itself a structured
|
|
1359
|
+
// value that could contain the entity: an object, an array, a $ref, or a
|
|
1360
|
+
// composed (allOf/oneOf/anyOf) schema. A scalar property (string, integer,
|
|
1361
|
+
// number, boolean) that merely shares the entity's name is a field of the
|
|
1362
|
+
// entity, not a wrapper, so the response must not be unwrapped down to it.
|
|
1363
|
+
function isEntityWrapperProp(propSchema: any): boolean {
|
|
1364
|
+
if (null == propSchema || 'object' !== typeof propSchema) {
|
|
1365
|
+
return false
|
|
1366
|
+
}
|
|
1367
|
+
if (null != propSchema.$ref) {
|
|
1368
|
+
return true
|
|
1369
|
+
}
|
|
1370
|
+
if (null != propSchema.properties ||
|
|
1371
|
+
null != propSchema.items ||
|
|
1372
|
+
null != propSchema.allOf ||
|
|
1373
|
+
null != propSchema.oneOf ||
|
|
1374
|
+
null != propSchema.anyOf) {
|
|
1375
|
+
return true
|
|
1376
|
+
}
|
|
1377
|
+
const t = propSchema.type
|
|
1378
|
+
return 'object' === t || 'array' === t
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
|
|
1346
1382
|
function inferEntityName(
|
|
1347
1383
|
mdesc: any,
|
|
1348
1384
|
parts: string[],
|
|
@@ -1429,6 +1465,13 @@ function probableEntityMethod(
|
|
|
1429
1465
|
prob_why = 'putish'
|
|
1430
1466
|
probent = true
|
|
1431
1467
|
}
|
|
1468
|
+
|
|
1469
|
+
// QUERY (RFC 10008) carries a filter body but is a safe read, so — like
|
|
1470
|
+
// GET — it implies an entity, not an action.
|
|
1471
|
+
else if ('QUERY' === mdesc.method) {
|
|
1472
|
+
prob_why = 'query'
|
|
1473
|
+
probent = true
|
|
1474
|
+
}
|
|
1432
1475
|
}
|
|
1433
1476
|
else if ('GET' === mdesc.method) {
|
|
1434
1477
|
prob_why = 'get'
|
package/src/parse.ts
CHANGED
|
@@ -75,7 +75,7 @@ async function parseOpenAPI(source: any, _meta?: any) {
|
|
|
75
75
|
// Validate it's an OpenAPI or Swagger spec
|
|
76
76
|
if (!parsed.openapi && !parsed.swagger) {
|
|
77
77
|
throw new Error(
|
|
78
|
-
`@voxgig/apidef: parse: Unsupported
|
|
78
|
+
`@voxgig/apidef: parse: Unsupported spec: missing 'openapi' or 'swagger' version field`
|
|
79
79
|
)
|
|
80
80
|
}
|
|
81
81
|
|
|
@@ -109,6 +109,14 @@ async function parseOpenAPI(source: any, _meta?: any) {
|
|
|
109
109
|
// Single-pass tree walk that:
|
|
110
110
|
// 1. Preserves original $ref values as x-ref
|
|
111
111
|
// 2. Resolves $ref JSON pointers in-place
|
|
112
|
+
//
|
|
113
|
+
// NOTE: resolution inlines a shallow copy of the target ({ ...resolved }),
|
|
114
|
+
// so multiple references to the same component share that component's
|
|
115
|
+
// nested child objects. Downstream consumers must therefore treat the
|
|
116
|
+
// resolved schema as read-only — mutating an inlined sub-object would leak
|
|
117
|
+
// across every site that referenced the same component. (A deep clone is
|
|
118
|
+
// deliberately avoided: schemas can be self-referential, which would make
|
|
119
|
+
// cloning non-terminating.)
|
|
112
120
|
function addXRefsAndResolve(obj: any, root: any, visited?: WeakSet<any>) {
|
|
113
121
|
if (!obj || typeof obj !== 'object') return
|
|
114
122
|
if (!visited) visited = new WeakSet()
|
package/src/transform/field.ts
CHANGED
|
@@ -54,7 +54,7 @@ const fieldTransform: Transform = async function(
|
|
|
54
54
|
seen[opfield.name] = opfield
|
|
55
55
|
}
|
|
56
56
|
else {
|
|
57
|
-
mergeField(
|
|
57
|
+
mergeField(mop, seen[opfield.name], opfield)
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
}
|
|
@@ -152,7 +152,11 @@ function findFieldDefs(
|
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
|
|
155
|
+
// A QUERY (RFC 10008) request body is a filter/query schema, not the
|
|
156
|
+
// entity shape, so it must not contribute entity fields. Fields for a
|
|
157
|
+
// QUERY op come from its response only. Other methods (POST/PUT/PATCH)
|
|
158
|
+
// carry the entity in the body, so merge as usual.
|
|
159
|
+
if (requestBody && 'query' !== method) {
|
|
156
160
|
fieldSets = [
|
|
157
161
|
fieldSets,
|
|
158
162
|
getx(requestBody, 'content "application/json" schema') ??
|
|
@@ -174,10 +178,17 @@ function findFieldDefs(
|
|
|
174
178
|
const requiredNames: string[] = Array.isArray(fieldSet?.required)
|
|
175
179
|
? fieldSet.required : []
|
|
176
180
|
each(fieldSet?.properties, (property: any) => {
|
|
177
|
-
|
|
178
|
-
|
|
181
|
+
// Don't mutate the parsed schema: a $ref-resolved schema is shared
|
|
182
|
+
// across every operation that references it, so flipping
|
|
183
|
+
// `property.required = true` here would leak this operation's
|
|
184
|
+
// required[] onto all the others. Derive `required` onto a shallow
|
|
185
|
+
// copy instead (matches the Go port, which builds fresh field defs).
|
|
186
|
+
if (!property.required && requiredNames.includes(property.key$)) {
|
|
187
|
+
fielddefs.push({ ...property, required: true })
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
fielddefs.push(property)
|
|
179
191
|
}
|
|
180
|
-
fielddefs.push(property)
|
|
181
192
|
})
|
|
182
193
|
})
|
|
183
194
|
}
|
|
@@ -216,7 +227,7 @@ function findExampleObject(opdef: any): any {
|
|
|
216
227
|
const responses = opdef.responses
|
|
217
228
|
if (null == responses) return null
|
|
218
229
|
|
|
219
|
-
const resdef = responses[
|
|
230
|
+
const resdef = responses['200'] ?? responses['201']
|
|
220
231
|
if (null == resdef) return null
|
|
221
232
|
|
|
222
233
|
// OpenAPI 3.x: content.application/json.example
|
|
@@ -310,22 +321,18 @@ function inferTypeFromValue(value: any): string {
|
|
|
310
321
|
|
|
311
322
|
|
|
312
323
|
function mergeField(
|
|
313
|
-
ment: ModelEntity,
|
|
314
324
|
mop: ModelOp,
|
|
315
|
-
|
|
316
|
-
def: any,
|
|
317
|
-
exisingField: ModelField,
|
|
325
|
+
existingField: ModelField,
|
|
318
326
|
newField: ModelField
|
|
319
327
|
) {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
exisingField.op[mop.name] = {
|
|
328
|
+
if (newField.req !== existingField.req) {
|
|
329
|
+
existingField.op[mop.name] = {
|
|
323
330
|
req: newField.req,
|
|
324
331
|
type: newField.type,
|
|
325
332
|
}
|
|
326
333
|
}
|
|
327
334
|
|
|
328
|
-
return
|
|
335
|
+
return existingField
|
|
329
336
|
}
|
|
330
337
|
|
|
331
338
|
|
|
@@ -152,7 +152,13 @@ function resolveOp(opname: OpName, gent: GuideEntity): undefined | ModelOp {
|
|
|
152
152
|
rename: p.rename,
|
|
153
153
|
method: p.method,
|
|
154
154
|
args: {},
|
|
155
|
-
|
|
155
|
+
// Carry the per-path op transform (res `body.<entity>`, req
|
|
156
|
+
// `{<entity>: reqdata}`) computed by the guide step
|
|
157
|
+
// (heuristic01 ResolveTransform) onto the point. It lives on the
|
|
158
|
+
// path's op, not on the op-map entry, so read p.op.transform.
|
|
159
|
+
// Spread into a fresh object so the default-fill below never
|
|
160
|
+
// mutates the shared guide op.transform across points.
|
|
161
|
+
transform: { ...((p as any).op?.transform ?? {}) },
|
|
156
162
|
select: {
|
|
157
163
|
exist: []
|
|
158
164
|
}
|
package/src/transform/top.ts
CHANGED
|
@@ -44,6 +44,18 @@ const topTransform = async function(
|
|
|
44
44
|
kit.info = stringifyInfoScalars(def.info ?? {})
|
|
45
45
|
kit.info.servers = stringifyInfoScalars(def.servers ?? [])
|
|
46
46
|
|
|
47
|
+
// Public APIs that declare NO authentication (no security schemes, no
|
|
48
|
+
// top-level `security`, and no per-operation `security`) get an explicit
|
|
49
|
+
// no-auth signal in the model. Downstream sdkgen reads it via
|
|
50
|
+
// isAuthActive() (main.kit.info.auth === false) to suppress apikey/auth
|
|
51
|
+
// code, docs and examples. Only the negative signal is emitted: when the
|
|
52
|
+
// spec DOES declare auth we leave `auth` unset so the SDK's own config
|
|
53
|
+
// (main.kit.config.auth) governs. Set AFTER stringifyInfoScalars so the
|
|
54
|
+
// value stays a real boolean rather than the string "false".
|
|
55
|
+
if (!specDeclaresAuth(def)) {
|
|
56
|
+
kit.info.auth = false
|
|
57
|
+
}
|
|
58
|
+
|
|
47
59
|
// Swagger 2.0
|
|
48
60
|
if (def.host) {
|
|
49
61
|
kit.info.servers.push({
|
|
@@ -51,6 +63,28 @@ const topTransform = async function(
|
|
|
51
63
|
})
|
|
52
64
|
}
|
|
53
65
|
|
|
66
|
+
// Some specs omit the scheme on `servers[].url` — e.g. the Art
|
|
67
|
+
// Institute of Chicago lists `api.artic.edu/api/v1` (no
|
|
68
|
+
// https://). Go's net/http barfs on that with "unsupported
|
|
69
|
+
// protocol scheme". Default to https when the URL has no scheme
|
|
70
|
+
// and the value isn't a relative path.
|
|
71
|
+
for (const server of (kit.info.servers as any[])) {
|
|
72
|
+
if (!server || 'string' !== typeof server.url) continue
|
|
73
|
+
const url: string = server.url.trim()
|
|
74
|
+
if (url === '') continue
|
|
75
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) continue // already has scheme
|
|
76
|
+
// `//host/path` is a protocol-relative URL — meaningless to a
|
|
77
|
+
// backend SDK, treat as missing-scheme and default to https.
|
|
78
|
+
if (url.startsWith('//')) {
|
|
79
|
+
server.url = 'https:' + url
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
// `/path` is path-only (relative to wherever the spec is served).
|
|
83
|
+
// Leave it untouched; it's a valid OpenAPI form.
|
|
84
|
+
if (url.startsWith('/')) continue
|
|
85
|
+
server.url = 'https://' + url
|
|
86
|
+
}
|
|
87
|
+
|
|
54
88
|
// A usable SDK requires a base URL. OpenAPI 3 puts it in `servers[].url`;
|
|
55
89
|
// Swagger 2 derives it from `host` + `basePath`. If neither yields a
|
|
56
90
|
// non-empty url, the generated SDK has no way to issue requests, so fail
|
|
@@ -66,6 +100,44 @@ const topTransform = async function(
|
|
|
66
100
|
}
|
|
67
101
|
|
|
68
102
|
|
|
103
|
+
// Does the spec declare any authentication? True if it defines security
|
|
104
|
+
// schemes (OpenAPI 3 `components.securitySchemes` or Swagger 2
|
|
105
|
+
// `securityDefinitions`), a top-level `security` requirement, or a
|
|
106
|
+
// per-operation `security` requirement. Used to emit a no-auth signal
|
|
107
|
+
// (info.auth: false) for fully public APIs.
|
|
108
|
+
function specDeclaresAuth(def: any): boolean {
|
|
109
|
+
if (null == def || 'object' !== typeof def) return false
|
|
110
|
+
|
|
111
|
+
const nonEmptyObj = (v: any) =>
|
|
112
|
+
null != v && 'object' === typeof v && Object.keys(v).length > 0
|
|
113
|
+
|
|
114
|
+
// OpenAPI 3 security schemes.
|
|
115
|
+
if (nonEmptyObj(def.components?.securitySchemes)) return true
|
|
116
|
+
|
|
117
|
+
// Swagger 2 security definitions.
|
|
118
|
+
if (nonEmptyObj(def.securityDefinitions)) return true
|
|
119
|
+
|
|
120
|
+
// Top-level security requirement.
|
|
121
|
+
if (Array.isArray(def.security) && def.security.length > 0) return true
|
|
122
|
+
|
|
123
|
+
// Per-operation security requirement.
|
|
124
|
+
const paths = def.paths
|
|
125
|
+
if (paths && 'object' === typeof paths) {
|
|
126
|
+
for (const pathItem of Object.values(paths)) {
|
|
127
|
+
if (null == pathItem || 'object' !== typeof pathItem) continue
|
|
128
|
+
for (const op of Object.values(pathItem as Record<string, any>)) {
|
|
129
|
+
if (op && 'object' === typeof op &&
|
|
130
|
+
Array.isArray((op as any).security) && (op as any).security.length > 0) {
|
|
131
|
+
return true
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return false
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
69
141
|
// OpenAPI's `info` object (and the `servers` array) declares every scalar
|
|
70
142
|
// leaf as a string. YAML/JSON parsers don't enforce that — `version: 2`
|
|
71
143
|
// without quotes parses as the number 2, `version: true` as a boolean.
|
package/src/types.ts
CHANGED
|
@@ -63,6 +63,14 @@ const ModelShape = Shape({
|
|
|
63
63
|
guide: {},
|
|
64
64
|
entity: {},
|
|
65
65
|
},
|
|
66
|
+
// Per-model overrides. `custom.plurals` is a plural → singular map
|
|
67
|
+
// consulted by depluralize() before the built-in IRREGULARS table
|
|
68
|
+
// and rule chain. Use for API-specific terminology where the
|
|
69
|
+
// generic English rules misclassify a name — e.g. {axes: axe}
|
|
70
|
+
// for a fitness API where the singular is "axe" not "axis".
|
|
71
|
+
custom: {
|
|
72
|
+
plurals: {},
|
|
73
|
+
},
|
|
66
74
|
}
|
|
67
75
|
})
|
|
68
76
|
const OpenModelShape = Shape(Open(ModelShape), { name: 'Model' })
|
|
@@ -179,7 +187,7 @@ import type {
|
|
|
179
187
|
BasicMethodDesc,
|
|
180
188
|
} from './desc'
|
|
181
189
|
|
|
182
|
-
type MethodName = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | ''
|
|
190
|
+
type MethodName = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'QUERY' | ''
|
|
183
191
|
|
|
184
192
|
|
|
185
193
|
|
package/src/utility.ts
CHANGED
|
@@ -110,103 +110,258 @@ function formatJsonSrc(jsonsrc: string) {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
|
|
113
|
-
// Common irregular plurals
|
|
113
|
+
// Common irregular plurals, in the form plural → singular. Used at the
|
|
114
|
+
// head of depluralize() to short-circuit cases the suffix rules below
|
|
115
|
+
// would otherwise mishandle.
|
|
116
|
+
//
|
|
117
|
+
// Three over-strip classes are worked around here because the surface
|
|
118
|
+
// form gives no clean discriminator:
|
|
119
|
+
//
|
|
120
|
+
// * `-Vse+s` plurals (houses, phases, noses, …) would hit the
|
|
121
|
+
// generic `-ses → ∅` rule and become hous/phas/nos. Every such
|
|
122
|
+
// -se+s plural needs an explicit entry.
|
|
123
|
+
//
|
|
124
|
+
// * `-che+s` plurals (caches, niches, headaches, …) would hit the
|
|
125
|
+
// generic `-ches → ∅` rule and become cach/nich/headach. Same
|
|
126
|
+
// pattern: no letter-doubling tell exists (cache vs church both
|
|
127
|
+
// have a single 'ch'), so each -che singular is enumerated.
|
|
128
|
+
//
|
|
129
|
+
// * `-oe+s` plurals (shoes, canoes, oboes) would hit the generic
|
|
130
|
+
// `-oes → -o` rule (for potatoes/heroes) and become sho/cano/obo.
|
|
131
|
+
// Only collision-safe entries are listed: a key must not also be a
|
|
132
|
+
// suffix of a real `-o`+es plural (e.g. `toes` is excluded because
|
|
133
|
+
// it would turn tomatoes → tomatoe).
|
|
134
|
+
//
|
|
135
|
+
// Keys are lowercase; depluralize() does a case-insensitive lookup
|
|
136
|
+
// and reapplies the caller's casing on the way out.
|
|
114
137
|
const IRREGULARS: Record<string, string> = {
|
|
115
138
|
'analytics': 'analytics',
|
|
116
139
|
'analyses': 'analysis',
|
|
117
140
|
'appendices': 'appendix',
|
|
141
|
+
'avalanches': 'avalanche',
|
|
118
142
|
'axes': 'axis',
|
|
143
|
+
'caches': 'cache',
|
|
144
|
+
'canoes': 'canoe',
|
|
145
|
+
'cases': 'case',
|
|
119
146
|
'children': 'child',
|
|
147
|
+
'cliches': 'cliche',
|
|
120
148
|
'courses': 'course',
|
|
149
|
+
'creches': 'creche',
|
|
121
150
|
'crises': 'crisis',
|
|
122
151
|
'criteria': 'criterion',
|
|
123
152
|
// 'data': 'datum',
|
|
124
153
|
'diagnoses': 'diagnosis',
|
|
154
|
+
'doses': 'dose',
|
|
155
|
+
'douches': 'douche',
|
|
125
156
|
'feet': 'foot',
|
|
126
|
-
'
|
|
157
|
+
'furnaces': 'furnace',
|
|
127
158
|
'geese': 'goose',
|
|
159
|
+
'headaches': 'headache',
|
|
128
160
|
'horses': 'horse',
|
|
129
|
-
'
|
|
161
|
+
'hoses': 'hose',
|
|
162
|
+
'houses': 'house',
|
|
130
163
|
'indices': 'index',
|
|
131
164
|
'lens': 'lens',
|
|
132
|
-
'
|
|
165
|
+
'licenses': 'license',
|
|
133
166
|
'matrices': 'matrix',
|
|
134
167
|
'men': 'man',
|
|
135
168
|
'mice': 'mouse',
|
|
169
|
+
'moustaches': 'moustache',
|
|
136
170
|
'movies': 'movie',
|
|
137
|
-
'
|
|
171
|
+
'mustaches': 'mustache',
|
|
172
|
+
'niches': 'niche',
|
|
173
|
+
'noses': 'nose',
|
|
174
|
+
'notices': 'notice',
|
|
175
|
+
'nurses': 'nurse',
|
|
138
176
|
'oases': 'oasis',
|
|
139
|
-
'
|
|
177
|
+
'oboes': 'oboe',
|
|
178
|
+
'pastiches': 'pastiche',
|
|
179
|
+
'pauses': 'pause',
|
|
180
|
+
'phases': 'phase',
|
|
181
|
+
'phrases': 'phrase',
|
|
182
|
+
'practices': 'practice',
|
|
183
|
+
'premises': 'premise',
|
|
184
|
+
'promises': 'promise',
|
|
185
|
+
'psyches': 'psyche',
|
|
186
|
+
'purses': 'purse',
|
|
140
187
|
'releases': 'release',
|
|
188
|
+
'roses': 'rose',
|
|
141
189
|
'people': 'person',
|
|
142
190
|
'phenomena': 'phenomenon',
|
|
143
|
-
'practice': 'practices',
|
|
144
|
-
'promise': 'promises',
|
|
145
191
|
'series': 'series',
|
|
192
|
+
'shoes': 'shoe',
|
|
193
|
+
'sources': 'source',
|
|
146
194
|
'species': 'species',
|
|
147
195
|
'teeth': 'tooth',
|
|
148
196
|
'theses': 'thesis',
|
|
197
|
+
'verses': 'verse',
|
|
149
198
|
'vertices': 'vertex',
|
|
150
199
|
'women': 'woman',
|
|
151
200
|
'yes': 'yes',
|
|
152
201
|
}
|
|
153
202
|
|
|
203
|
+
// Sorted longest-first so the most specific IRREGULARS suffix wins.
|
|
204
|
+
// Without this, 'women' would be shadowed by 'men' (3 < 5) under
|
|
205
|
+
// insertion-order iteration. Both happen to round-trip correctly
|
|
206
|
+
// today, but the sort makes any future entry safe by construction.
|
|
207
|
+
const IRREGULAR_KEYS = Object.keys(IRREGULARS).sort((a, b) => b.length - a.length)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
// Reapply the case pattern of `source` to `target`. Used so the
|
|
211
|
+
// case-insensitive lookups in depluralize() preserve the caller's
|
|
212
|
+
// casing on the way out (HOUSES → HOUSE, Houses → House, houses →
|
|
213
|
+
// house). Falls through to `target` unchanged for mixed-case sources
|
|
214
|
+
// that don't fit one of the three canonical patterns.
|
|
215
|
+
function matchCase(source: string, target: string): string {
|
|
216
|
+
if (source === source.toLowerCase()) return target.toLowerCase()
|
|
217
|
+
if (source === source.toUpperCase()) return target.toUpperCase()
|
|
218
|
+
if (source[0] === source[0].toUpperCase()) {
|
|
219
|
+
return target[0].toUpperCase() + target.slice(1).toLowerCase()
|
|
220
|
+
}
|
|
221
|
+
return target
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
// Per-model plural overrides, populated from the model's
|
|
226
|
+
// `main.custom.plurals` section at apidef pipeline entry and cleared
|
|
227
|
+
// between runs. Checked by depluralize() before the built-in
|
|
228
|
+
// IRREGULARS table and rule chain — so a model can override any
|
|
229
|
+
// default depluralization, including correct-by-default cases, when
|
|
230
|
+
// its domain demands a different singular (e.g. fitness API with
|
|
231
|
+
// {axes: axe}, photography app with {lenses: lens}).
|
|
232
|
+
//
|
|
233
|
+
// Module-level rather than a parameter so the many existing
|
|
234
|
+
// depluralize/canonize call sites across transforms and guide
|
|
235
|
+
// inherit the override without signature churn. apidef is
|
|
236
|
+
// single-model-per-process; if that ever changes, switch this to a
|
|
237
|
+
// per-context map.
|
|
238
|
+
let CUSTOM_PLURALS: Record<string, string> = {}
|
|
239
|
+
let CUSTOM_PLURAL_KEYS: string[] = []
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
function setCustomPlurals(plurals: Record<string, string> | undefined | null) {
|
|
243
|
+
CUSTOM_PLURALS = {}
|
|
244
|
+
if (plurals) {
|
|
245
|
+
for (const k of Object.keys(plurals)) {
|
|
246
|
+
// Skip null/undefined values so a partially-typed model entry
|
|
247
|
+
// doesn't poison the map.
|
|
248
|
+
const v = plurals[k]
|
|
249
|
+
if (null == v) continue
|
|
250
|
+
CUSTOM_PLURALS[k.toLowerCase()] = v
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
CUSTOM_PLURAL_KEYS = Object.keys(CUSTOM_PLURALS).sort((a, b) => b.length - a.length)
|
|
254
|
+
|
|
255
|
+
// canonize() memoizes depluralize() output, and depluralize() consults
|
|
256
|
+
// CUSTOM_PLURALS — so the cache is only valid for the plural config that
|
|
257
|
+
// produced it. ApiDef.makeBuild reuses one apidef instance across models,
|
|
258
|
+
// so without this a second model would read the first model's
|
|
259
|
+
// custom-plural-affected canonize results. Invalidate on every change.
|
|
260
|
+
CANONIZE_CACHE.clear()
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
function clearCustomPlurals() {
|
|
265
|
+
setCustomPlurals(undefined)
|
|
266
|
+
}
|
|
267
|
+
|
|
154
268
|
function depluralize(word: string): string {
|
|
155
269
|
if (!word || word.length === 0) {
|
|
156
270
|
return word
|
|
157
271
|
}
|
|
158
272
|
|
|
159
|
-
|
|
160
|
-
|
|
273
|
+
// Case-insensitive throughout: IRREGULARS lookups and every
|
|
274
|
+
// suffix-rule endsWith() check operate on the lowercased form,
|
|
275
|
+
// but slice/concat use the original word so the caller's casing
|
|
276
|
+
// is preserved (HOUSES → HOUSE, Houses → House, houses → house).
|
|
277
|
+
const lower = word.toLowerCase()
|
|
278
|
+
|
|
279
|
+
// Per-model custom plurals win over the built-in IRREGULARS and
|
|
280
|
+
// rule chain. Same lookup shape: exact match first, then
|
|
281
|
+
// longest-suffix match against CUSTOM_PLURAL_KEYS.
|
|
282
|
+
const customExact = CUSTOM_PLURALS[lower]
|
|
283
|
+
if (customExact) {
|
|
284
|
+
return matchCase(word, customExact)
|
|
285
|
+
}
|
|
286
|
+
for (const ending of CUSTOM_PLURAL_KEYS) {
|
|
287
|
+
if (lower.endsWith(ending)) {
|
|
288
|
+
const cut = word.length - ending.length
|
|
289
|
+
return word.slice(0, cut) + matchCase(word.slice(cut), CUSTOM_PLURALS[ending])
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const exact = IRREGULARS[lower]
|
|
294
|
+
if (exact) {
|
|
295
|
+
return matchCase(word, exact)
|
|
161
296
|
}
|
|
162
297
|
|
|
163
|
-
for (
|
|
164
|
-
if (
|
|
165
|
-
|
|
298
|
+
for (const ending of IRREGULAR_KEYS) {
|
|
299
|
+
if (lower.endsWith(ending)) {
|
|
300
|
+
const cut = word.length - ending.length
|
|
301
|
+
return word.slice(0, cut) + matchCase(word.slice(cut), IRREGULARS[ending])
|
|
166
302
|
}
|
|
167
303
|
}
|
|
168
304
|
|
|
169
|
-
// Rules for regular plurals (applied in order)
|
|
305
|
+
// Rules for regular plurals (applied in order). The -ies and -ves
|
|
306
|
+
// rules add a letter, so they need to match the case of the dropped
|
|
307
|
+
// suffix; all other rules just slice and inherit the caller's case.
|
|
170
308
|
|
|
171
309
|
// -ies -> -y (cities -> city), but only if result is > 2 chars
|
|
172
|
-
if (
|
|
173
|
-
const
|
|
310
|
+
if (lower.endsWith('ies') && word.length > 3) {
|
|
311
|
+
const dropped = word.slice(-3)
|
|
312
|
+
const y = dropped === dropped.toUpperCase() ? 'Y' : 'y'
|
|
313
|
+
const result = word.slice(0, -3) + y
|
|
174
314
|
if (result.length > 2) {
|
|
175
315
|
return result
|
|
176
316
|
}
|
|
177
317
|
}
|
|
178
318
|
|
|
179
319
|
// -ves -> -f or -fe (wolves -> wolf, knives -> knife)
|
|
180
|
-
if (
|
|
320
|
+
if (lower.endsWith('ves')) {
|
|
181
321
|
const stem = word.slice(0, -3)
|
|
322
|
+
const dropped = word.slice(-3)
|
|
323
|
+
const isUpper = dropped === dropped.toUpperCase()
|
|
182
324
|
// Check if it should be -fe (like knife, wife, life)
|
|
183
|
-
if (['kni', 'wi', 'li'].includes(stem)) {
|
|
184
|
-
return stem + 'fe'
|
|
325
|
+
if (['kni', 'wi', 'li'].includes(stem.toLowerCase())) {
|
|
326
|
+
return stem + (isUpper ? 'FE' : 'fe')
|
|
185
327
|
}
|
|
186
|
-
return stem + 'f'
|
|
328
|
+
return stem + (isUpper ? 'F' : 'f')
|
|
187
329
|
}
|
|
188
330
|
|
|
189
331
|
// -oes -> -o (potatoes -> potato)
|
|
190
|
-
if (
|
|
332
|
+
if (lower.endsWith('oes')) {
|
|
191
333
|
return word.slice(0, -2)
|
|
192
334
|
}
|
|
193
335
|
|
|
194
336
|
// Handle words ending in -nses (like responses, expenses, licenses)
|
|
195
337
|
// These should only lose the final -s, not -es
|
|
196
|
-
if (
|
|
338
|
+
if (lower.endsWith('nses')) {
|
|
339
|
+
return word.slice(0, -1)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// -zes plurals come from -ze singulars (prize, size, freeze, maze,
|
|
343
|
+
// breeze, …) far more often than from a bare -z taking -es. The only
|
|
344
|
+
// -zes plurals that strip the full -es have a doubled-z stem
|
|
345
|
+
// (buzz/buzzes, fez/fezzes). Discriminate on -zzes so prizes → prize
|
|
346
|
+
// instead of priz. Mirrors the -ses/-Vse+s problem the IRREGULARS
|
|
347
|
+
// table works around for the -se case.
|
|
348
|
+
if (lower.endsWith('zzes')) {
|
|
349
|
+
return word.slice(0, -2)
|
|
350
|
+
}
|
|
351
|
+
if (lower.endsWith('zes')) {
|
|
197
352
|
return word.slice(0, -1)
|
|
198
353
|
}
|
|
199
354
|
|
|
200
|
-
// -ses, -xes, -
|
|
201
|
-
if (
|
|
202
|
-
|
|
355
|
+
// -ses, -xes, -shes, -ches -> remove -es (boxes -> box)
|
|
356
|
+
if (lower.endsWith('ses') || lower.endsWith('xes') ||
|
|
357
|
+
lower.endsWith('shes') || lower.endsWith('ches')) {
|
|
203
358
|
return word.slice(0, -2)
|
|
204
359
|
}
|
|
205
360
|
|
|
206
361
|
// -s -> remove -s (cats -> cat), but only if result is > 2 chars
|
|
207
|
-
if (
|
|
208
|
-
!
|
|
209
|
-
!
|
|
362
|
+
if (lower.endsWith('s') &&
|
|
363
|
+
!lower.endsWith('ss') &&
|
|
364
|
+
!lower.endsWith('us') &&
|
|
210
365
|
word.length > 3
|
|
211
366
|
) {
|
|
212
367
|
return word.slice(0, -1)
|
|
@@ -572,7 +727,10 @@ function formatJSONIC(
|
|
|
572
727
|
'`' + JSON.stringify(v)
|
|
573
728
|
.substring(1)
|
|
574
729
|
.replace(/\\n/g, '\n')
|
|
575
|
-
|
|
730
|
+
// Inside a JSONIC backtick literal a double quote is a literal
|
|
731
|
+
// character, so unescape JSON's \" back to " (was previously
|
|
732
|
+
// replaced with ':' which silently corrupted quoted text).
|
|
733
|
+
.replace(/\\"/g, '"')
|
|
576
734
|
.replace(/`/g, '\\`')
|
|
577
735
|
.replace(/"$/, '`'))
|
|
578
736
|
case 'number': return c('number', Number.isFinite(v) ? String(v) : 'null')
|
|
@@ -1148,6 +1306,8 @@ export {
|
|
|
1148
1306
|
loadFile,
|
|
1149
1307
|
formatJsonSrc,
|
|
1150
1308
|
depluralize,
|
|
1309
|
+
setCustomPlurals,
|
|
1310
|
+
clearCustomPlurals,
|
|
1151
1311
|
find,
|
|
1152
1312
|
capture,
|
|
1153
1313
|
pathMatch,
|
package/README.md
DELETED
|
File without changes
|