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 +12 -8
- package/src/FileType.js +0 -13
- package/src/Project.js +70 -14
- package/src/index.js +8 -9
- package/src/plugins/BuiltinPlugin.js +2 -0
- package/src/rules/ResourceAllCaps.js +215 -0
- package/src/rules/ResourceSentenceEnding.js +296 -52
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ilib-lint",
|
|
3
|
-
"version": "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.
|
|
66
|
-
"
|
|
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-
|
|
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": "
|
|
85
|
-
"test": "pnpm test:
|
|
86
|
-
"test:
|
|
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()
|
|
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
|
-
|
|
574
|
-
|
|
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.
|
|
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 {
|
|
44
|
+
import { isSpace, isAlpha, isAlnum } from 'ilib-ctype';
|
|
40
45
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(`[^${
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
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
|
-
|
|
578
|
+
|
|
455
579
|
const expectedInverted = sourceEndingType === 'question' ? '¿' : '¡';
|
|
456
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|