ilib-lint 2.18.2 → 2.19.1

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.1",
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
- "ilib-locale": "^1.2.4",
81
- "ilib-tools-common": "^1.19.0"
82
+ "ilib-scriptinfo": "^1.0.0",
83
+ "ilib-tools-common": "^1.19.0",
84
+ "ilib-locale": "^1.2.4"
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
@@ -561,7 +595,17 @@ class Project extends DirItem {
561
595
  * @param {Array.<Result>} results the results of the linting process
562
596
  */
563
597
  applyTransformers(results) {
564
- this.get().forEach((file) => file.applyTransformers(results));
598
+ const files = this.get();
599
+ for (const file of files) {
600
+ if (!this.options.opt.quiet && this.options.opt.progressInfo) {
601
+ logger.info(`Applying transformers to file [${file.filePath}]`);
602
+ }
603
+ try {
604
+ file.applyTransformers(results);
605
+ } catch (e) {
606
+ logger.error(`Error applying transformers to file [${file.getFilePath()}]`, e);
607
+ }
608
+ }
565
609
  }
566
610
 
567
611
  /**
@@ -569,9 +613,19 @@ class Project extends DirItem {
569
613
  * file type of each file.
570
614
  */
571
615
  serialize() {
572
- if (this.options.opt.write) {
573
- const files = this.get();
574
- files.forEach((file) => {
616
+ if (!this.options.opt.write) {
617
+ logger.debug("Skipping serialization because write option is not set");
618
+ return;
619
+ }
620
+ const files = this.get();
621
+ for (const file of files) {
622
+ if (!this.options.opt.quiet && this.options.opt.progressInfo) {
623
+ logger.info(
624
+ `Serializing file [${file.filePath}]` +
625
+ (this.options.opt.overwrite ? " (overwriting)" : "(as .modified)")
626
+ );
627
+ }
628
+ try {
575
629
  const irs = file.getIRs();
576
630
  const fileType = file.getFileType();
577
631
  const serializer = fileType.getSerializer();
@@ -585,7 +639,9 @@ class Project extends DirItem {
585
639
  }
586
640
  sourceFile.write();
587
641
  }
588
- });
642
+ } catch (e) {
643
+ logger.error(`Error serializing file [${file.getFilePath()}]`, e);
644
+ }
589
645
  }
590
646
  }
591
647
 
@@ -627,7 +683,7 @@ class Project extends DirItem {
627
683
  run() {
628
684
  let startTime = new Date();
629
685
 
630
- const results = this.findIssues(this.options.opt.locales);
686
+ const results = this.findIssues(this.locales);
631
687
  this.applyTransformers(results);
632
688
  this.serialize();
633
689
  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, isAlpha, isAlnum } 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,106 @@ 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
+ * Format punctuation for error messages - replace empty strings with "no punctuation"
550
+ * @param {string} punctuation - The punctuation string to format
551
+ * @returns {string} - The formatted punctuation string
552
+ */
553
+ static formatPunctuationForMessage(punctuation) {
554
+ return punctuation === '' ? 'no punctuation' : `"${punctuation}"`;
555
+ }
556
+
557
+ /**
558
+ * Check if Spanish target has the correct inverted punctuation in the last sentence
438
559
  * @param {string} lastSentence - The last sentence of the target string (already stripped of quotes)
439
560
  * @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
561
+ * @param {Locale} targetLocaleObj - The target locale object for custom punctuation configuration
562
+ * @returns {{correct: boolean, position: number}} - position is where inverted punctuation should be
441
563
  */
442
- hasCorrectSpanishInvertedPunctuation(lastSentence, sourceEndingType) {
443
- if (!lastSentence || typeof lastSentence !== 'string') return false;
564
+ hasCorrectSpanishInvertedPunctuation(lastSentence, sourceEndingType, targetLocaleObj) {
565
+ if (!lastSentence || typeof lastSentence !== 'string') return { correct: false, position: 0 };
444
566
  // Only check for questions and exclamations
445
567
  if (sourceEndingType !== 'question' && sourceEndingType !== 'exclamation') {
446
- return true; // Not applicable for other punctuation types
568
+ return { correct: true, position: 0 }; // Not applicable for other punctuation types
447
569
  }
448
570
  // Strip any leading quote characters before checking for inverted punctuation
449
571
  const quoteChars = ResourceSentenceEnding.allQuoteChars;
450
572
  let strippedSentence = lastSentence;
573
+ let strippedOffset = 0;
451
574
  while (strippedSentence.length > 0 && (quoteChars.includes(strippedSentence.charAt(0)) || isSpace(strippedSentence.charAt(0)))) {
452
575
  strippedSentence = strippedSentence.slice(1);
576
+ strippedOffset++;
453
577
  }
454
- // Check for inverted punctuation at the beginning of the stripped last sentence
578
+
455
579
  const expectedInverted = sourceEndingType === 'question' ? '¿' : '¡';
456
- return strippedSentence.startsWith(expectedInverted);
580
+
581
+ // Search backwards from the end of the sentence
582
+ // If we find the correct inverted punctuation first, it's correct
583
+ // If we find sentence-ending punctuation first, it's incorrect
584
+ for (let i = strippedSentence.length - 1; i >= 0; i--) {
585
+ const char = strippedSentence.charAt(i);
586
+
587
+ // If we find the correct inverted punctuation, it's correct
588
+ if (char === expectedInverted) {
589
+ return { correct: true, position: strippedOffset };
590
+ }
591
+
592
+ // If we find sentence-ending punctuation (excluding the final one),
593
+ // we've reached the start of this sentence without finding inverted punctuation
594
+ // Use locale-specific punctuation configuration
595
+ const sentenceEndingChars = this.getExpectedPunctuationRegexWithoutColons(targetLocaleObj);
596
+ const sentenceEndingRegex = new RegExp(`[${sentenceEndingChars}]`, 'u');
597
+ if (sentenceEndingRegex.test(char)) {
598
+ // Skip the final punctuation at the end
599
+ if (i === strippedSentence.length - 1) {
600
+ continue;
601
+ }
602
+
603
+ // Special handling for dots: check if it's part of a letter-dot-letter pattern
604
+ // (like email addresses, URLs, abbreviations, etc.)
605
+ if (char === '.') {
606
+ // Check if this dot is part of a letter-dot-letter pattern
607
+ const beforeDot = i > 0 ? strippedSentence.charAt(i - 1) : '';
608
+ const afterDot = i < strippedSentence.length - 1 ? strippedSentence.charAt(i + 1) : '';
609
+
610
+ // If it's letter-dot-letter, it's not sentence-ending punctuation
611
+ // But exclude cases where the dot is part of an ellipsis (...)
612
+ if (isAlpha(beforeDot) && isAlpha(afterDot)) {
613
+ // Check if this is part of an ellipsis (three consecutive dots)
614
+ const beforeBeforeDot = i > 1 ? strippedSentence.charAt(i - 2) : '';
615
+ const afterAfterDot = i < strippedSentence.length - 2 ? strippedSentence.charAt(i + 2) : '';
616
+
617
+ // If it's part of an ellipsis (...), treat it as sentence-ending punctuation
618
+ if (beforeBeforeDot === '.' || afterAfterDot === '.') {
619
+ // This is part of an ellipsis, so treat as sentence-ending punctuation
620
+ } else {
621
+ // This is a letter-dot-letter pattern, skip it
622
+ continue;
623
+ }
624
+ }
625
+ }
626
+
627
+ // Special handling for question marks: check if it's part of a URL query parameter
628
+ // (like ?param=value in URLs)
629
+ if (char === '?') {
630
+ // Check if this question mark is part of a URL query parameter
631
+ const afterQuestion = i < strippedSentence.length - 1 ? strippedSentence.charAt(i + 1) : '';
632
+ const beforeQuestion = i > 0 ? strippedSentence.charAt(i - 1) : '';
633
+
634
+ // If it's followed by alphanumeric characters (like ?param=value),
635
+ // it's likely part of a URL query parameter, not sentence-ending punctuation
636
+ if (isAlnum(afterQuestion)) {
637
+ // This is likely a URL query parameter, skip it
638
+ continue;
639
+ }
640
+ }
641
+
642
+ // Found sentence-ending punctuation before inverted punctuation
643
+ return { correct: false, position: strippedOffset };
644
+ }
645
+ }
646
+
647
+ // If we reach the start without finding either, it's incorrect
648
+ return { correct: false, position: strippedOffset };
457
649
  }
458
650
 
459
651
  /**
@@ -490,7 +682,7 @@ class ResourceSentenceEnding extends ResourceRule {
490
682
  * @param {string} target - The target string
491
683
  * @param {string} incorrectPunctuation - The incorrect punctuation
492
684
  * @param {string} correctPunctuation - The correct punctuation
493
- * @returns {Fix|undefined} - The fix object or undefined if no fix can be created
685
+ * @returns {ResourceFix|undefined} - The fix object or undefined if no fix can be created
494
686
  */
495
687
  createPunctuationFix(resource, target, incorrectPunctuation, correctPunctuation, index, category, targetLocaleObj) {
496
688
  // Get the last sentence to find the position
@@ -547,7 +739,7 @@ class ResourceSentenceEnding extends ResourceRule {
547
739
  * @param {string} character - The character to insert
548
740
  * @param {number} [index] - Index for array/plural resources
549
741
  * @param {string} [category] - Category for plural resources
550
- * @returns {Fix|undefined} - The fix object or undefined if no fix can be created
742
+ * @returns {ResourceFix|undefined} - The fix object or undefined if no fix can be created
551
743
  */
552
744
  createInsertCharacterFix(resource, target, position, character, index, category) {
553
745
  return ResourceFixer.createFix({
@@ -576,7 +768,7 @@ class ResourceSentenceEnding extends ResourceRule {
576
768
  * @param {number} [index] - Index for array/plural resources
577
769
  * @param {string} [category] - Category for plural resources
578
770
  * @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
771
+ * @returns {ResourceFix|undefined} - The fix object or undefined if no fix can be created
580
772
  */
581
773
  createFixForSpanishInvertedPunctuation(resource, target, lastSentence, correctPunctuation, index, category, targetLocaleObj) {
582
774
  const lastSentenceStart = target.lastIndexOf(lastSentence);
@@ -592,7 +784,7 @@ class ResourceSentenceEnding extends ResourceRule {
592
784
  * @param {string} nonBreakingSpace - The non-breaking space character to insert
593
785
  * @param {number} [index] - Index for array/plural resources
594
786
  * @param {string} [category] - Category for plural resources
595
- * @returns {Fix|undefined} - The fix object or undefined if no fix can be created
787
+ * @returns {ResourceFix|undefined} - The fix object or undefined if no fix can be created
596
788
  */
597
789
  createFixForFrenchNonBreakingSpace(resource, target, position, nonBreakingSpace, index, category) {
598
790
  return ResourceFixer.createFix({
@@ -618,7 +810,7 @@ class ResourceSentenceEnding extends ResourceRule {
618
810
  * @param {string} currentSpace - The current space character (or empty string if none)
619
811
  * @param {number} [index] - Index for array/plural resources
620
812
  * @param {string} [category] - Category for plural resources
621
- * @returns {Fix|undefined} - The fix object or undefined if no fix is needed
813
+ * @returns {ResourceFix|undefined} - The fix object or undefined if no fix is needed
622
814
  */
623
815
  createFrenchSpacingFix(resource, target, spacePosition, needsNonBreakingSpace, currentSpace, index, category) {
624
816
  const regularSpace = ' ';
@@ -733,6 +925,29 @@ class ResourceSentenceEnding extends ResourceRule {
733
925
  const sourceLanguage = sourceLocaleObj.getLanguage();
734
926
  if (!sourceLanguage) return undefined;
735
927
 
928
+ // Exception 1: Check minimum length
929
+ if (source.length < this.minimumLength) {
930
+ return undefined;
931
+ }
932
+
933
+ // Exception 2: Check if source has no spaces AND doesn't end with sentence-ending punctuation (not a sentence)
934
+ if (!source.includes(' ')) {
935
+ const trimmed = source.trim();
936
+ const lastChar = trimmed.charAt(trimmed.length - 1);
937
+ const sentenceEndingChars = ['.', '?', '!', '。', '?', '!', '…', ':'];
938
+ if (!sentenceEndingChars.includes(lastChar)) {
939
+ return undefined; // Not a sentence
940
+ }
941
+ }
942
+
943
+ // Exception 3: Check if source is in exception list
944
+ const exceptions = this.exceptionsMap[targetLanguage];
945
+ if (exceptions) {
946
+ if (exceptions.some(exception => exception.toLowerCase().trim() === source.toLowerCase().trim())) {
947
+ return undefined;
948
+ }
949
+ }
950
+
736
951
  const optionalPunctuationLanguages = ['th', 'lo', 'my', 'km', 'vi', 'id', 'ms', 'tl', 'jv', 'su'];
737
952
  const isOptionalPunctuationLanguage = optionalPunctuationLanguages.includes(targetLanguage);
738
953
 
@@ -779,7 +994,7 @@ class ResourceSentenceEnding extends ResourceRule {
779
994
  rule: this,
780
995
  severity: "warning",
781
996
  id: resource.getKey(),
782
- description: `Sentence ending should be "" for ${targetLocale} locale instead of "${targetEnding.original}" (${unicodeCode})`,
997
+ description: `Sentence ending should be no punctuation for ${targetLocale} locale instead of "${targetEnding.original}" (${unicodeCode})`,
783
998
  source,
784
999
  highlight,
785
1000
  pathName: file,
@@ -819,7 +1034,7 @@ class ResourceSentenceEnding extends ResourceRule {
819
1034
  rule: this,
820
1035
  severity: "warning",
821
1036
  id: resource.getKey(),
822
- description: `Sentence ending should be "${insertString}" (${unicodeCode}) for ${targetLocale} locale instead of ""`,
1037
+ description: `Sentence ending should be "${insertString}" (${unicodeCode}) for ${targetLocale} locale instead of ${ResourceSentenceEnding.formatPunctuationForMessage('')}`,
823
1038
  source,
824
1039
  highlight,
825
1040
  pathName: file,
@@ -836,7 +1051,25 @@ class ResourceSentenceEnding extends ResourceRule {
836
1051
 
837
1052
  // For Spanish, check for inverted punctuation at the beginning
838
1053
  if (targetLanguage === 'es' && (sourceEnding.type === 'question' || sourceEnding.type === 'exclamation')) {
839
- if (!this.hasCorrectSpanishInvertedPunctuation(lastSentence, sourceEnding.type)) {
1054
+ // For Spanish inverted punctuation, we need to check the appropriate part of the target:
1055
+ // - If source ends with quote, check the quoted content (lastSentence already contains this)
1056
+ // - If source doesn't end with quote, check the full target string
1057
+ // - However, if lastSentence is the result of getLastSentenceFromContent (which extracts
1058
+ // only the part after the last sentence-ending punctuation), we should check the full target
1059
+ // because inverted punctuation should be at the beginning of the entire sentence
1060
+ // For Spanish inverted punctuation, we need to check the appropriate part of the target:
1061
+ // - If source ends with quote, check the quoted content (lastSentence already contains this)
1062
+ // - If source doesn't end with quote, check the lastSentence (which contains the relevant part)
1063
+ // because inverted punctuation should be at the beginning of the sentence being checked
1064
+ // - Special case: if lastSentence is very short (like "com?" from email addresses or "bar?" from URLs),
1065
+ // use the full target string instead
1066
+ let stringToCheck = lastSentence;
1067
+ if (lastSentence.length < 10 && !lastSentence.startsWith('¿') && !lastSentence.startsWith('¡')) {
1068
+ // This might be a fragment from an email address, URL, or similar, use the full target
1069
+ stringToCheck = target;
1070
+ }
1071
+ const invertedPunctuationResult = this.hasCorrectSpanishInvertedPunctuation(stringToCheck, sourceEnding.type, targetLocaleObj);
1072
+ if (!invertedPunctuationResult.correct) {
840
1073
  // Spanish target is missing inverted punctuation at the beginning
841
1074
  const quoteChars = ResourceSentenceEnding.allQuoteChars;
842
1075
  let quotedContentStart = -1;
@@ -852,7 +1085,18 @@ class ResourceSentenceEnding extends ResourceRule {
852
1085
  const afterQuote = target.substring(quotedContentStart);
853
1086
  highlight = `${beforeQuote}<e0/>${afterQuote}`;
854
1087
  } else {
855
- highlight = `<e0/>${target}`;
1088
+ // For multi-sentence strings, find where the last sentence starts
1089
+ const lastSentenceStart = target.lastIndexOf(lastSentence);
1090
+ if (lastSentenceStart !== -1 && stringToCheck === lastSentence) {
1091
+ // Use the position from hasCorrectSpanishInvertedPunctuation for more precise highlighting
1092
+ const highlightPosition = lastSentenceStart + invertedPunctuationResult.position;
1093
+ const beforeHighlight = target.substring(0, highlightPosition);
1094
+ const afterHighlight = target.substring(highlightPosition);
1095
+ highlight = `${beforeHighlight}<e0/>${afterHighlight}`;
1096
+ } else {
1097
+ // If we're using the full target string (due to URL/email special case), highlight at the beginning
1098
+ highlight = `<e0/>${target}`;
1099
+ }
856
1100
  }
857
1101
 
858
1102
  // Add prefix for array/plural resources
@@ -952,7 +1196,7 @@ class ResourceSentenceEnding extends ResourceRule {
952
1196
  const expectedUnicode = ResourceSentenceEnding.getUnicodeCodes(expectedPunctuation);
953
1197
  const positionInfo = this.findIncorrectPunctuationPosition(target, lastSentence, targetEnding.original);
954
1198
 
955
- description = `Sentence ending should be "${expectedPunctuation}" (${expectedUnicode}) for ${targetLocale} locale instead of "${targetEnding.original}" (${unicodeCode})`;
1199
+ description = `Sentence ending should be ${ResourceSentenceEnding.formatPunctuationForMessage(expectedPunctuation)} (${expectedUnicode}) for ${targetLocale} locale instead of ${ResourceSentenceEnding.formatPunctuationForMessage(targetEnding.original)} (${unicodeCode})`;
956
1200
 
957
1201
  if (positionInfo) {
958
1202
  const beforePunctuation = target.substring(0, positionInfo.position);
@@ -968,7 +1212,7 @@ class ResourceSentenceEnding extends ResourceRule {
968
1212
  const expectedUnicodeWithSpace = ResourceSentenceEnding.getUnicodeCodes(expectedWithSpace);
969
1213
 
970
1214
  highlight = `${beforePunctuation}<e0/>`;
971
- description = `Sentence ending should be "${expectedWithSpace}" (${expectedUnicodeWithSpace}) for ${targetLocale} locale instead of ""`;
1215
+ description = `Sentence ending should be "${expectedWithSpace}" (${expectedUnicodeWithSpace}) for ${targetLocale} locale instead of no punctuation`;
972
1216
  fix = this.createPunctuationFix(resource, target, '', expectedWithSpace, index, category, targetLocaleObj);
973
1217
  } else {
974
1218
  // Target has some punctuation - check for spacing issues