ilib-lint 2.18.2 → 2.19.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ilib-lint",
3
- "version": "2.18.2",
3
+ "version": "2.19.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.js",
6
6
  "module": "./src/index.js",
@@ -62,30 +62,34 @@
62
62
  "jest": "^29.7.0",
63
63
  "jsdoc": "^4.0.3",
64
64
  "jsdoc-to-markdown": "^8.0.3",
65
- "typescript": "^5.5.4",
66
- "@ilib-mono/e2e-test": "^0.0.0"
65
+ "typescript": "^5.9.2",
66
+ "ilib-internal": "^0.0.0"
67
67
  },
68
68
  "dependencies": {
69
69
  "@formatjs/intl": "^2.10.4",
70
70
  "ilib-localeinfo": "^1.1.0",
71
+ "ilib-localematcher": "^1.2.2",
71
72
  "intl-messageformat": "^10.5",
72
73
  "json5": "^2.2.3",
73
74
  "log4js": "^6.9.1",
74
75
  "micromatch": "^4.0.7",
75
76
  "options-parser": "^0.4.0",
76
77
  "xml-js": "^1.6.11",
78
+ "ilib-casemapper": "^1.0.2",
77
79
  "ilib-common": "^1.1.6",
78
80
  "ilib-ctype": "^1.3.0",
79
81
  "ilib-lint-common": "^3.6.0",
80
82
  "ilib-locale": "^1.2.4",
83
+ "ilib-scriptinfo": "^1.0.0",
81
84
  "ilib-tools-common": "^1.19.0"
82
85
  },
83
86
  "scripts": {
84
- "coverage": "pnpm test -- --coverage",
85
- "test": "pnpm test:jest",
86
- "test:jest": "LANG=en_US.UTF8 node --trace-warnings --experimental-vm-modules node_modules/jest/bin/jest.js",
87
+ "coverage": "LANG=en_US.UTF8 node --trace-warnings --experimental-vm-modules node_modules/jest/bin/jest.js --coverage",
88
+ "test": "pnpm test:cli",
89
+ "test:cli": "LANG=en_US.UTF8 node --trace-warnings --experimental-vm-modules node_modules/jest/bin/jest.js",
87
90
  "test:watch": "pnpm test:jest --watch",
88
91
  "test:e2e": "LANG=en_US.UTF8 node --trace-warnings --experimental-vm-modules node_modules/jest/bin/jest.js --config test-e2e/jest.config.cjs",
92
+ "test:all": "pnpm test:cli test:e2e",
89
93
  "debug": "LANG=en_US.UTF8 node --experimental-vm-modules --inspect-brk node_modules/jest/bin/jest.js -i",
90
94
  "lint": "node src/index.js",
91
95
  "clean": "git clean -f -d src test",
package/src/FileType.js CHANGED
@@ -51,13 +51,6 @@ class FileType {
51
51
  */
52
52
  name;
53
53
 
54
- /**
55
- * The list of locales to use with this file type
56
- * @type {Array.<String>|undefined}
57
- * @readonly
58
- */
59
- locales;
60
-
61
54
  /**
62
55
  * The intermediate representation type of this file type.
63
56
  * @type {String}
@@ -114,7 +107,6 @@ class FileType {
114
107
  * of this file type as documented above
115
108
  * @param {String} options.name the name or glob spec for this file type
116
109
  * @param {Project} options.project the Project that this file type is a part of
117
- * @param {Array.<String>} [options.locales] list of locales to use with this file type
118
110
  * @param {String} [options.template] the path name template for this file type
119
111
  * which shows how to extract the locale from the path
120
112
  * name if the path includes it. Many file types
@@ -152,7 +144,6 @@ class FileType {
152
144
 
153
145
  this.name = options.name;
154
146
  this.project = options.project;
155
- this.locales = options.locales;
156
147
  this.template = options.template;
157
148
 
158
149
  const parserNames = options.parsers;
@@ -243,10 +234,6 @@ class FileType {
243
234
  return this.project;
244
235
  }
245
236
 
246
- getLocales() {
247
- return this.locales || this.project.getLocales();
248
- }
249
-
250
237
  getTemplate() {
251
238
  return this.template;
252
239
  }
package/src/Project.js CHANGED
@@ -77,6 +77,42 @@ function isOwnMethod(instance, methodName, parentClass) {
77
77
  return typeof instance[methodName] === "function" && instance[methodName] !== parentClass.prototype[methodName];
78
78
  }
79
79
 
80
+ /**
81
+ * Default locales for the linter if none are specified on the command line or in the config file. These are the top
82
+ * 27 locales on the internet by volume as of 2015. (Maybe we should update this list?)
83
+ * @type {readonly string[]}
84
+ */
85
+ const defaultLocales = [
86
+ "en-AU",
87
+ "en-CA",
88
+ "en-GB",
89
+ "en-IN",
90
+ "en-NG",
91
+ "en-PH",
92
+ "en-PK",
93
+ "en-US",
94
+ "en-ZA",
95
+ "de-DE",
96
+ "fr-CA",
97
+ "fr-FR",
98
+ "es-AR",
99
+ "es-ES",
100
+ "es-MX",
101
+ "id-ID",
102
+ "it-IT",
103
+ "ja-JP",
104
+ "ko-KR",
105
+ "pt-BR",
106
+ "ru-RU",
107
+ "tr-TR",
108
+ "vi-VN",
109
+ "zxx-XX",
110
+ "zh-Hans-CN",
111
+ "zh-Hant-HK",
112
+ "zh-Hant-TW",
113
+ "zh-Hans-SG"
114
+ ];
115
+
80
116
  /**
81
117
  * @class Represent an ilin-lint project.
82
118
  *
@@ -124,6 +160,12 @@ class Project extends DirItem {
124
160
  }
125
161
 
126
162
  this.sourceLocale = config?.sourceLocale || options?.opt?.sourceLocale;
163
+ /**
164
+ * @readonly
165
+ * @type {string[]}
166
+ */
167
+ this.locales = this.options?.opt?.locales || this.config.locales || [...defaultLocales];
168
+
127
169
  this.config.autofix = options?.opt?.fix === true || config?.autofix === true;
128
170
 
129
171
  this.pluginMgr = this.options.pluginManager;
@@ -401,14 +443,6 @@ class Project extends DirItem {
401
443
  return this.sourceLocale || "en-US";
402
444
  }
403
445
 
404
- /**
405
- * Return the list of global locales for this project.
406
- * @returns {Array.<String>} the list of global locales for this project
407
- */
408
- getLocales() {
409
- return this.options.locales || this.config.locales;
410
- }
411
-
412
446
  /**
413
447
  * Return the plugin manager for this project.
414
448
  * @returns {PluginManager} the plugin manager for this project
@@ -627,7 +661,7 @@ class Project extends DirItem {
627
661
  run() {
628
662
  let startTime = new Date();
629
663
 
630
- const results = this.findIssues(this.options.opt.locales);
664
+ const results = this.findIssues(this.locales);
631
665
  this.applyTransformers(results);
632
666
  this.serialize();
633
667
  let endTime = new Date();
package/src/index.js CHANGED
@@ -79,7 +79,6 @@ const optionConfig = {
79
79
  locales: {
80
80
  short: "l",
81
81
  varName: "LOCALES",
82
- "default": "en-AU,en-CA,en-GB,en-IN,en-NG,en-PH,en-PK,en-US,en-ZA,de-DE,fr-CA,fr-FR,es-AR,es-ES,es-MX,id-ID,it-IT,ja-JP,ko-KR,pt-BR,ru-RU,tr-TR,vi-VN,zxx-XX,zh-Hans-CN,zh-Hant-HK,zh-Hant-TW,zh-Hans-SG",
83
82
  help: "Locales you want your app to support. Value is a comma-separated list of BCP-47 style locale tags. Default: the top 20 locales on the internet by traffic."
84
83
  },
85
84
  sourceLocale: {
@@ -188,15 +187,15 @@ if (paths.length === 0) {
188
187
 
189
188
  if (options.opt.locales) {
190
189
  options.opt.locales = options.opt.locales.split(/,/g);
190
+ // normalize the locale specs
191
+ options.opt.locales = options.opt.locales.map(spec => {
192
+ let loc = new Locale(spec);
193
+ if (!loc.getLanguage()) {
194
+ loc = new Locale("und", loc.getRegion(), loc.getVariant(), loc.getScript());
195
+ }
196
+ return loc.getSpec();
197
+ });
191
198
  }
192
- // normalize the locale specs
193
- options.opt.locales = options.opt.locales.map(spec => {
194
- let loc = new Locale(spec);
195
- if (!loc.getLanguage()) {
196
- loc = new Locale("und", loc.getRegion(), loc.getVariant(), loc.getScript());
197
- }
198
- return loc.getSpec();
199
- });
200
199
 
201
200
  if (options.opt.fix || options.opt.overwrite) {
202
201
  // The write option indicates that modified files should be written back to disk.
@@ -46,6 +46,7 @@ import ResourceXML from '../rules/ResourceXML.js';
46
46
  import ResourceCamelCase from '../rules/ResourceCamelCase.js';
47
47
  import ResourceSnakeCase from '../rules/ResourceSnakeCase.js';
48
48
  import ResourceKebabCase from '../rules/ResourceKebabCase.js';
49
+ import ResourceAllCaps from '../rules/ResourceAllCaps.js';
49
50
  import ResourceGNUPrintfMatch from '../rules/ResourceGNUPrintfMatch.js';
50
51
  import ResourceReturnChar from '../rules/ResourceReturnChar.js';
51
52
  import StringFixer from './string/StringFixer.js';
@@ -542,6 +543,7 @@ class BuiltinPlugin extends Plugin {
542
543
  ResourceCamelCase,
543
544
  ResourceSnakeCase,
544
545
  ResourceKebabCase,
546
+ ResourceAllCaps,
545
547
  ResourceGNUPrintfMatch,
546
548
  ResourceReturnChar,
547
549
  FileEncodingRule,
@@ -0,0 +1,215 @@
1
+ /*
2
+ * ResourceAllCaps.js - rule for checking that ALL CAPS source strings have ALL CAPS targets
3
+ *
4
+ * Copyright © 2025 JEDLSoft
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ *
16
+ * See the License for the specific language governing permissions and
17
+ * limitations under the License.
18
+ */
19
+ import ResourceRule from './ResourceRule.js';
20
+ import ResourceFixer from '../plugins/resource/ResourceFixer.js';
21
+
22
+ import {Result} from 'ilib-lint-common';
23
+ import Locale from 'ilib-locale';
24
+ import {isAlpha, isUpper} from 'ilib-ctype';
25
+ import CaseMapper from 'ilib-casemapper';
26
+ import { scriptInfoFactory } from 'ilib-scriptinfo';
27
+ import LocaleMatcher from 'ilib-localematcher';
28
+
29
+ // type imports
30
+ /** @ignore @typedef {import('ilib-tools-common').Resource} Resource */
31
+ /** @ignore @typedef {import('../plugins/resource/ResourceFix.js').default} ResourceFix */
32
+
33
+ /**
34
+ * @classdesc Class representing an ilib-lint programmatic rule for linting ALL CAPS strings.
35
+ * @class
36
+ */
37
+ class ResourceAllCaps extends ResourceRule {
38
+ /**
39
+ * Create a ResourceAllCaps rule instance.
40
+ * @param {object} options
41
+ * @param {string[]} [options.exceptions] An array of strings to exclude from the rule.
42
+ */
43
+ constructor(options) {
44
+ super(options);
45
+
46
+ this.name = "resource-all-caps";
47
+ this.description = "Ensure that when source strings are in ALL CAPS, then the targets are also in ALL CAPS";
48
+ this.link = "https://github.com/iLib-js/ilib-mono/blob/main/packages/ilib-lint/docs/resource-all-caps.md";
49
+ this.exceptions = Array.isArray(options?.exceptions) ? options.exceptions : [];
50
+ }
51
+
52
+ /**
53
+ * Check if a source string is in ALL CAPS and if the target string matches the casing style.
54
+ * @override
55
+ * @param {Object} params parameters for the string matching
56
+ * @param {string} params.source the source string to match against
57
+ * @param {string} params.target the target string to match against
58
+ * @param {string} params.file the file path where the resources came from
59
+ * @param {Resource} params.resource the resource that contains the source and/or target string
60
+ * @param {number} [params.index] the index of the resource
61
+ * @param {string} [params.category] the category of the resource
62
+ * @returns {Result|undefined} A Result with severity 'error' if the source string is in ALL CAPS and target string is not in ALL CAPS, otherwise undefined.
63
+ */
64
+ matchString({source, target, file, resource, index, category}) {
65
+ if (!source || !target) {
66
+ return;
67
+ }
68
+
69
+ const isException = this.exceptions.includes(source);
70
+ if (isException) {
71
+ return;
72
+ }
73
+
74
+ const isAllCaps = ResourceAllCaps.isAllCaps(source);
75
+ if (!isAllCaps) {
76
+ return;
77
+ }
78
+
79
+ // Check if the target locale supports capital letters
80
+ if (!ResourceAllCaps.hasCapitalLetters(resource.targetLocale || 'en-US')) {
81
+ return;
82
+ }
83
+
84
+ // Check if target matches the ALL CAPS style
85
+ if (ResourceAllCaps.isAllCaps(target)) {
86
+ return;
87
+ }
88
+
89
+ const result = new Result({
90
+ severity: "error",
91
+ id: resource.getKey(),
92
+ source,
93
+ description: "The source string is in ALL CAPS, but the target string is not.",
94
+ rule: this,
95
+ locale: resource.targetLocale,
96
+ pathName: file,
97
+ fix: this.createFix(resource, target, file, index, category),
98
+ highlight: `<e0>${target}</e0>`
99
+ });
100
+ return result;
101
+ }
102
+
103
+ /**
104
+ * Get the fix for this rule - converts target to ALL CAPS while preserving the translation
105
+ * @param {Resource} resource the resource to fix
106
+ * @param {string} target the current target string
107
+ * @param {string} file the file path
108
+ * @param {number} [index] the index for array resources
109
+ * @param {string} [category] the category for plural resources
110
+ * @returns {ResourceFix | undefined} the fix for this rule
111
+ */
112
+ createFix(resource, target, file, index, category) {
113
+ const locale = resource.targetLocale || 'en-US';
114
+ const casemapper = new CaseMapper({
115
+ locale,
116
+ direction: "toupper"
117
+ });
118
+ const upperCaseTarget = casemapper.map(target);
119
+ if (!upperCaseTarget) {
120
+ // if we could not upper-case the target, then we cannot fix it, so don't return a fix
121
+ return undefined;
122
+ }
123
+
124
+ const command = ResourceFixer.createStringCommand(0, target.length, upperCaseTarget);
125
+ return ResourceFixer.createFix({
126
+ resource,
127
+ target: true,
128
+ category,
129
+ index,
130
+ commands: [command]
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Checks if a given string is in ALL CAPS style, i.e. at least 2 letter characters exist and all of them are uppercase.
136
+ *
137
+ * @public
138
+ * @param {string} string A non-empty string to check.
139
+ * @returns {boolean} Returns true for a string that is in ALL CAPS (all letter characters are uppercase and at least 2 letter characters exist).
140
+ * Otherwise, returns false.
141
+ */
142
+ static isAllCaps(string) {
143
+ if (!string || typeof string !== 'string') {
144
+ return false;
145
+ }
146
+
147
+ const trimmed = string.trim();
148
+ if (trimmed.length < 2) {
149
+ return false;
150
+ }
151
+
152
+ let letterCount = 0;
153
+ let allLettersUpper = true;
154
+
155
+ for (let i = 0; i < trimmed.length; i++) {
156
+ const char = trimmed[i];
157
+ if (isAlpha(char)) {
158
+ letterCount++;
159
+ if (!isUpper(char)) {
160
+ allLettersUpper = false;
161
+ break;
162
+ }
163
+ }
164
+ }
165
+
166
+ return letterCount >= 2 && allLettersUpper;
167
+ }
168
+
169
+ /**
170
+ * Checks if a locale supports letter upper- and lower-casing.
171
+ * A language by itself cannot have capitalization; Instead, it's a property of a script.
172
+ * Therefore, if no script is explicitly specified in the locale, this method will figure out
173
+ * what the script is. It will use LocaleMatcher to guess the most likely full
174
+ * locale, which always includes the script tag. This may not be the script that the caller intended
175
+ * to use with the locale, but it will be a good guess because most locales only use one script.
176
+ * Very few locales use multiple scripts, though they do exist. (Kurdish and Serbian for example
177
+ * are commonly written in multiple scripts.) Once it has the name of the script, it will check whether
178
+ * that script supports letter casing.
179
+ * @public
180
+ * @param {string} locale The locale to check for capital letter support.
181
+ * @returns {boolean} Returns true if the locale's script supports capital letters, false otherwise.
182
+ */
183
+ static hasCapitalLetters(locale) {
184
+ if (!locale) {
185
+ return true; // Default to true for unknown locales
186
+ }
187
+
188
+ try {
189
+ const localeObj = new Locale(locale);
190
+ let script = localeObj.getScript();
191
+
192
+ // If no script is specified, use LocaleMatcher to get the likely locale
193
+ if (!script) {
194
+ const localeMatcher = new LocaleMatcher({ locale: locale });
195
+ const likelyLocale = localeMatcher.getLikelyLocale();
196
+ if (likelyLocale) {
197
+ const likelyLocaleObj = new Locale(likelyLocale);
198
+ script = likelyLocaleObj.getScript();
199
+ }
200
+ }
201
+
202
+ if (script) {
203
+ const scriptInfo = scriptInfoFactory(script);
204
+ return scriptInfo?.casing ?? true;
205
+ }
206
+
207
+ return true; // Default to true for unknown scripts
208
+ } catch (error) {
209
+ // If there's any error parsing the locale or script, default to true
210
+ return true;
211
+ }
212
+ }
213
+ }
214
+
215
+ export default ResourceAllCaps;
@@ -17,12 +17,18 @@
17
17
  * limitations under the License.
18
18
  */
19
19
 
20
- /**
20
+ /*
21
21
  * ResourceSentenceEnding - Checks that sentence-ending punctuation is appropriate for the target locale
22
22
  *
23
23
  * This rule checks if the source string ends with certain punctuation marks and ensures
24
24
  * the target uses the locale-appropriate equivalent.
25
25
  *
26
+ * Features:
27
+ * - Configurable minimum length threshold to skip short strings (abbreviations)
28
+ * - Automatic skipping of strings with no spaces (non-sentences)
29
+ * - Custom punctuation mappings per locale
30
+ * - Exception lists to skip specific source strings
31
+ *
26
32
  * Examples:
27
33
  * - English period (.) should become Japanese maru (。) in Japanese
28
34
  * - English question mark (?) should become Japanese question mark (?) in Japanese
@@ -34,14 +40,23 @@
34
40
  import { Result } from 'ilib-lint-common';
35
41
  import ResourceRule from './ResourceRule.js';
36
42
  import Locale from 'ilib-locale';
37
- import LocaleInfo from 'ilib-localeinfo';
38
43
  import ResourceFixer from '../plugins/resource/ResourceFixer.js';
39
- import { isPunct, isSpace } from 'ilib-ctype';
44
+ import { isSpace } from 'ilib-ctype';
40
45
 
41
- /** @ignore @typedef {import("ilib-tools-common").Resource} Resource */
42
- /** @ignore @typedef {import("ilib-lint-common").Fix} Fix */
46
+ /**
47
+ * @ignore
48
+ * @typedef {import("ilib-tools-common").Resource} Resource
49
+ */
50
+ /**
51
+ * @ignore
52
+ * @typedef {import("ilib-lint-common").Fix} Fix
53
+ */
54
+ /**
55
+ * @ignore
56
+ * @typedef {import("../plugins/resource/ResourceFix.js").default} ResourceFix
57
+ */
43
58
 
44
- /** @ignore
59
+ /*
45
60
  * Default punctuation for each punctuation type
46
61
  */
47
62
  const defaults = {
@@ -52,7 +67,7 @@ const defaults = {
52
67
  'colon': ':'
53
68
  };
54
69
 
55
- /** @ignore
70
+ /*
56
71
  * Punctuation map for each language, with default punctuation for each punctuation type
57
72
  */
58
73
  const punctuationMap = {
@@ -72,42 +87,133 @@ const punctuationMap = {
72
87
  'bn': { 'period': '।', 'question': '?', 'exclamation': '!', 'ellipsis': '…', 'colon': ':' }
73
88
  };
74
89
 
90
+ /**
91
+ * @ignore
92
+ * @typedef {{period?: string, question?: string, exclamation?: string, ellipsis?: string, colon?: string, exceptions?: string[]}} LocaleOptions
93
+ * @property {string} [period] - Custom period punctuation for this locale
94
+ * @property {string} [question] - Custom question mark punctuation for this locale
95
+ * @property {string} [exclamation] - Custom exclamation mark punctuation for this locale
96
+ * @property {string} [ellipsis] - Custom ellipsis punctuation for this locale
97
+ * @property {string} [colon] - Custom colon punctuation for this locale
98
+ * @property {string[]} [exceptions] - Array of source strings to skip checking for this locale.
99
+ * Useful for handling special cases like abbreviations that should not be checked for sentence-ending punctuation.
100
+ */
101
+
102
+ /**
103
+ * @ignore
104
+ * @typedef {{minimumLength?: number}} ResourceSentenceEndingFixedOptions
105
+ * @property {number} [minimumLength=10] - Minimum length of source string before the rule is applied.
106
+ * Strings shorter than this length will be skipped (useful for avoiding false positives on abbreviations).
107
+ */
108
+
109
+ /**
110
+ * @ignore
111
+ * @typedef {ResourceSentenceEndingFixedOptions | Record<string, LocaleOptions>} ResourceSentenceEndingOptions
112
+ */
113
+
75
114
  /**
76
115
  * @class ResourceSentenceEnding
77
116
  * @extends ResourceRule
78
117
  */
79
118
  class ResourceSentenceEnding extends ResourceRule {
80
- constructor(options) {
119
+ /**
120
+ * Constructs a new ResourceSentenceEnding rule instance.
121
+ *
122
+ * @param {ResourceSentenceEndingOptions} [options] - Configuration options for the rule
123
+ *
124
+ * @example
125
+ * // Basic usage with default settings
126
+ * const rule = new ResourceSentenceEnding();
127
+ *
128
+ * @example
129
+ * // Custom minimum length
130
+ * const rule = new ResourceSentenceEnding({
131
+ * minimumLength: 15
132
+ * });
133
+ *
134
+ * @example
135
+ * // Custom punctuation mappings for Japanese
136
+ * const rule = new ResourceSentenceEnding({
137
+ * 'ja-JP': {
138
+ * period: '。',
139
+ * question: '?',
140
+ * exclamation: '!',
141
+ * ellipsis: '…',
142
+ * colon: ':'
143
+ * }
144
+ * });
145
+ *
146
+ * @example
147
+ * // Exception list for German
148
+ * const rule = new ResourceSentenceEnding({
149
+ * 'de-DE': {
150
+ * exceptions: [
151
+ * 'See the Dr.',
152
+ * 'Visit the Prof.',
153
+ * 'Check with Mr.'
154
+ * ]
155
+ * }
156
+ * });
157
+ *
158
+ * @example
159
+ * // Combined configuration
160
+ * const rule = new ResourceSentenceEnding({
161
+ * minimumLength: 8,
162
+ * 'ja-JP': {
163
+ * period: '。',
164
+ * exceptions: ['Loading...', 'Please wait...']
165
+ * },
166
+ * 'de-DE': {
167
+ * exceptions: ['See the Dr.', 'Visit the Prof.']
168
+ * }
169
+ * });
170
+ */
171
+ constructor(options = {}) {
81
172
  super(options);
82
173
  this.name = "resource-sentence-ending";
83
174
  this.description = "Checks that sentence-ending punctuation is appropriate for the locale of the target string and matches the punctuation in the source string";
84
175
  this.link = "https://github.com/iLib-js/ilib-lint/blob/main/docs/resource-sentence-ending.md";
85
176
 
177
+ // Initialize minimum length configuration
178
+ this.minimumLength = Math.max(0, options?.minimumLength ?? 10);
179
+
86
180
  // Initialize custom punctuation mappings from configuration
87
181
  this.customPunctuationMap = {};
182
+ // Initialize exception lists from configuration
183
+ this.exceptionsMap = {};
184
+
88
185
  if (options && typeof options === 'object' && !Array.isArray(options)) {
89
- // options is an object with locale codes as keys and punctuation mappings as values
90
- // Merge the default punctuation with the custom punctuation so that the custom
91
- // punctuation overrides the default and we don't have to specify all punctuation types.
92
- // Custom maps are stored by language, not locale, so that they apply to all locales of
93
- // that language.
94
- for (const locale in options) {
95
- const localeObj = new Locale(locale);
96
-
97
- // only process config for valid locales
98
- if (localeObj.isValid()) {
99
- const language = localeObj.getLanguage();
100
- // locale must have a language code
101
- if (!language) continue;
102
- // Apply locale-specific defaults for any locale that usesthis language
103
- const localeDefaults = this.getLocaleDefaults(language);
104
- this.customPunctuationMap[language] = {
105
- ...localeDefaults,
106
- ...options[locale]
107
- };
186
+ // options is an object with locale codes as keys and punctuation mappings as values
187
+ // Merge the default punctuation with the custom punctuation so that the custom
188
+ // punctuation overrides the default and we don't have to specify all punctuation types.
189
+ // Custom maps are stored by language, not locale, so that they apply to all locales of
190
+ // that language.
191
+ for (const locale in options) {
192
+ const localeObj = new Locale(locale);
193
+
194
+ // only process config for valid locales
195
+ if (localeObj.isValid()) {
196
+ const language = localeObj.getLanguage();
197
+ // locale must have a language code
198
+ if (!language) continue;
199
+
200
+ // Separate punctuation mappings from exceptions
201
+ const { exceptions, ...punctuationMappings } = options[locale];
202
+
203
+ // Apply locale-specific defaults for any locale that usesthis language
204
+ const localeDefaults = this.getLocaleDefaults(language);
205
+ this.customPunctuationMap[language] = {
206
+ ...localeDefaults,
207
+ ...punctuationMappings
208
+ };
209
+
210
+ // Store exceptions separately
211
+ if (exceptions && Array.isArray(exceptions)) {
212
+ this.exceptionsMap[language] = exceptions;
213
+ }
214
+ }
108
215
  }
109
216
  }
110
- }
111
217
 
112
218
  // Build the set of sentence-ending punctuation characters dynamically
113
219
  this.sentenceEndingPunctuationSet = this.buildSentenceEndingPunctuationSet();
@@ -251,11 +357,12 @@ class ResourceSentenceEnding extends ResourceRule {
251
357
  }
252
358
 
253
359
  /**
254
- * Get a regex that matches all expected punctuation for a given locale
360
+ * Get a regex that matches all expected punctuation for a given locale, excluding colons
361
+ * This is used by getLastSentenceFromContent to avoid splitting on colons in the middle of sentences
255
362
  * @param {Locale} localeObj locale of the punctuation
256
- * @returns {string} regex string that matches all expected punctuation for the locale
363
+ * @returns {string} regex string that matches all expected punctuation for the locale except colons
257
364
  */
258
- getExpectedPunctuationRegex(localeObj) {
365
+ getExpectedPunctuationRegexWithoutColons(localeObj) {
259
366
  const language = localeObj.getLanguage();
260
367
  let config;
261
368
  if (language) {
@@ -266,7 +373,10 @@ class ResourceSentenceEnding extends ResourceRule {
266
373
  } else {
267
374
  config = defaults;
268
375
  }
269
- return Object.values(config).join('').replace(/\./g, '\\.').replace(/\?/g, '\\?');
376
+ // Exclude colons from the punctuation regex
377
+ const punctuationWithoutColons = { ...config };
378
+ delete punctuationWithoutColons.colon;
379
+ return Object.values(punctuationWithoutColons).join('').replace(/\./g, '\\.').replace(/\?/g, '\\?');
270
380
  }
271
381
 
272
382
  /**
@@ -381,11 +491,13 @@ class ResourceSentenceEnding extends ResourceRule {
381
491
  getLastSentenceFromContent(content, targetLocaleObj) {
382
492
  if (!content) return content;
383
493
  // Only treat .!?。?! as sentence-ending punctuation, not ¿ or ¡
384
- const allSentenceEnding = this.getExpectedPunctuationRegex(targetLocaleObj);
494
+ // Exclude colons from sentence-ending punctuation for this function because
495
+ // colons in the middle of a sentence should not split the sentence
496
+ const sentenceEndingWithoutColons = this.getExpectedPunctuationRegexWithoutColons(targetLocaleObj);
385
497
  // Fix: Use a regex that finds the last sentence, properly handling trailing whitespace
386
498
  // First, trim trailing whitespace to avoid matching spaces instead of sentences
387
499
  const trimmedContent = content.trim();
388
- const sentenceEndingRegex = new RegExp(`[^${allSentenceEnding}]+\\p{P}?\\w*$`, 'gu');
500
+ const sentenceEndingRegex = new RegExp(`[^${sentenceEndingWithoutColons}]+\\p{P}?\\w*$`, 'gu');
389
501
  const match = sentenceEndingRegex.exec(trimmedContent);
390
502
  if (match !== null && match.length > 0) {
391
503
  let lastSentence = match[0].trim();
@@ -434,26 +546,53 @@ class ResourceSentenceEnding extends ResourceRule {
434
546
  }
435
547
 
436
548
  /**
437
- * Check if Spanish target has the correct inverted punctuation at the beginning of the last sentence
549
+ * Check if Spanish target has the correct inverted punctuation in the last sentence
438
550
  * @param {string} lastSentence - The last sentence of the target string (already stripped of quotes)
439
551
  * @param {string} sourceEndingType - The type of ending punctuation in source
440
- * @returns {boolean} - True if Spanish target has correct inverted punctuation at start of last sentence
552
+ * @returns {{correct: boolean, position: number}} - position is where inverted punctuation should be
441
553
  */
442
554
  hasCorrectSpanishInvertedPunctuation(lastSentence, sourceEndingType) {
443
- if (!lastSentence || typeof lastSentence !== 'string') return false;
555
+ if (!lastSentence || typeof lastSentence !== 'string') return { correct: false, position: 0 };
444
556
  // Only check for questions and exclamations
445
557
  if (sourceEndingType !== 'question' && sourceEndingType !== 'exclamation') {
446
- return true; // Not applicable for other punctuation types
558
+ return { correct: true, position: 0 }; // Not applicable for other punctuation types
447
559
  }
448
560
  // Strip any leading quote characters before checking for inverted punctuation
449
561
  const quoteChars = ResourceSentenceEnding.allQuoteChars;
450
562
  let strippedSentence = lastSentence;
563
+ let strippedOffset = 0;
451
564
  while (strippedSentence.length > 0 && (quoteChars.includes(strippedSentence.charAt(0)) || isSpace(strippedSentence.charAt(0)))) {
452
565
  strippedSentence = strippedSentence.slice(1);
566
+ strippedOffset++;
453
567
  }
454
- // Check for inverted punctuation at the beginning of the stripped last sentence
568
+
455
569
  const expectedInverted = sourceEndingType === 'question' ? '¿' : '¡';
456
- return strippedSentence.startsWith(expectedInverted);
570
+
571
+ // Search backwards from the end of the sentence
572
+ // If we find the correct inverted punctuation first, it's correct
573
+ // If we find sentence-ending punctuation first, it's incorrect
574
+ for (let i = strippedSentence.length - 1; i >= 0; i--) {
575
+ const char = strippedSentence.charAt(i);
576
+
577
+ // If we find the correct inverted punctuation, it's correct
578
+ if (char === expectedInverted) {
579
+ return { correct: true, position: strippedOffset };
580
+ }
581
+
582
+ // If we find sentence-ending punctuation (excluding the final one),
583
+ // we've reached the start of this sentence without finding inverted punctuation
584
+ if (char === '.' || char === '!' || char === '?' || char === '。' || char === '!' || char === '?') {
585
+ // Skip the final punctuation at the end
586
+ if (i === strippedSentence.length - 1) {
587
+ continue;
588
+ }
589
+ // Found sentence-ending punctuation before inverted punctuation
590
+ return { correct: false, position: strippedOffset };
591
+ }
592
+ }
593
+
594
+ // If we reach the start without finding either, it's incorrect
595
+ return { correct: false, position: strippedOffset };
457
596
  }
458
597
 
459
598
  /**
@@ -490,7 +629,7 @@ class ResourceSentenceEnding extends ResourceRule {
490
629
  * @param {string} target - The target string
491
630
  * @param {string} incorrectPunctuation - The incorrect punctuation
492
631
  * @param {string} correctPunctuation - The correct punctuation
493
- * @returns {Fix|undefined} - The fix object or undefined if no fix can be created
632
+ * @returns {ResourceFix|undefined} - The fix object or undefined if no fix can be created
494
633
  */
495
634
  createPunctuationFix(resource, target, incorrectPunctuation, correctPunctuation, index, category, targetLocaleObj) {
496
635
  // Get the last sentence to find the position
@@ -547,7 +686,7 @@ class ResourceSentenceEnding extends ResourceRule {
547
686
  * @param {string} character - The character to insert
548
687
  * @param {number} [index] - Index for array/plural resources
549
688
  * @param {string} [category] - Category for plural resources
550
- * @returns {Fix|undefined} - The fix object or undefined if no fix can be created
689
+ * @returns {ResourceFix|undefined} - The fix object or undefined if no fix can be created
551
690
  */
552
691
  createInsertCharacterFix(resource, target, position, character, index, category) {
553
692
  return ResourceFixer.createFix({
@@ -576,7 +715,7 @@ class ResourceSentenceEnding extends ResourceRule {
576
715
  * @param {number} [index] - Index for array/plural resources
577
716
  * @param {string} [category] - Category for plural resources
578
717
  * @param {Locale} [targetLocaleObj] - The target locale object (unused, kept for compatibility)
579
- * @returns {Fix|undefined} - The fix object or undefined if no fix can be created
718
+ * @returns {ResourceFix|undefined} - The fix object or undefined if no fix can be created
580
719
  */
581
720
  createFixForSpanishInvertedPunctuation(resource, target, lastSentence, correctPunctuation, index, category, targetLocaleObj) {
582
721
  const lastSentenceStart = target.lastIndexOf(lastSentence);
@@ -592,7 +731,7 @@ class ResourceSentenceEnding extends ResourceRule {
592
731
  * @param {string} nonBreakingSpace - The non-breaking space character to insert
593
732
  * @param {number} [index] - Index for array/plural resources
594
733
  * @param {string} [category] - Category for plural resources
595
- * @returns {Fix|undefined} - The fix object or undefined if no fix can be created
734
+ * @returns {ResourceFix|undefined} - The fix object or undefined if no fix can be created
596
735
  */
597
736
  createFixForFrenchNonBreakingSpace(resource, target, position, nonBreakingSpace, index, category) {
598
737
  return ResourceFixer.createFix({
@@ -618,7 +757,7 @@ class ResourceSentenceEnding extends ResourceRule {
618
757
  * @param {string} currentSpace - The current space character (or empty string if none)
619
758
  * @param {number} [index] - Index for array/plural resources
620
759
  * @param {string} [category] - Category for plural resources
621
- * @returns {Fix|undefined} - The fix object or undefined if no fix is needed
760
+ * @returns {ResourceFix|undefined} - The fix object or undefined if no fix is needed
622
761
  */
623
762
  createFrenchSpacingFix(resource, target, spacePosition, needsNonBreakingSpace, currentSpace, index, category) {
624
763
  const regularSpace = ' ';
@@ -733,6 +872,29 @@ class ResourceSentenceEnding extends ResourceRule {
733
872
  const sourceLanguage = sourceLocaleObj.getLanguage();
734
873
  if (!sourceLanguage) return undefined;
735
874
 
875
+ // Exception 1: Check minimum length
876
+ if (source.length < this.minimumLength) {
877
+ return undefined;
878
+ }
879
+
880
+ // Exception 2: Check if source has no spaces AND doesn't end with sentence-ending punctuation (not a sentence)
881
+ if (!source.includes(' ')) {
882
+ const trimmed = source.trim();
883
+ const lastChar = trimmed.charAt(trimmed.length - 1);
884
+ const sentenceEndingChars = ['.', '?', '!', '。', '?', '!', '…', ':'];
885
+ if (!sentenceEndingChars.includes(lastChar)) {
886
+ return undefined; // Not a sentence
887
+ }
888
+ }
889
+
890
+ // Exception 3: Check if source is in exception list
891
+ const exceptions = this.exceptionsMap[targetLanguage];
892
+ if (exceptions) {
893
+ if (exceptions.some(exception => exception.toLowerCase().trim() === source.toLowerCase().trim())) {
894
+ return undefined;
895
+ }
896
+ }
897
+
736
898
  const optionalPunctuationLanguages = ['th', 'lo', 'my', 'km', 'vi', 'id', 'ms', 'tl', 'jv', 'su'];
737
899
  const isOptionalPunctuationLanguage = optionalPunctuationLanguages.includes(targetLanguage);
738
900
 
@@ -836,7 +998,19 @@ class ResourceSentenceEnding extends ResourceRule {
836
998
 
837
999
  // For Spanish, check for inverted punctuation at the beginning
838
1000
  if (targetLanguage === 'es' && (sourceEnding.type === 'question' || sourceEnding.type === 'exclamation')) {
839
- if (!this.hasCorrectSpanishInvertedPunctuation(lastSentence, sourceEnding.type)) {
1001
+ // For Spanish inverted punctuation, we need to check the appropriate part of the target:
1002
+ // - If source ends with quote, check the quoted content (lastSentence already contains this)
1003
+ // - If source doesn't end with quote, check the full target string
1004
+ // - However, if lastSentence is the result of getLastSentenceFromContent (which extracts
1005
+ // only the part after the last sentence-ending punctuation), we should check the full target
1006
+ // because inverted punctuation should be at the beginning of the entire sentence
1007
+ // For Spanish inverted punctuation, we need to check the appropriate part of the target:
1008
+ // - If source ends with quote, check the quoted content (lastSentence already contains this)
1009
+ // - If source doesn't end with quote, check the lastSentence (which contains the relevant part)
1010
+ // because inverted punctuation should be at the beginning of the sentence being checked
1011
+ const stringToCheck = lastSentence;
1012
+ const invertedPunctuationResult = this.hasCorrectSpanishInvertedPunctuation(stringToCheck, sourceEnding.type);
1013
+ if (!invertedPunctuationResult.correct) {
840
1014
  // Spanish target is missing inverted punctuation at the beginning
841
1015
  const quoteChars = ResourceSentenceEnding.allQuoteChars;
842
1016
  let quotedContentStart = -1;
@@ -852,7 +1026,17 @@ class ResourceSentenceEnding extends ResourceRule {
852
1026
  const afterQuote = target.substring(quotedContentStart);
853
1027
  highlight = `${beforeQuote}<e0/>${afterQuote}`;
854
1028
  } else {
855
- highlight = `<e0/>${target}`;
1029
+ // For multi-sentence strings, find where the last sentence starts
1030
+ const lastSentenceStart = target.lastIndexOf(lastSentence);
1031
+ if (lastSentenceStart !== -1) {
1032
+ // Use the position from hasCorrectSpanishInvertedPunctuation for more precise highlighting
1033
+ const highlightPosition = lastSentenceStart + invertedPunctuationResult.position;
1034
+ const beforeHighlight = target.substring(0, highlightPosition);
1035
+ const afterHighlight = target.substring(highlightPosition);
1036
+ highlight = `${beforeHighlight}<e0/>${afterHighlight}`;
1037
+ } else {
1038
+ highlight = `<e0/>${target}`;
1039
+ }
856
1040
  }
857
1041
 
858
1042
  // Add prefix for array/plural resources