metal-orm 1.0.100 → 1.0.102

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,445 +1,468 @@
1
- import {
2
- applyToCompoundHead,
3
- applyToCompoundWords,
4
- detectTextFormat,
5
- normalizeLookup,
6
- splitIntoWords,
7
- stripDiacritics
8
- } from './compound.mjs';
9
-
10
- // ═══════════════════════════════════════════════════════════════════════════
11
- // PATTERNS
12
- // ═══════════════════════════════════════════════════════════════════════════
13
-
14
- /**
15
- * Precompiled regex patterns for performance.
16
- * @type {Readonly<Record<string, RegExp>>}
17
- */
18
- const PATTERNS = Object.freeze({
19
- consonantEnding: /[rzn]$/,
20
- consonantEsEnding: /[rzn]es$/,
21
- endsInX: /x$/,
22
- vowelBeforeS: /[aeiou]s$/,
23
- });
24
-
25
- // ═══════════════════════════════════════════════════════════════════════════
26
- // IRREGULAR DICTIONARIES (all normalized - no diacritics)
27
- // ═══════════════════════════════════════════════════════════════════════════
28
-
29
- /**
30
- * Default irregular plurals for Brazilian Portuguese.
31
- * Keys AND values are normalized (no diacritics, lowercase).
32
- * @type {Readonly<Record<string, string>>}
33
- */
34
- export const PT_BR_DEFAULT_IRREGULARS = Object.freeze({
35
- // ─────────────────────────────────────────────────────────────────────────
36
- // -ão → -ães (irregular, must memorize)
37
- // ─────────────────────────────────────────────────────────────────────────
38
- 'pao': 'paes',
39
- 'cao': 'caes',
40
- 'alemao': 'alemaes',
41
- 'capitao': 'capitaes',
42
- 'charlatao': 'charlataes',
43
- 'escrivao': 'escrivaes',
44
- 'tabeliao': 'tabeliaes',
45
- 'guardiao': 'guardiaes',
46
- 'sacristao': 'sacristaes',
47
-
48
- // ─────────────────────────────────────────────────────────────────────────
49
- // -ão → -ãos (irregular, must memorize)
50
- // ─────────────────────────────────────────────────────────────────────────
51
- 'mao': 'maos',
52
- 'cidadao': 'cidadaos',
53
- 'cristao': 'cristaos',
54
- 'irmao': 'irmaos',
55
- 'orgao': 'orgaos',
56
- 'bencao': 'bencaos',
57
- 'grao': 'graos',
58
- 'orfao': 'orfaos',
59
- 'sotao': 'sotaos',
60
- 'acordao': 'acordaos',
61
- 'cortesao': 'cortesaos',
62
- 'pagao': 'pagaos',
63
- 'chao': 'chaos',
64
- 'vao': 'vaos',
65
-
66
- // ─────────────────────────────────────────────────────────────────────────
67
- // -l special cases
68
- // ─────────────────────────────────────────────────────────────────────────
69
- 'mal': 'males',
70
- 'consul': 'consules',
71
-
72
- // ─────────────────────────────────────────────────────────────────────────
73
- // Unstressed -il → -eis (paroxytones)
74
- // ─────────────────────────────────────────────────────────────────────────
75
- 'fossil': 'fosseis',
76
- 'reptil': 'repteis',
77
- 'facil': 'faceis',
78
- 'dificil': 'dificeis',
79
- 'util': 'uteis',
80
- 'inutil': 'inuteis',
81
- 'agil': 'ageis',
82
- 'fragil': 'frageis',
83
- 'projetil': 'projeteis',
84
- 'volatil': 'volateis',
85
- 'docil': 'doceis',
86
- 'portatil': 'portateis',
87
- 'textil': 'texteis',
88
-
89
- // ─────────────────────────────────────────────────────────────────────────
90
- // Invariable words (paroxytone/proparoxytone ending in -s/-x)
91
- // ─────────────────────────────────────────────────────────────────────────
92
- 'onibus': 'onibus',
93
- 'lapis': 'lapis',
94
- 'virus': 'virus',
95
- 'atlas': 'atlas',
96
- 'pires': 'pires',
97
- 'cais': 'cais',
98
- 'torax': 'torax',
99
- 'fenix': 'fenix',
100
- 'xerox': 'xerox',
101
- 'latex': 'latex',
102
- 'index': 'index',
103
- 'duplex': 'duplex',
104
- 'telex': 'telex',
105
- 'climax': 'climax',
106
- 'simples': 'simples',
107
- 'oasis': 'oasis',
108
- 'tenis': 'tenis',
109
-
110
- // ─────────────────────────────────────────────────────────────────────────
111
- // -ês → -eses (nationalities, months, etc.)
112
- // ─────────────────────────────────────────────────────────────────────────
113
- 'portugues': 'portugueses',
114
- 'ingles': 'ingleses',
115
- 'frances': 'franceses',
116
- 'holandes': 'holandeses',
117
- 'japones': 'japoneses',
118
- 'chines': 'chineses',
119
- 'irlandes': 'irlandeses',
120
- 'escoces': 'escoceses',
121
- 'mes': 'meses',
122
- 'burges': 'burgueses',
123
- 'fregues': 'fregueses',
124
- 'marques': 'marqueses',
125
-
126
- // ─────────────────────────────────────────────────────────────────────────
127
- // Other irregulars
128
- // ─────────────────────────────────────────────────────────────────────────
129
- 'qualquer': 'quaisquer',
130
- 'carater': 'caracteres',
131
- 'junior': 'juniores',
132
- 'senior': 'seniores',
133
- });
134
-
135
- /**
136
- * Builds reverse irregular mapping (plural → singular).
137
- * @param {Record<string, string>} irregulars
138
- * @returns {Record<string, string>}
139
- */
140
- const buildSingularIrregulars = (irregulars) => {
141
- const result = {};
142
- for (const [singular, plural] of Object.entries(irregulars)) {
143
- if (plural !== singular) {
144
- result[plural] = singular;
145
- }
146
- }
147
- return result;
148
- };
149
-
150
- /**
151
- * Default irregular singulars (auto-generated reverse mapping).
152
- * @type {Readonly<Record<string, string>>}
153
- */
154
- export const PT_BR_DEFAULT_SINGULAR_IRREGULARS = Object.freeze(
155
- buildSingularIrregulars(PT_BR_DEFAULT_IRREGULARS)
156
- );
157
-
158
- // ═══════════════════════════════════════════════════════════════════════════
159
- // CONNECTORS
160
- // ═══════════════════════════════════════════════════════════════════════════
161
-
162
- /**
163
- * Portuguese connector words used in compound expressions.
164
- * @type {ReadonlySet<string>}
165
- */
166
- export const PT_BR_CONNECTORS = Object.freeze(new Set(
167
- [
168
- 'de', 'da', 'do', 'das', 'dos',
169
- 'em', 'na', 'no', 'nas', 'nos',
170
- 'a', 'ao', 'as', 'aos',
171
- 'com', 'sem', 'sob', 'sobre',
172
- 'para', 'por', 'pela', 'pelo', 'pelas', 'pelos',
173
- 'entre', 'contra', 'perante',
174
- 'e', 'ou'
175
- ].map(normalizeLookup)
176
- ));
177
-
178
- const hasConnectorWord = (term, connectors) => {
179
- if (!term || !String(term).trim()) return false;
180
- const original = String(term).trim();
181
- const format = detectTextFormat(original);
182
- const words = splitIntoWords(original, format);
183
- return words.some(word => connectors?.has?.(normalizeLookup(word)));
184
- };
185
-
186
- // ═══════════════════════════════════════════════════════════════════════════
187
- // INFLECTION RULES
188
- // ═══════════════════════════════════════════════════════════════════════════
189
-
190
- /**
191
- * Pluralization rules for Portuguese words.
192
- * Format: [suffix, replacement, suffixLength]
193
- * @type {ReadonlyArray<Readonly<[string, string, number]>>}
194
- */
195
- const PLURAL_RULES = Object.freeze([
196
- // -ão → -ões (default; -ães and -ãos handled via irregulars)
197
- ['ao', 'oes', 2],
198
- // -m → -ns
199
- ['m', 'ns', 1],
200
- // -l endings
201
- ['al', 'ais', 2],
202
- ['el', 'eis', 2],
203
- ['ol', 'ois', 2],
204
- ['ul', 'uis', 2],
205
- ['il', 'is', 2], // Stressed -il; unstressed in irregulars
206
- ]);
207
-
208
- /**
209
- * Singularization rules for Portuguese words.
210
- * Format: [suffix, replacement, suffixLength]
211
- * @type {ReadonlyArray<Readonly<[string, string, number]>>}
212
- */
213
- const SINGULAR_RULES = Object.freeze([
214
- // -ões/-ães/-ãos → -ão
215
- ['oes', 'ao', 3],
216
- ['aes', 'ao', 3],
217
- ['aos', 'ao', 3],
218
- // -ns-m
219
- ['ns', 'm', 2],
220
- // -l endings reverse
221
- ['ais', 'al', 3],
222
- ['eis', 'el', 3],
223
- ['ois', 'ol', 3],
224
- ['uis', 'ul', 3],
225
- ['is', 'il', 2],
226
- // -eses → -es
227
- ['eses', 'es', 4],
228
- ]);
229
-
230
- // ═══════════════════════════════════════════════════════════════════════════
231
- // UTILITY FUNCTIONS
232
- // ═══════════════════════════════════════════════════════════════════════════
233
-
234
- /**
235
- * Normalizes a word for rule matching and irregular lookup.
236
- * @param {string} word - The word to normalize
237
- * @returns {string} Normalized word (lowercase, no diacritics)
238
- */
239
- const normalizeWord = (word) =>
240
- stripDiacritics((word ?? '').toString()).toLowerCase().trim();
241
-
242
- /**
243
- * Applies suffix rules to a word.
244
- * @param {string} word - Normalized word
245
- * @param {ReadonlyArray<Readonly<[string, string, number]>>} rules
246
- * @returns {string|null} Transformed word or null if no rule matched
247
- */
248
- const applyRules = (word, rules) => {
249
- for (const [suffix, replacement, length] of rules) {
250
- if (word.endsWith(suffix)) {
251
- return word.slice(0, -length) + replacement;
252
- }
253
- }
254
- return null;
255
- };
256
-
257
- // ═══════════════════════════════════════════════════════════════════════════
258
- // PLURALIZATION
259
- // ═══════════════════════════════════════════════════════════════════════════
260
-
261
- /**
262
- * Converts a Portuguese word to its plural form.
263
- * Output is normalized (no diacritics, lowercase).
264
- *
265
- * @param {string} word - The word to pluralize
266
- * @param {Record<string, string>} [irregulars=PT_BR_DEFAULT_IRREGULARS]
267
- * @returns {string} The pluralized word (normalized)
268
- */
269
- export const pluralizeWordPtBr = (
270
- word,
271
- irregulars = PT_BR_DEFAULT_IRREGULARS
272
- ) => {
273
- const normalized = normalizeWord(word);
274
- if (!normalized) return '';
275
-
276
- // 1. Check irregulars first
277
- const irregular = irregulars[normalized];
278
- if (irregular !== undefined) {
279
- return irregular;
280
- }
281
-
282
- // 2. Apply suffix-based rules
283
- const ruleResult = applyRules(normalized, PLURAL_RULES);
284
- if (ruleResult !== null) {
285
- return ruleResult;
286
- }
287
-
288
- // 3. Words ending in -x are typically invariable
289
- if (PATTERNS.endsInX.test(normalized)) {
290
- return normalized;
291
- }
292
-
293
- // 4. Consonants r, z, n require -es
294
- if (PATTERNS.consonantEnding.test(normalized)) {
295
- return normalized + 'es';
296
- }
297
-
298
- // 5. Words ending in -s (invariable or already plural)
299
- if (normalized.endsWith('s')) {
300
- return normalized;
301
- }
302
-
303
- // 6. Default: add -s
304
- return normalized + 's';
305
- };
306
-
307
- // ═══════════════════════════════════════════════════════════════════════════
308
- // SINGULARIZATION
309
- // ═══════════════════════════════════════════════════════════════════════════
310
-
311
- /**
312
- * Converts a Portuguese word to its singular form.
313
- * Output is normalized (no diacritics, lowercase).
314
- *
315
- * @param {string} word - The word to singularize
316
- * @param {Record<string, string>} [irregulars=PT_BR_DEFAULT_SINGULAR_IRREGULARS]
317
- * @returns {string} The singularized word (normalized)
318
- */
319
- export const singularizeWordPtBr = (
320
- word,
321
- irregulars = PT_BR_DEFAULT_SINGULAR_IRREGULARS
322
- ) => {
323
- const normalized = normalizeWord(word);
324
- if (!normalized) return '';
325
-
326
- // 1. Check irregulars first
327
- const irregular = irregulars[normalized];
328
- if (irregular !== undefined) {
329
- return irregular;
330
- }
331
-
332
- // 2. Apply suffix-based rules
333
- const ruleResult = applyRules(normalized, SINGULAR_RULES);
334
- if (ruleResult !== null) {
335
- return ruleResult;
336
- }
337
-
338
- // 3. Handle consonant + es pattern
339
- if (PATTERNS.consonantEsEnding.test(normalized)) {
340
- return normalized.slice(0, -2);
341
- }
342
-
343
- // 4. Words ending in vowel+s: remove s
344
- if (PATTERNS.vowelBeforeS.test(normalized)) {
345
- return normalized.slice(0, -1);
346
- }
347
-
348
- // 5. Already singular or invariable
349
- return normalized;
350
- };
351
-
352
- // ═══════════════════════════════════════════════════════════════════════════
353
- // NOUN SPECIFIERS (SUBSTANTIVOS DETERMINANTES)
354
- // ═══════════════════════════════════════════════════════════════════════════
355
-
356
- /**
357
- * Portuguese words that act as specifiers/delimiters in compound nouns.
358
- * When these appear as the second term, only the first term varies.
359
- * @type {ReadonlySet<string>}
360
- */
361
- export const PT_BR_NOUN_SPECIFIERS = Object.freeze(new Set(
362
- [
363
- 'correcao', 'padrao', 'limite', 'chave', 'base', 'chefe',
364
- 'satelite', 'fantasma', 'monstro', 'escola', 'piloto',
365
- 'femea', 'macho', 'geral', 'solicitacao'
366
- ].map(normalizeLookup)
367
- ));
368
-
369
- const isCompoundWithSpecifier = (term, specifiers = PT_BR_NOUN_SPECIFIERS) => {
370
- if (!term || !String(term).trim()) return false;
371
- const original = String(term).trim();
372
- const format = detectTextFormat(original);
373
- const words = splitIntoWords(original, format);
374
-
375
- if (words.length < 2) return false;
376
-
377
- // Check if the last word is a known specifier
378
- const lastWord = words[words.length - 1];
379
- return specifiers.has(normalizeLookup(lastWord));
380
- };
381
-
382
- // ═══════════════════════════════════════════════════════════════════════════
383
- // COMPOUND TERM HANDLING
384
- // ═══════════════════════════════════════════════════════════════════════════
385
-
386
- /**
387
- * Pluralizes a compound property/relation name in Portuguese.
388
- */
389
- export const pluralizeRelationPropertyPtBr = (
390
- term,
391
- { pluralizeWord = pluralizeWordPtBr, connectors = PT_BR_CONNECTORS, specifiers = PT_BR_NOUN_SPECIFIERS } = {}
392
- ) => {
393
- if (hasConnectorWord(term, connectors) || isCompoundWithSpecifier(term, specifiers)) {
394
- return applyToCompoundHead(term, { connectors, transformWord: pluralizeWord });
395
- }
396
- return applyToCompoundWords(term, { connectors, transformWord: pluralizeWord });
397
- }
398
-
399
- /**
400
- * Singularizes a compound property/relation name in Portuguese.
401
- */
402
- export const singularizeRelationPropertyPtBr = (
403
- term,
404
- { singularizeWord = singularizeWordPtBr, connectors = PT_BR_CONNECTORS, specifiers = PT_BR_NOUN_SPECIFIERS } = {}
405
- ) => {
406
- if (hasConnectorWord(term, connectors) || isCompoundWithSpecifier(term, specifiers)) {
407
- return applyToCompoundHead(term, { connectors, transformWord: singularizeWord });
408
- }
409
- return applyToCompoundWords(term, { connectors, transformWord: singularizeWord });
410
- }
411
-
412
- // ═══════════════════════════════════════════════════════════════════════════
413
- // INFLECTOR FACTORY
414
- // ═══════════════════════════════════════════════════════════════════════════
415
-
416
- /**
417
- * Creates a Brazilian Portuguese inflector instance.
418
- */
419
- export const createPtBrInflector = ({ customIrregulars = {} } = {}) => {
420
- const irregularPlurals = Object.freeze({
421
- ...PT_BR_DEFAULT_IRREGULARS,
422
- ...customIrregulars
423
- });
424
-
425
- const irregularSingulars = Object.freeze({
426
- ...PT_BR_DEFAULT_SINGULAR_IRREGULARS,
427
- ...buildSingularIrregulars(customIrregulars)
428
- });
429
-
430
- const pluralizeWord = (w) => pluralizeWordPtBr(w, irregularPlurals);
431
- const singularizeWord = (w) => singularizeWordPtBr(w, irregularSingulars);
432
-
433
- return Object.freeze({
434
- locale: 'pt-BR',
435
- irregularPlurals,
436
- irregularSingulars,
437
- pluralizeWord,
438
- singularizeWord,
439
- pluralizeRelationProperty: (term) => pluralizeRelationPropertyPtBr(term, { pluralizeWord }),
440
- singularizeRelationProperty: (term) => singularizeRelationPropertyPtBr(term, { singularizeWord }),
441
- normalizeForLookup: normalizeWord
442
- });
443
- };
444
-
445
- export default createPtBrInflector;
1
+ import {
2
+ applyToCompoundHead,
3
+ applyToCompoundWords,
4
+ detectTextFormat,
5
+ normalizeLookup,
6
+ splitIntoWords,
7
+ stripDiacritics
8
+ } from './compound.mjs';
9
+
10
+ // ═══════════════════════════════════════════════════════════════════════════
11
+ // PATTERNS
12
+ // ═══════════════════════════════════════════════════════════════════════════
13
+
14
+ /**
15
+ * Precompiled regex patterns for performance.
16
+ * @type {Readonly<Record<string, RegExp>>}
17
+ */
18
+ const PATTERNS = Object.freeze({
19
+ consonantEnding: /[rzn]$/,
20
+ consonantEsEnding: /[rzn]es$/,
21
+ endsInX: /x$/,
22
+ vowelBeforeS: /[aeiou]s$/,
23
+ });
24
+
25
+ // ═══════════════════════════════════════════════════════════════════════════
26
+ // IRREGULAR DICTIONARIES (all normalized - no diacritics)
27
+ // ═══════════════════════════════════════════════════════════════════════════
28
+
29
+ /**
30
+ * Default irregular plurals for Brazilian Portuguese.
31
+ * Keys AND values are normalized (no diacritics, lowercase).
32
+ * @type {Readonly<Record<string, string>>}
33
+ */
34
+ export const PT_BR_DEFAULT_IRREGULARS = Object.freeze({
35
+ // ─────────────────────────────────────────────────────────────────────────
36
+ // -ão → -ães (irregular, must memorize)
37
+ // ─────────────────────────────────────────────────────────────────────────
38
+ 'pao': 'paes',
39
+ 'cao': 'caes',
40
+ 'alemao': 'alemaes',
41
+ 'capitao': 'capitaes',
42
+ 'charlatao': 'charlataes',
43
+ 'escrivao': 'escrivaes',
44
+ 'tabeliao': 'tabeliaes',
45
+ 'guardiao': 'guardiaes',
46
+ 'sacristao': 'sacristaes',
47
+
48
+ // ─────────────────────────────────────────────────────────────────────────
49
+ // -ão → -ãos (irregular, must memorize)
50
+ // ─────────────────────────────────────────────────────────────────────────
51
+ 'mao': 'maos',
52
+ 'cidadao': 'cidadaos',
53
+ 'cristao': 'cristaos',
54
+ 'irmao': 'irmaos',
55
+ 'orgao': 'orgaos',
56
+ 'bencao': 'bencaos',
57
+ 'grao': 'graos',
58
+ 'orfao': 'orfaos',
59
+ 'sotao': 'sotaos',
60
+ 'acordao': 'acordaos',
61
+ 'cortesao': 'cortesaos',
62
+ 'pagao': 'pagaos',
63
+ 'chao': 'chaos',
64
+ 'vao': 'vaos',
65
+
66
+ // ─────────────────────────────────────────────────────────────────────────
67
+ // -l special cases
68
+ // ─────────────────────────────────────────────────────────────────────────
69
+ 'mal': 'males',
70
+ 'consul': 'consules',
71
+
72
+ // ─────────────────────────────────────────────────────────────────────────
73
+ // Unstressed -il → -eis (paroxytones)
74
+ // ─────────────────────────────────────────────────────────────────────────
75
+ 'fossil': 'fosseis',
76
+ 'reptil': 'repteis',
77
+ 'facil': 'faceis',
78
+ 'dificil': 'dificeis',
79
+ 'util': 'uteis',
80
+ 'inutil': 'inuteis',
81
+ 'agil': 'ageis',
82
+ 'fragil': 'frageis',
83
+ 'projetil': 'projeteis',
84
+ 'volatil': 'volateis',
85
+ 'docil': 'doceis',
86
+ 'portatil': 'portateis',
87
+ 'textil': 'texteis',
88
+
89
+ // ─────────────────────────────────────────────────────────────────────────
90
+ // Singular words ending in -s/-us (not invariable, need -es plural)
91
+ // ─────────────────────────────────────────────────────────────────────────
92
+ 'deus': 'deuses',
93
+
94
+ // ─────────────────────────────────────────────────────────────────────────
95
+ // Invariable words (paroxytone/proparoxytone ending in -s/-x)
96
+ // ─────────────────────────────────────────────────────────────────────────
97
+ 'caos': 'caos',
98
+ 'onibus': 'onibus',
99
+ 'lapis': 'lapis',
100
+ 'virus': 'virus',
101
+ 'atlas': 'atlas',
102
+ 'pires': 'pires',
103
+ 'cais': 'cais',
104
+ 'torax': 'torax',
105
+ 'fenix': 'fenix',
106
+ 'xerox': 'xerox',
107
+ 'latex': 'latex',
108
+ 'index': 'index',
109
+ 'duplex': 'duplex',
110
+ 'telex': 'telex',
111
+ 'climax': 'climax',
112
+ 'simples': 'simples',
113
+ 'oasis': 'oasis',
114
+ 'tenis': 'tenis',
115
+
116
+ // ─────────────────────────────────────────────────────────────────────────
117
+ // -ês → -eses (nationalities, months, etc.)
118
+ // ─────────────────────────────────────────────────────────────────────────
119
+ 'portugues': 'portugueses',
120
+ 'ingles': 'ingleses',
121
+ 'frances': 'franceses',
122
+ 'holandes': 'holandeses',
123
+ 'japones': 'japoneses',
124
+ 'chines': 'chineses',
125
+ 'irlandes': 'irlandeses',
126
+ 'escoces': 'escoceses',
127
+ 'mes': 'meses',
128
+ 'burges': 'burgueses',
129
+ 'fregues': 'fregueses',
130
+ 'marques': 'marqueses',
131
+
132
+ // ─────────────────────────────────────────────────────────────────────────
133
+ // Other irregulars
134
+ // ─────────────────────────────────────────────────────────────────────────
135
+ 'qualquer': 'quaisquer',
136
+ 'carater': 'caracteres',
137
+ 'junior': 'juniores',
138
+ 'senior': 'seniores',
139
+
140
+ // ─────────────────────────────────────────────────────────────────────────
141
+ // Ambiguous normalized forms (context-dependent)
142
+ // "pais" (plural of pai) is invariable; "país" → "países" handled by rule
143
+ // ─────────────────────────────────────────────────────────────────────────
144
+ 'pais': 'paises', // país → países (oxytone in -s)
145
+ });
146
+
147
+ /**
148
+ * Builds reverse irregular mapping (plural → singular).
149
+ * @param {Record<string, string>} irregulars
150
+ * @returns {Record<string, string>}
151
+ */
152
+ const buildSingularIrregulars = (irregulars) => {
153
+ const result = {};
154
+ for (const [singular, plural] of Object.entries(irregulars)) {
155
+ if (plural !== singular) {
156
+ result[plural] = singular;
157
+ }
158
+ }
159
+ return result;
160
+ };
161
+
162
+ /**
163
+ * Default irregular singulars (auto-generated reverse mapping).
164
+ * @type {Readonly<Record<string, string>>}
165
+ */
166
+ export const PT_BR_DEFAULT_SINGULAR_IRREGULARS = Object.freeze(
167
+ buildSingularIrregulars(PT_BR_DEFAULT_IRREGULARS)
168
+ );
169
+
170
+ // ═══════════════════════════════════════════════════════════════════════════
171
+ // CONNECTORS
172
+ // ═══════════════════════════════════════════════════════════════════════════
173
+
174
+ /**
175
+ * Portuguese connector words used in compound expressions.
176
+ * @type {ReadonlySet<string>}
177
+ */
178
+ export const PT_BR_CONNECTORS = Object.freeze(new Set(
179
+ [
180
+ 'de', 'da', 'do', 'das', 'dos',
181
+ 'em', 'na', 'no', 'nas', 'nos',
182
+ 'a', 'ao', 'as', 'aos',
183
+ 'com', 'sem', 'sob', 'sobre',
184
+ 'para', 'por', 'pela', 'pelo', 'pelas', 'pelos',
185
+ 'entre', 'contra', 'perante',
186
+ 'e', 'ou'
187
+ ].map(normalizeLookup)
188
+ ));
189
+
190
+ const hasConnectorWord = (term, connectors) => {
191
+ if (!term || !String(term).trim()) return false;
192
+ const original = String(term).trim();
193
+ const format = detectTextFormat(original);
194
+ const words = splitIntoWords(original, format);
195
+ return words.some(word => connectors?.has?.(normalizeLookup(word)));
196
+ };
197
+
198
+ // ═══════════════════════════════════════════════════════════════════════════
199
+ // INFLECTION RULES
200
+ // ═══════════════════════════════════════════════════════════════════════════
201
+
202
+ /**
203
+ * Pluralization rules for Portuguese words.
204
+ * Format: [suffix, replacement, suffixLength]
205
+ * @type {ReadonlyArray<Readonly<[string, string, number]>>}
206
+ */
207
+ const PLURAL_RULES = Object.freeze([
208
+ // -ão → -ões (default; -ães and -ãos handled via irregulars)
209
+ ['ao', 'oes', 2],
210
+ // -m -ns
211
+ ['m', 'ns', 1],
212
+ // -l endings
213
+ ['al', 'ais', 2],
214
+ ['el', 'eis', 2],
215
+ ['ol', 'ois', 2],
216
+ ['ul', 'uis', 2],
217
+ ['il', 'is', 2], // Stressed -il; unstressed in irregulars
218
+ // Oxytone words ending in -s add -es (gásgases, ás→ases, ês→eses, etc.)
219
+ // Note: normalized form (no diacritics), so "gás" becomes "gas"
220
+ ['as', 'ases', 2],
221
+ ['es', 'eses', 2],
222
+ ['is', 'ises', 2],
223
+ ['os', 'oses', 2],
224
+ ['us', 'uses', 2],
225
+ ]);
226
+
227
+ /**
228
+ * Singularization rules for Portuguese words.
229
+ * Format: [suffix, replacement, suffixLength]
230
+ * @type {ReadonlyArray<Readonly<[string, string, number]>>}
231
+ */
232
+ const SINGULAR_RULES = Object.freeze([
233
+ // -ões/-ães/-ãos → -ão
234
+ ['oes', 'ao', 3],
235
+ ['aes', 'ao', 3],
236
+ ['aos', 'ao', 3],
237
+ // -ns -m
238
+ ['ns', 'm', 2],
239
+ // -l endings reverse
240
+ ['ais', 'al', 3],
241
+ ['eis', 'el', 3],
242
+ ['ois', 'ol', 3],
243
+ ['uis', 'ul', 3],
244
+ ['is', 'il', 2],
245
+ // Oxytone -Vses → -Vs (gases→gas, meses→mes, etc.)
246
+ ['ases', 'as', 4],
247
+ ['eses', 'es', 4],
248
+ ['ises', 'is', 4],
249
+ ['oses', 'os', 4],
250
+ ['uses', 'us', 4],
251
+ ]);
252
+
253
+ // ═══════════════════════════════════════════════════════════════════════════
254
+ // UTILITY FUNCTIONS
255
+ // ═══════════════════════════════════════════════════════════════════════════
256
+
257
+ /**
258
+ * Normalizes a word for rule matching and irregular lookup.
259
+ * @param {string} word - The word to normalize
260
+ * @returns {string} Normalized word (lowercase, no diacritics)
261
+ */
262
+ const normalizeWord = (word) =>
263
+ stripDiacritics((word ?? '').toString()).toLowerCase().trim();
264
+
265
+ /**
266
+ * Applies suffix rules to a word.
267
+ * @param {string} word - Normalized word
268
+ * @param {ReadonlyArray<Readonly<[string, string, number]>>} rules
269
+ * @returns {string|null} Transformed word or null if no rule matched
270
+ */
271
+ const applyRules = (word, rules) => {
272
+ for (const [suffix, replacement, length] of rules) {
273
+ if (word.endsWith(suffix)) {
274
+ return word.slice(0, -length) + replacement;
275
+ }
276
+ }
277
+ return null;
278
+ };
279
+
280
+ // ═══════════════════════════════════════════════════════════════════════════
281
+ // PLURALIZATION
282
+ // ═══════════════════════════════════════════════════════════════════════════
283
+
284
+ /**
285
+ * Converts a Portuguese word to its plural form.
286
+ * Output is normalized (no diacritics, lowercase).
287
+ *
288
+ * @param {string} word - The word to pluralize
289
+ * @param {Record<string, string>} [irregulars=PT_BR_DEFAULT_IRREGULARS]
290
+ * @returns {string} The pluralized word (normalized)
291
+ */
292
+ export const pluralizeWordPtBr = (
293
+ word,
294
+ irregulars = PT_BR_DEFAULT_IRREGULARS
295
+ ) => {
296
+ const normalized = normalizeWord(word);
297
+ if (!normalized) return '';
298
+
299
+ // 1. Check irregulars first
300
+ const irregular = irregulars[normalized];
301
+ if (irregular !== undefined) {
302
+ return irregular;
303
+ }
304
+
305
+ // 2. Apply suffix-based rules
306
+ const ruleResult = applyRules(normalized, PLURAL_RULES);
307
+ if (ruleResult !== null) {
308
+ return ruleResult;
309
+ }
310
+
311
+ // 3. Words ending in -x are typically invariable
312
+ if (PATTERNS.endsInX.test(normalized)) {
313
+ return normalized;
314
+ }
315
+
316
+ // 4. Consonants r, z, n require -es
317
+ if (PATTERNS.consonantEnding.test(normalized)) {
318
+ return normalized + 'es';
319
+ }
320
+
321
+ // 5. Words ending in -s (invariable or already plural)
322
+ if (normalized.endsWith('s')) {
323
+ return normalized;
324
+ }
325
+
326
+ // 6. Default: add -s
327
+ return normalized + 's';
328
+ };
329
+
330
+ // ═══════════════════════════════════════════════════════════════════════════
331
+ // SINGULARIZATION
332
+ // ═══════════════════════════════════════════════════════════════════════════
333
+
334
+ /**
335
+ * Converts a Portuguese word to its singular form.
336
+ * Output is normalized (no diacritics, lowercase).
337
+ *
338
+ * @param {string} word - The word to singularize
339
+ * @param {Record<string, string>} [irregulars=PT_BR_DEFAULT_SINGULAR_IRREGULARS]
340
+ * @returns {string} The singularized word (normalized)
341
+ */
342
+ export const singularizeWordPtBr = (
343
+ word,
344
+ irregulars = PT_BR_DEFAULT_SINGULAR_IRREGULARS
345
+ ) => {
346
+ const normalized = normalizeWord(word);
347
+ if (!normalized) return '';
348
+
349
+ // 1. Check irregulars first
350
+ const irregular = irregulars[normalized];
351
+ if (irregular !== undefined) {
352
+ return irregular;
353
+ }
354
+
355
+ // 2. Apply suffix-based rules
356
+ const ruleResult = applyRules(normalized, SINGULAR_RULES);
357
+ if (ruleResult !== null) {
358
+ return ruleResult;
359
+ }
360
+
361
+ // 3. Handle consonant + es pattern
362
+ if (PATTERNS.consonantEsEnding.test(normalized)) {
363
+ return normalized.slice(0, -2);
364
+ }
365
+
366
+ // 4. Words ending in vowel+s: remove s
367
+ if (PATTERNS.vowelBeforeS.test(normalized)) {
368
+ return normalized.slice(0, -1);
369
+ }
370
+
371
+ // 5. Already singular or invariable
372
+ return normalized;
373
+ };
374
+
375
+ // ═══════════════════════════════════════════════════════════════════════════
376
+ // NOUN SPECIFIERS (SUBSTANTIVOS DETERMINANTES)
377
+ // ═══════════════════════════════════════════════════════════════════════════
378
+
379
+ /**
380
+ * Portuguese words that act as specifiers/delimiters in compound nouns.
381
+ * When these appear as the second term, only the first term varies.
382
+ * @type {ReadonlySet<string>}
383
+ */
384
+ export const PT_BR_NOUN_SPECIFIERS = Object.freeze(new Set(
385
+ [
386
+ 'correcao', 'padrao', 'limite', 'chave', 'base', 'chefe',
387
+ 'satelite', 'fantasma', 'monstro', 'escola', 'piloto',
388
+ 'femea', 'macho', 'geral', 'solicitacao'
389
+ ].map(normalizeLookup)
390
+ ));
391
+
392
+ const isCompoundWithSpecifier = (term, specifiers = PT_BR_NOUN_SPECIFIERS) => {
393
+ if (!term || !String(term).trim()) return false;
394
+ const original = String(term).trim();
395
+ const format = detectTextFormat(original);
396
+ const words = splitIntoWords(original, format);
397
+
398
+ if (words.length < 2) return false;
399
+
400
+ // Check if the last word is a known specifier
401
+ const lastWord = words[words.length - 1];
402
+ return specifiers.has(normalizeLookup(lastWord));
403
+ };
404
+
405
+ // ═══════════════════════════════════════════════════════════════════════════
406
+ // COMPOUND TERM HANDLING
407
+ // ═══════════════════════════════════════════════════════════════════════════
408
+
409
+ /**
410
+ * Pluralizes a compound property/relation name in Portuguese.
411
+ */
412
+ export const pluralizeRelationPropertyPtBr = (
413
+ term,
414
+ { pluralizeWord = pluralizeWordPtBr, connectors = PT_BR_CONNECTORS, specifiers = PT_BR_NOUN_SPECIFIERS } = {}
415
+ ) => {
416
+ if (hasConnectorWord(term, connectors) || isCompoundWithSpecifier(term, specifiers)) {
417
+ return applyToCompoundHead(term, { connectors, transformWord: pluralizeWord });
418
+ }
419
+ return applyToCompoundWords(term, { connectors, transformWord: pluralizeWord });
420
+ }
421
+
422
+ /**
423
+ * Singularizes a compound property/relation name in Portuguese.
424
+ */
425
+ export const singularizeRelationPropertyPtBr = (
426
+ term,
427
+ { singularizeWord = singularizeWordPtBr, connectors = PT_BR_CONNECTORS, specifiers = PT_BR_NOUN_SPECIFIERS } = {}
428
+ ) => {
429
+ if (hasConnectorWord(term, connectors) || isCompoundWithSpecifier(term, specifiers)) {
430
+ return applyToCompoundHead(term, { connectors, transformWord: singularizeWord });
431
+ }
432
+ return applyToCompoundWords(term, { connectors, transformWord: singularizeWord });
433
+ }
434
+
435
+ // ═══════════════════════════════════════════════════════════════════════════
436
+ // INFLECTOR FACTORY
437
+ // ═══════════════════════════════════════════════════════════════════════════
438
+
439
+ /**
440
+ * Creates a Brazilian Portuguese inflector instance.
441
+ */
442
+ export const createPtBrInflector = ({ customIrregulars = {} } = {}) => {
443
+ const irregularPlurals = Object.freeze({
444
+ ...PT_BR_DEFAULT_IRREGULARS,
445
+ ...customIrregulars
446
+ });
447
+
448
+ const irregularSingulars = Object.freeze({
449
+ ...PT_BR_DEFAULT_SINGULAR_IRREGULARS,
450
+ ...buildSingularIrregulars(customIrregulars)
451
+ });
452
+
453
+ const pluralizeWord = (w) => pluralizeWordPtBr(w, irregularPlurals);
454
+ const singularizeWord = (w) => singularizeWordPtBr(w, irregularSingulars);
455
+
456
+ return Object.freeze({
457
+ locale: 'pt-BR',
458
+ irregularPlurals,
459
+ irregularSingulars,
460
+ pluralizeWord,
461
+ singularizeWord,
462
+ pluralizeRelationProperty: (term) => pluralizeRelationPropertyPtBr(term, { pluralizeWord }),
463
+ singularizeRelationProperty: (term) => singularizeRelationPropertyPtBr(term, { singularizeWord }),
464
+ normalizeForLookup: normalizeWord
465
+ });
466
+ };
467
+
468
+ export default createPtBrInflector;