ilib-lint 2.18.2 → 2.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -6
- package/src/FileType.js +0 -13
- package/src/Project.js +43 -9
- 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 +231 -47
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ilib-lint",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"module": "./src/index.js",
|
|
@@ -62,30 +62,34 @@
|
|
|
62
62
|
"jest": "^29.7.0",
|
|
63
63
|
"jsdoc": "^4.0.3",
|
|
64
64
|
"jsdoc-to-markdown": "^8.0.3",
|
|
65
|
-
"typescript": "^5.
|
|
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
82
|
"ilib-locale": "^1.2.4",
|
|
83
|
+
"ilib-scriptinfo": "^1.0.0",
|
|
81
84
|
"ilib-tools-common": "^1.19.0"
|
|
82
85
|
},
|
|
83
86
|
"scripts": {
|
|
84
|
-
"coverage": "
|
|
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
|
|
@@ -627,7 +661,7 @@ class Project extends DirItem {
|
|
|
627
661
|
run() {
|
|
628
662
|
let startTime = new Date();
|
|
629
663
|
|
|
630
|
-
const results = this.findIssues(this.
|
|
664
|
+
const results = this.findIssues(this.locales);
|
|
631
665
|
this.applyTransformers(results);
|
|
632
666
|
this.serialize();
|
|
633
667
|
let endTime = new Date();
|
package/src/index.js
CHANGED
|
@@ -79,7 +79,6 @@ const optionConfig = {
|
|
|
79
79
|
locales: {
|
|
80
80
|
short: "l",
|
|
81
81
|
varName: "LOCALES",
|
|
82
|
-
"default": "en-AU,en-CA,en-GB,en-IN,en-NG,en-PH,en-PK,en-US,en-ZA,de-DE,fr-CA,fr-FR,es-AR,es-ES,es-MX,id-ID,it-IT,ja-JP,ko-KR,pt-BR,ru-RU,tr-TR,vi-VN,zxx-XX,zh-Hans-CN,zh-Hant-HK,zh-Hant-TW,zh-Hans-SG",
|
|
83
82
|
help: "Locales you want your app to support. Value is a comma-separated list of BCP-47 style locale tags. Default: the top 20 locales on the internet by traffic."
|
|
84
83
|
},
|
|
85
84
|
sourceLocale: {
|
|
@@ -188,15 +187,15 @@ if (paths.length === 0) {
|
|
|
188
187
|
|
|
189
188
|
if (options.opt.locales) {
|
|
190
189
|
options.opt.locales = options.opt.locales.split(/,/g);
|
|
190
|
+
// normalize the locale specs
|
|
191
|
+
options.opt.locales = options.opt.locales.map(spec => {
|
|
192
|
+
let loc = new Locale(spec);
|
|
193
|
+
if (!loc.getLanguage()) {
|
|
194
|
+
loc = new Locale("und", loc.getRegion(), loc.getVariant(), loc.getScript());
|
|
195
|
+
}
|
|
196
|
+
return loc.getSpec();
|
|
197
|
+
});
|
|
191
198
|
}
|
|
192
|
-
// normalize the locale specs
|
|
193
|
-
options.opt.locales = options.opt.locales.map(spec => {
|
|
194
|
-
let loc = new Locale(spec);
|
|
195
|
-
if (!loc.getLanguage()) {
|
|
196
|
-
loc = new Locale("und", loc.getRegion(), loc.getVariant(), loc.getScript());
|
|
197
|
-
}
|
|
198
|
-
return loc.getSpec();
|
|
199
|
-
});
|
|
200
199
|
|
|
201
200
|
if (options.opt.fix || options.opt.overwrite) {
|
|
202
201
|
// The write option indicates that modified files should be written back to disk.
|
|
@@ -46,6 +46,7 @@ import ResourceXML from '../rules/ResourceXML.js';
|
|
|
46
46
|
import ResourceCamelCase from '../rules/ResourceCamelCase.js';
|
|
47
47
|
import ResourceSnakeCase from '../rules/ResourceSnakeCase.js';
|
|
48
48
|
import ResourceKebabCase from '../rules/ResourceKebabCase.js';
|
|
49
|
+
import ResourceAllCaps from '../rules/ResourceAllCaps.js';
|
|
49
50
|
import ResourceGNUPrintfMatch from '../rules/ResourceGNUPrintfMatch.js';
|
|
50
51
|
import ResourceReturnChar from '../rules/ResourceReturnChar.js';
|
|
51
52
|
import StringFixer from './string/StringFixer.js';
|
|
@@ -542,6 +543,7 @@ class BuiltinPlugin extends Plugin {
|
|
|
542
543
|
ResourceCamelCase,
|
|
543
544
|
ResourceSnakeCase,
|
|
544
545
|
ResourceKebabCase,
|
|
546
|
+
ResourceAllCaps,
|
|
545
547
|
ResourceGNUPrintfMatch,
|
|
546
548
|
ResourceReturnChar,
|
|
547
549
|
FileEncodingRule,
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ResourceAllCaps.js - rule for checking that ALL CAPS source strings have ALL CAPS targets
|
|
3
|
+
*
|
|
4
|
+
* Copyright © 2025 JEDLSoft
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
*
|
|
16
|
+
* See the License for the specific language governing permissions and
|
|
17
|
+
* limitations under the License.
|
|
18
|
+
*/
|
|
19
|
+
import ResourceRule from './ResourceRule.js';
|
|
20
|
+
import ResourceFixer from '../plugins/resource/ResourceFixer.js';
|
|
21
|
+
|
|
22
|
+
import {Result} from 'ilib-lint-common';
|
|
23
|
+
import Locale from 'ilib-locale';
|
|
24
|
+
import {isAlpha, isUpper} from 'ilib-ctype';
|
|
25
|
+
import CaseMapper from 'ilib-casemapper';
|
|
26
|
+
import { scriptInfoFactory } from 'ilib-scriptinfo';
|
|
27
|
+
import LocaleMatcher from 'ilib-localematcher';
|
|
28
|
+
|
|
29
|
+
// type imports
|
|
30
|
+
/** @ignore @typedef {import('ilib-tools-common').Resource} Resource */
|
|
31
|
+
/** @ignore @typedef {import('../plugins/resource/ResourceFix.js').default} ResourceFix */
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @classdesc Class representing an ilib-lint programmatic rule for linting ALL CAPS strings.
|
|
35
|
+
* @class
|
|
36
|
+
*/
|
|
37
|
+
class ResourceAllCaps extends ResourceRule {
|
|
38
|
+
/**
|
|
39
|
+
* Create a ResourceAllCaps rule instance.
|
|
40
|
+
* @param {object} options
|
|
41
|
+
* @param {string[]} [options.exceptions] An array of strings to exclude from the rule.
|
|
42
|
+
*/
|
|
43
|
+
constructor(options) {
|
|
44
|
+
super(options);
|
|
45
|
+
|
|
46
|
+
this.name = "resource-all-caps";
|
|
47
|
+
this.description = "Ensure that when source strings are in ALL CAPS, then the targets are also in ALL CAPS";
|
|
48
|
+
this.link = "https://github.com/iLib-js/ilib-mono/blob/main/packages/ilib-lint/docs/resource-all-caps.md";
|
|
49
|
+
this.exceptions = Array.isArray(options?.exceptions) ? options.exceptions : [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Check if a source string is in ALL CAPS and if the target string matches the casing style.
|
|
54
|
+
* @override
|
|
55
|
+
* @param {Object} params parameters for the string matching
|
|
56
|
+
* @param {string} params.source the source string to match against
|
|
57
|
+
* @param {string} params.target the target string to match against
|
|
58
|
+
* @param {string} params.file the file path where the resources came from
|
|
59
|
+
* @param {Resource} params.resource the resource that contains the source and/or target string
|
|
60
|
+
* @param {number} [params.index] the index of the resource
|
|
61
|
+
* @param {string} [params.category] the category of the resource
|
|
62
|
+
* @returns {Result|undefined} A Result with severity 'error' if the source string is in ALL CAPS and target string is not in ALL CAPS, otherwise undefined.
|
|
63
|
+
*/
|
|
64
|
+
matchString({source, target, file, resource, index, category}) {
|
|
65
|
+
if (!source || !target) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const isException = this.exceptions.includes(source);
|
|
70
|
+
if (isException) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const isAllCaps = ResourceAllCaps.isAllCaps(source);
|
|
75
|
+
if (!isAllCaps) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Check if the target locale supports capital letters
|
|
80
|
+
if (!ResourceAllCaps.hasCapitalLetters(resource.targetLocale || 'en-US')) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Check if target matches the ALL CAPS style
|
|
85
|
+
if (ResourceAllCaps.isAllCaps(target)) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const result = new Result({
|
|
90
|
+
severity: "error",
|
|
91
|
+
id: resource.getKey(),
|
|
92
|
+
source,
|
|
93
|
+
description: "The source string is in ALL CAPS, but the target string is not.",
|
|
94
|
+
rule: this,
|
|
95
|
+
locale: resource.targetLocale,
|
|
96
|
+
pathName: file,
|
|
97
|
+
fix: this.createFix(resource, target, file, index, category),
|
|
98
|
+
highlight: `<e0>${target}</e0>`
|
|
99
|
+
});
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get the fix for this rule - converts target to ALL CAPS while preserving the translation
|
|
105
|
+
* @param {Resource} resource the resource to fix
|
|
106
|
+
* @param {string} target the current target string
|
|
107
|
+
* @param {string} file the file path
|
|
108
|
+
* @param {number} [index] the index for array resources
|
|
109
|
+
* @param {string} [category] the category for plural resources
|
|
110
|
+
* @returns {ResourceFix | undefined} the fix for this rule
|
|
111
|
+
*/
|
|
112
|
+
createFix(resource, target, file, index, category) {
|
|
113
|
+
const locale = resource.targetLocale || 'en-US';
|
|
114
|
+
const casemapper = new CaseMapper({
|
|
115
|
+
locale,
|
|
116
|
+
direction: "toupper"
|
|
117
|
+
});
|
|
118
|
+
const upperCaseTarget = casemapper.map(target);
|
|
119
|
+
if (!upperCaseTarget) {
|
|
120
|
+
// if we could not upper-case the target, then we cannot fix it, so don't return a fix
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const command = ResourceFixer.createStringCommand(0, target.length, upperCaseTarget);
|
|
125
|
+
return ResourceFixer.createFix({
|
|
126
|
+
resource,
|
|
127
|
+
target: true,
|
|
128
|
+
category,
|
|
129
|
+
index,
|
|
130
|
+
commands: [command]
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Checks if a given string is in ALL CAPS style, i.e. at least 2 letter characters exist and all of them are uppercase.
|
|
136
|
+
*
|
|
137
|
+
* @public
|
|
138
|
+
* @param {string} string A non-empty string to check.
|
|
139
|
+
* @returns {boolean} Returns true for a string that is in ALL CAPS (all letter characters are uppercase and at least 2 letter characters exist).
|
|
140
|
+
* Otherwise, returns false.
|
|
141
|
+
*/
|
|
142
|
+
static isAllCaps(string) {
|
|
143
|
+
if (!string || typeof string !== 'string') {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const trimmed = string.trim();
|
|
148
|
+
if (trimmed.length < 2) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let letterCount = 0;
|
|
153
|
+
let allLettersUpper = true;
|
|
154
|
+
|
|
155
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
156
|
+
const char = trimmed[i];
|
|
157
|
+
if (isAlpha(char)) {
|
|
158
|
+
letterCount++;
|
|
159
|
+
if (!isUpper(char)) {
|
|
160
|
+
allLettersUpper = false;
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return letterCount >= 2 && allLettersUpper;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Checks if a locale supports letter upper- and lower-casing.
|
|
171
|
+
* A language by itself cannot have capitalization; Instead, it's a property of a script.
|
|
172
|
+
* Therefore, if no script is explicitly specified in the locale, this method will figure out
|
|
173
|
+
* what the script is. It will use LocaleMatcher to guess the most likely full
|
|
174
|
+
* locale, which always includes the script tag. This may not be the script that the caller intended
|
|
175
|
+
* to use with the locale, but it will be a good guess because most locales only use one script.
|
|
176
|
+
* Very few locales use multiple scripts, though they do exist. (Kurdish and Serbian for example
|
|
177
|
+
* are commonly written in multiple scripts.) Once it has the name of the script, it will check whether
|
|
178
|
+
* that script supports letter casing.
|
|
179
|
+
* @public
|
|
180
|
+
* @param {string} locale The locale to check for capital letter support.
|
|
181
|
+
* @returns {boolean} Returns true if the locale's script supports capital letters, false otherwise.
|
|
182
|
+
*/
|
|
183
|
+
static hasCapitalLetters(locale) {
|
|
184
|
+
if (!locale) {
|
|
185
|
+
return true; // Default to true for unknown locales
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
const localeObj = new Locale(locale);
|
|
190
|
+
let script = localeObj.getScript();
|
|
191
|
+
|
|
192
|
+
// If no script is specified, use LocaleMatcher to get the likely locale
|
|
193
|
+
if (!script) {
|
|
194
|
+
const localeMatcher = new LocaleMatcher({ locale: locale });
|
|
195
|
+
const likelyLocale = localeMatcher.getLikelyLocale();
|
|
196
|
+
if (likelyLocale) {
|
|
197
|
+
const likelyLocaleObj = new Locale(likelyLocale);
|
|
198
|
+
script = likelyLocaleObj.getScript();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (script) {
|
|
203
|
+
const scriptInfo = scriptInfoFactory(script);
|
|
204
|
+
return scriptInfo?.casing ?? true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return true; // Default to true for unknown scripts
|
|
208
|
+
} catch (error) {
|
|
209
|
+
// If there's any error parsing the locale or script, default to true
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export default ResourceAllCaps;
|
|
@@ -17,12 +17,18 @@
|
|
|
17
17
|
* limitations under the License.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
/*
|
|
21
21
|
* ResourceSentenceEnding - Checks that sentence-ending punctuation is appropriate for the target locale
|
|
22
22
|
*
|
|
23
23
|
* This rule checks if the source string ends with certain punctuation marks and ensures
|
|
24
24
|
* the target uses the locale-appropriate equivalent.
|
|
25
25
|
*
|
|
26
|
+
* Features:
|
|
27
|
+
* - Configurable minimum length threshold to skip short strings (abbreviations)
|
|
28
|
+
* - Automatic skipping of strings with no spaces (non-sentences)
|
|
29
|
+
* - Custom punctuation mappings per locale
|
|
30
|
+
* - Exception lists to skip specific source strings
|
|
31
|
+
*
|
|
26
32
|
* Examples:
|
|
27
33
|
* - English period (.) should become Japanese maru (。) in Japanese
|
|
28
34
|
* - English question mark (?) should become Japanese question mark (?) in Japanese
|
|
@@ -34,14 +40,23 @@
|
|
|
34
40
|
import { Result } from 'ilib-lint-common';
|
|
35
41
|
import ResourceRule from './ResourceRule.js';
|
|
36
42
|
import Locale from 'ilib-locale';
|
|
37
|
-
import LocaleInfo from 'ilib-localeinfo';
|
|
38
43
|
import ResourceFixer from '../plugins/resource/ResourceFixer.js';
|
|
39
|
-
import {
|
|
44
|
+
import { isSpace } 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,53 @@ class ResourceSentenceEnding extends ResourceRule {
|
|
|
434
546
|
}
|
|
435
547
|
|
|
436
548
|
/**
|
|
437
|
-
* Check if Spanish target has the correct inverted punctuation
|
|
549
|
+
* Check if Spanish target has the correct inverted punctuation in the last sentence
|
|
438
550
|
* @param {string} lastSentence - The last sentence of the target string (already stripped of quotes)
|
|
439
551
|
* @param {string} sourceEndingType - The type of ending punctuation in source
|
|
440
|
-
* @returns {boolean} -
|
|
552
|
+
* @returns {{correct: boolean, position: number}} - position is where inverted punctuation should be
|
|
441
553
|
*/
|
|
442
554
|
hasCorrectSpanishInvertedPunctuation(lastSentence, sourceEndingType) {
|
|
443
|
-
if (!lastSentence || typeof lastSentence !== 'string') return false;
|
|
555
|
+
if (!lastSentence || typeof lastSentence !== 'string') return { correct: false, position: 0 };
|
|
444
556
|
// Only check for questions and exclamations
|
|
445
557
|
if (sourceEndingType !== 'question' && sourceEndingType !== 'exclamation') {
|
|
446
|
-
return true; // Not applicable for other punctuation types
|
|
558
|
+
return { correct: true, position: 0 }; // Not applicable for other punctuation types
|
|
447
559
|
}
|
|
448
560
|
// Strip any leading quote characters before checking for inverted punctuation
|
|
449
561
|
const quoteChars = ResourceSentenceEnding.allQuoteChars;
|
|
450
562
|
let strippedSentence = lastSentence;
|
|
563
|
+
let strippedOffset = 0;
|
|
451
564
|
while (strippedSentence.length > 0 && (quoteChars.includes(strippedSentence.charAt(0)) || isSpace(strippedSentence.charAt(0)))) {
|
|
452
565
|
strippedSentence = strippedSentence.slice(1);
|
|
566
|
+
strippedOffset++;
|
|
453
567
|
}
|
|
454
|
-
|
|
568
|
+
|
|
455
569
|
const expectedInverted = sourceEndingType === 'question' ? '¿' : '¡';
|
|
456
|
-
|
|
570
|
+
|
|
571
|
+
// Search backwards from the end of the sentence
|
|
572
|
+
// If we find the correct inverted punctuation first, it's correct
|
|
573
|
+
// If we find sentence-ending punctuation first, it's incorrect
|
|
574
|
+
for (let i = strippedSentence.length - 1; i >= 0; i--) {
|
|
575
|
+
const char = strippedSentence.charAt(i);
|
|
576
|
+
|
|
577
|
+
// If we find the correct inverted punctuation, it's correct
|
|
578
|
+
if (char === expectedInverted) {
|
|
579
|
+
return { correct: true, position: strippedOffset };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// If we find sentence-ending punctuation (excluding the final one),
|
|
583
|
+
// we've reached the start of this sentence without finding inverted punctuation
|
|
584
|
+
if (char === '.' || char === '!' || char === '?' || char === '。' || char === '!' || char === '?') {
|
|
585
|
+
// Skip the final punctuation at the end
|
|
586
|
+
if (i === strippedSentence.length - 1) {
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
// Found sentence-ending punctuation before inverted punctuation
|
|
590
|
+
return { correct: false, position: strippedOffset };
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// If we reach the start without finding either, it's incorrect
|
|
595
|
+
return { correct: false, position: strippedOffset };
|
|
457
596
|
}
|
|
458
597
|
|
|
459
598
|
/**
|
|
@@ -490,7 +629,7 @@ class ResourceSentenceEnding extends ResourceRule {
|
|
|
490
629
|
* @param {string} target - The target string
|
|
491
630
|
* @param {string} incorrectPunctuation - The incorrect punctuation
|
|
492
631
|
* @param {string} correctPunctuation - The correct punctuation
|
|
493
|
-
* @returns {
|
|
632
|
+
* @returns {ResourceFix|undefined} - The fix object or undefined if no fix can be created
|
|
494
633
|
*/
|
|
495
634
|
createPunctuationFix(resource, target, incorrectPunctuation, correctPunctuation, index, category, targetLocaleObj) {
|
|
496
635
|
// Get the last sentence to find the position
|
|
@@ -547,7 +686,7 @@ class ResourceSentenceEnding extends ResourceRule {
|
|
|
547
686
|
* @param {string} character - The character to insert
|
|
548
687
|
* @param {number} [index] - Index for array/plural resources
|
|
549
688
|
* @param {string} [category] - Category for plural resources
|
|
550
|
-
* @returns {
|
|
689
|
+
* @returns {ResourceFix|undefined} - The fix object or undefined if no fix can be created
|
|
551
690
|
*/
|
|
552
691
|
createInsertCharacterFix(resource, target, position, character, index, category) {
|
|
553
692
|
return ResourceFixer.createFix({
|
|
@@ -576,7 +715,7 @@ class ResourceSentenceEnding extends ResourceRule {
|
|
|
576
715
|
* @param {number} [index] - Index for array/plural resources
|
|
577
716
|
* @param {string} [category] - Category for plural resources
|
|
578
717
|
* @param {Locale} [targetLocaleObj] - The target locale object (unused, kept for compatibility)
|
|
579
|
-
* @returns {
|
|
718
|
+
* @returns {ResourceFix|undefined} - The fix object or undefined if no fix can be created
|
|
580
719
|
*/
|
|
581
720
|
createFixForSpanishInvertedPunctuation(resource, target, lastSentence, correctPunctuation, index, category, targetLocaleObj) {
|
|
582
721
|
const lastSentenceStart = target.lastIndexOf(lastSentence);
|
|
@@ -592,7 +731,7 @@ class ResourceSentenceEnding extends ResourceRule {
|
|
|
592
731
|
* @param {string} nonBreakingSpace - The non-breaking space character to insert
|
|
593
732
|
* @param {number} [index] - Index for array/plural resources
|
|
594
733
|
* @param {string} [category] - Category for plural resources
|
|
595
|
-
* @returns {
|
|
734
|
+
* @returns {ResourceFix|undefined} - The fix object or undefined if no fix can be created
|
|
596
735
|
*/
|
|
597
736
|
createFixForFrenchNonBreakingSpace(resource, target, position, nonBreakingSpace, index, category) {
|
|
598
737
|
return ResourceFixer.createFix({
|
|
@@ -618,7 +757,7 @@ class ResourceSentenceEnding extends ResourceRule {
|
|
|
618
757
|
* @param {string} currentSpace - The current space character (or empty string if none)
|
|
619
758
|
* @param {number} [index] - Index for array/plural resources
|
|
620
759
|
* @param {string} [category] - Category for plural resources
|
|
621
|
-
* @returns {
|
|
760
|
+
* @returns {ResourceFix|undefined} - The fix object or undefined if no fix is needed
|
|
622
761
|
*/
|
|
623
762
|
createFrenchSpacingFix(resource, target, spacePosition, needsNonBreakingSpace, currentSpace, index, category) {
|
|
624
763
|
const regularSpace = ' ';
|
|
@@ -733,6 +872,29 @@ class ResourceSentenceEnding extends ResourceRule {
|
|
|
733
872
|
const sourceLanguage = sourceLocaleObj.getLanguage();
|
|
734
873
|
if (!sourceLanguage) return undefined;
|
|
735
874
|
|
|
875
|
+
// Exception 1: Check minimum length
|
|
876
|
+
if (source.length < this.minimumLength) {
|
|
877
|
+
return undefined;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Exception 2: Check if source has no spaces AND doesn't end with sentence-ending punctuation (not a sentence)
|
|
881
|
+
if (!source.includes(' ')) {
|
|
882
|
+
const trimmed = source.trim();
|
|
883
|
+
const lastChar = trimmed.charAt(trimmed.length - 1);
|
|
884
|
+
const sentenceEndingChars = ['.', '?', '!', '。', '?', '!', '…', ':'];
|
|
885
|
+
if (!sentenceEndingChars.includes(lastChar)) {
|
|
886
|
+
return undefined; // Not a sentence
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Exception 3: Check if source is in exception list
|
|
891
|
+
const exceptions = this.exceptionsMap[targetLanguage];
|
|
892
|
+
if (exceptions) {
|
|
893
|
+
if (exceptions.some(exception => exception.toLowerCase().trim() === source.toLowerCase().trim())) {
|
|
894
|
+
return undefined;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
736
898
|
const optionalPunctuationLanguages = ['th', 'lo', 'my', 'km', 'vi', 'id', 'ms', 'tl', 'jv', 'su'];
|
|
737
899
|
const isOptionalPunctuationLanguage = optionalPunctuationLanguages.includes(targetLanguage);
|
|
738
900
|
|
|
@@ -836,7 +998,19 @@ class ResourceSentenceEnding extends ResourceRule {
|
|
|
836
998
|
|
|
837
999
|
// For Spanish, check for inverted punctuation at the beginning
|
|
838
1000
|
if (targetLanguage === 'es' && (sourceEnding.type === 'question' || sourceEnding.type === 'exclamation')) {
|
|
839
|
-
|
|
1001
|
+
// For Spanish inverted punctuation, we need to check the appropriate part of the target:
|
|
1002
|
+
// - If source ends with quote, check the quoted content (lastSentence already contains this)
|
|
1003
|
+
// - If source doesn't end with quote, check the full target string
|
|
1004
|
+
// - However, if lastSentence is the result of getLastSentenceFromContent (which extracts
|
|
1005
|
+
// only the part after the last sentence-ending punctuation), we should check the full target
|
|
1006
|
+
// because inverted punctuation should be at the beginning of the entire sentence
|
|
1007
|
+
// For Spanish inverted punctuation, we need to check the appropriate part of the target:
|
|
1008
|
+
// - If source ends with quote, check the quoted content (lastSentence already contains this)
|
|
1009
|
+
// - If source doesn't end with quote, check the lastSentence (which contains the relevant part)
|
|
1010
|
+
// because inverted punctuation should be at the beginning of the sentence being checked
|
|
1011
|
+
const stringToCheck = lastSentence;
|
|
1012
|
+
const invertedPunctuationResult = this.hasCorrectSpanishInvertedPunctuation(stringToCheck, sourceEnding.type);
|
|
1013
|
+
if (!invertedPunctuationResult.correct) {
|
|
840
1014
|
// Spanish target is missing inverted punctuation at the beginning
|
|
841
1015
|
const quoteChars = ResourceSentenceEnding.allQuoteChars;
|
|
842
1016
|
let quotedContentStart = -1;
|
|
@@ -852,7 +1026,17 @@ class ResourceSentenceEnding extends ResourceRule {
|
|
|
852
1026
|
const afterQuote = target.substring(quotedContentStart);
|
|
853
1027
|
highlight = `${beforeQuote}<e0/>${afterQuote}`;
|
|
854
1028
|
} else {
|
|
855
|
-
|
|
1029
|
+
// For multi-sentence strings, find where the last sentence starts
|
|
1030
|
+
const lastSentenceStart = target.lastIndexOf(lastSentence);
|
|
1031
|
+
if (lastSentenceStart !== -1) {
|
|
1032
|
+
// Use the position from hasCorrectSpanishInvertedPunctuation for more precise highlighting
|
|
1033
|
+
const highlightPosition = lastSentenceStart + invertedPunctuationResult.position;
|
|
1034
|
+
const beforeHighlight = target.substring(0, highlightPosition);
|
|
1035
|
+
const afterHighlight = target.substring(highlightPosition);
|
|
1036
|
+
highlight = `${beforeHighlight}<e0/>${afterHighlight}`;
|
|
1037
|
+
} else {
|
|
1038
|
+
highlight = `<e0/>${target}`;
|
|
1039
|
+
}
|
|
856
1040
|
}
|
|
857
1041
|
|
|
858
1042
|
// Add prefix for array/plural resources
|