@voxgig/apidef 6.2.0 → 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.
Files changed (40) hide show
  1. package/bin/voxgig-apidef +1 -1
  2. package/dist/apidef.js +9 -0
  3. package/dist/apidef.js.map +1 -1
  4. package/dist/builder/entity/entity.js +10 -12
  5. package/dist/builder/entity/entity.js.map +1 -1
  6. package/dist/builder/entity/info.js +5 -1
  7. package/dist/builder/entity/info.js.map +1 -1
  8. package/dist/guide/guide.js +7 -9
  9. package/dist/guide/guide.js.map +1 -1
  10. package/dist/guide/heuristic01.js +43 -3
  11. package/dist/guide/heuristic01.js.map +1 -1
  12. package/dist/parse.js +9 -1
  13. package/dist/parse.js.map +1 -1
  14. package/dist/transform/field.js +21 -10
  15. package/dist/transform/field.js.map +1 -1
  16. package/dist/transform/operation.js +7 -1
  17. package/dist/transform/operation.js.map +1 -1
  18. package/dist/transform/top.js +70 -0
  19. package/dist/transform/top.js.map +1 -1
  20. package/dist/types.d.ts +28 -1
  21. package/dist/types.js +8 -0
  22. package/dist/types.js.map +1 -1
  23. package/dist/utility.d.ts +3 -1
  24. package/dist/utility.js +179 -29
  25. package/dist/utility.js.map +1 -1
  26. package/model/{apidef.jsonic → apidef.aontu} +6 -0
  27. package/package.json +9 -3
  28. package/src/apidef.ts +12 -0
  29. package/src/builder/entity/entity.ts +10 -20
  30. package/src/builder/entity/info.ts +5 -1
  31. package/src/guide/guide.ts +6 -7
  32. package/src/guide/heuristic01.ts +46 -3
  33. package/src/parse.ts +9 -1
  34. package/src/transform/field.ts +21 -14
  35. package/src/transform/operation.ts +7 -1
  36. package/src/transform/top.ts +72 -0
  37. package/src/types.ts +9 -1
  38. package/src/utility.ts +189 -29
  39. package/README.md +0 -2
  40. /package/model/{guide.jsonic → guide.aontu} +0 -0
@@ -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
- if (resprops[entdesc.origname]) {
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 OpenAPI version: undefined`
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()
@@ -54,7 +54,7 @@ const fieldTransform: Transform = async function(
54
54
  seen[opfield.name] = opfield
55
55
  }
56
56
  else {
57
- mergeField(ment, mop, mpoint, def, seen[opfield.name], opfield)
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
- if (requestBody) {
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
- if (requiredNames.includes(property.key$)) {
178
- property.required = true
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[200] ?? responses[201] ?? responses['200'] ?? responses['201']
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
- mpoint: ModelPoint,
316
- def: any,
317
- exisingField: ModelField,
325
+ existingField: ModelField,
318
326
  newField: ModelField
319
327
  ) {
320
-
321
- if (newField.req !== exisingField.req) {
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 exisingField
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
- transform: opdesc.transform ?? {},
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
  }
@@ -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 - hoisted to module scope to avoid re-allocation per call.
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
- 'furnace': 'furnaces',
157
+ 'furnaces': 'furnace',
127
158
  'geese': 'goose',
159
+ 'headaches': 'headache',
128
160
  'horses': 'horse',
129
- 'house': 'houses',
161
+ 'hoses': 'hose',
162
+ 'houses': 'house',
130
163
  'indices': 'index',
131
164
  'lens': 'lens',
132
- 'license': 'licenses',
165
+ 'licenses': 'license',
133
166
  'matrices': 'matrix',
134
167
  'men': 'man',
135
168
  'mice': 'mouse',
169
+ 'moustaches': 'moustache',
136
170
  'movies': 'movie',
137
- 'notice': 'notices',
171
+ 'mustaches': 'mustache',
172
+ 'niches': 'niche',
173
+ 'noses': 'nose',
174
+ 'notices': 'notice',
175
+ 'nurses': 'nurse',
138
176
  'oases': 'oasis',
139
- 'phrase': 'phrase',
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
- if (IRREGULARS[word]) {
160
- return IRREGULARS[word]
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 (let ending in IRREGULARS) {
164
- if (word.endsWith(ending)) {
165
- return word.replace(ending, IRREGULARS[ending])
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 (word.endsWith('ies') && word.length > 3) {
173
- const result = word.slice(0, -3) + 'y'
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 (word.endsWith('ves')) {
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 (word.endsWith('oes')) {
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 (word.endsWith('nses')) {
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, -zes, -shes, -ches -> remove -es (boxes -> box)
201
- if (word.endsWith('ses') || word.endsWith('xes') || word.endsWith('zes') ||
202
- word.endsWith('shes') || word.endsWith('ches')) {
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 (word.endsWith('s') &&
208
- !word.endsWith('ss') &&
209
- !word.endsWith('us') &&
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
- .replace(/\\"/g, ':')
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
@@ -1,2 +0,0 @@
1
- # apidef
2
- API model definition
File without changes