diff --git a/docs/IString-Refactoring.md b/docs/IString-Refactoring.md index 24cd45387..65e91607b 100644 --- a/docs/IString-Refactoring.md +++ b/docs/IString-Refactoring.md @@ -86,7 +86,7 @@ IString.js (final assembly) --- -### Phase 2: Extract IStringFmt.js +### Phase 2: Extract IStringFmt.js ← **done** **Items to move:** @@ -105,25 +105,33 @@ var IStringFmt = require('./IStringFmt'); Object.assign(IString.prototype, IStringFmt); ``` +Also added `IString.prototype.constructor = IString;` right after the `IString.prototype = {...}` object-literal assignment. Replacing `.prototype` wholesale drops the auto-generated `constructor` property, so `formatChoice`'s internal `new this.constructor(...)` calls (used instead of a bare `IString` reference to avoid a require-time circular dependency with IStringFmt.js) resolved to `Object` instead of `IString` until this line was added. + +`_testChoice`'s `IString._fncs.*` / `IString.plurals_default` references became `PluralUtils._fncs.*` / `PluralUtils.plurals_default` directly, and `setLocale`'s `IString.loadPlurals(...)` call became `PluralUtils.loadPlurals(...)`, since IStringFmt.js requires PluralUtils.js itself rather than reaching back through IString's static aliases. The `Locale` require moved from IString.js to IStringFmt.js (nothing else in IString.js used it). + **Verification:** -- The 3 external `formatChoice` callers (DurationFmt, UnitFmt, DateFmt) work unchanged -- The `format` → `formatChoice` internal call chain works correctly +- The 4 external `formatChoice` callers (DateFmt ×2, DurationFmt, UnitFmt) work unchanged ✓ +- The 2 external `setLocale` callers (ResBundle, UnitFmt) work unchanged ✓ +- The `format` → `formatChoice` internal call chain works correctly ✓ +- New unit tests added for the formatting mixin ([test/root/teststringfmt.js](../js/test/root/teststringfmt.js)) ✓ +- Existing suites (`teststrings.js`, `testpluralutils.js`, `teststringsasync.js`, `testdatefmt.js`, `testunitfmt.js`, `testnamefmt.js`, `testaddress.js`) pass unchanged ✓ -**Expected result:** ~400 additional lines removed from IString.js +**Result:** IString.js reduced from 1158 to 712 lines (~446 removed); IStringFmt.js is 482 lines. --- ## Expected Final File Sizes -| File | Estimated lines | Actual (so far) | +| File | Estimated lines | Actual | |------|----------------|-----------------| | PluralUtils.js | ~460 | 378 (done) | -| IStringFmt.js | ~420 | — (Phase 2 pending) | -| IString.js | ~660 | 1158 (after Phase 1; Phase 2 pending) | +| IStringFmt.js | ~420 | 482 (done) | +| IString.js | ~660 | 712 (done) | --- ## Notes -- `formatChoice` internally creates `new IString(strings[i])`. After the split, `IStringFmt` does not require `IString` directly, so there is no circular dependency. By the time any `IStringFmt` method executes, `IString.prototype` already has the mixin applied. -- `_testChoice` calls `IString._fncs` (aliased to `PluralUtils._fncs`). `IStringFmt.js` can depend on `PluralUtils` directly, which is cleaner and avoids any indirect circular reference. +- `formatChoice` internally creates `new IString(strings[i])`. After the split, `IStringFmt` does not require `IString` directly, so there is no circular dependency. Instead, `formatChoice` uses `new this.constructor(...)`, which resolves to `IString` at call time via the instance's prototype chain. +- Because `IString.prototype` is replaced wholesale with an object literal (`IString.prototype = {...}`), the JS-engine-provided `constructor` property is lost in the process (it would otherwise point back to `IString` automatically). `IString.js` restores it explicitly with `IString.prototype.constructor = IString;` right after the object-literal assignment — this is required for `this.constructor` in `formatChoice` to resolve correctly. +- `_testChoice` calls `PluralUtils._fncs` and `PluralUtils.plurals_default` directly (not through the `IString._fncs`/`IString.plurals_default` aliases), and `setLocale` calls `PluralUtils.loadPlurals` directly (not `IString.loadPlurals`), since `IStringFmt.js` depends on `PluralUtils` directly, which is cleaner and avoids any indirect circular reference. diff --git a/js/lib/IString.js b/js/lib/IString.js index 7ac43eb14..fcb94cc4e 100644 --- a/js/lib/IString.js +++ b/js/lib/IString.js @@ -20,8 +20,8 @@ // !data plurals var ilib = require("../index.js"); -var Locale = require("./Locale.js"); var PluralUtils = require("./PluralUtils.js"); +var IStringFmt = require("./IStringFmt"); /** * @class @@ -171,418 +171,6 @@ IString.prototype = { return this.str.length; }, - /** - * Format this string instance as a message, replacing the parameters with - * the given values.
- * - * The string can contain any text that a regular Javascript string can - * contain. Replacement parameters have the syntax: - * - *
- * {name}
- *
- *
- * Where "name" can be any string surrounded by curly brackets. The value of
- * "name" is taken from the parameters argument.- * - * Example: - * - *
- * var str = new IString("There are {num} objects.");
- * console.log(str.format({
- * num: 12
- * });
- *
- *
- * Would give the output:
- *
- * - * There are 12 objects. - *- * - * If a property is missing from the parameter block, the replacement - * parameter substring is left untouched in the string, and a different - * set of parameters may be applied a second time. This way, different - * parts of the code may format different parts of the message that they - * happen to know about.
- * - * Example: - * - *
- * var str = new IString("There are {num} objects in the {container}.");
- * console.log(str.format({
- * num: 12
- * });
- *
- *
- * Would give the output:- * - *
- * There are 12 objects in the {container}.
- *
- *
- * The result can then be formatted again with a different parameter block that
- * specifies a value for the container property.
- *
- * @param params a Javascript object containing values for the replacement
- * parameters in the current string
- * @return a new IString instance with as many replacement parameters filled
- * out as possible with real values.
- */
- format: function (params) {
- var formatted = this.str;
- if (params) {
- var regex;
- for (var p in params) {
- if (typeof(params[p]) !== 'undefined') {
- regex = new RegExp("\{"+p+"\}", "g");
- formatted = formatted.replace(regex, params[p]);
- }
- }
- }
- return formatted.toString();
- },
-
- /** @private */
- _testChoice: function(index, limit) {
- var operandValue = {};
-
- switch (typeof(index)) {
- case 'number':
- operandValue = IString._fncs.calculateNumberDigits(index);
-
- if (limit.substring(0,2) === "<=") {
- limit = parseFloat(limit.substring(2));
- return operandValue.n <= limit;
- } else if (limit.substring(0,2) === ">=") {
- limit = parseFloat(limit.substring(2));
- return operandValue.n >= limit;
- } else if (limit.charAt(0) === "<") {
- limit = parseFloat(limit.substring(1));
- return operandValue.n < limit;
- } else if (limit.charAt(0) === ">") {
- limit = parseFloat(limit.substring(1));
- return operandValue.n > limit;
- } else {
- this.locale = this.locale || new Locale(this.localeSpec);
- switch (limit) {
- case "zero":
- case "one":
- case "two":
- case "few":
- case "many":
- // CLDR locale-dependent number classes
- var ruleset = ilib.data["plurals_" + this.locale.getLanguage()+ "_" + this.locale.getRegion()] || ilib.data["plurals_" + this.locale.getLanguage()]|| IString.plurals_default;
- if (ruleset) {
- var rule = ruleset[limit];
- return IString._fncs.getValue(rule, operandValue);
- }
- break;
- case "":
- case "other":
- // matches anything
- return true;
- default:
- var dash = limit.indexOf("-");
- if (dash !== -1) {
- // range
- var start = limit.substring(0, dash);
- var end = limit.substring(dash+1);
- return operandValue.n >= parseInt(start, 10) && operandValue.n <= parseInt(end, 10);
- } else {
- return operandValue.n === parseInt(limit, 10);
- }
- }
- }
- break;
- case 'boolean':
- return (limit === "true" && index === true) || (limit === "false" && index === false);
-
- case 'string':
- var regexp = new RegExp(limit, "i");
- return regexp.test(index);
-
- case 'object':
- throw "syntax error: formatChoice parameter for the argument index cannot be an object";
- }
-
- return false;
- },
- /** @private */
- _isIntlPluralAvailable: function(locale) {
- if (typeof (locale.getVariant()) !== 'undefined'){
- return false;
- }
-
- if (typeof(Intl) !== 'undefined' &&
- typeof(Intl.PluralRules) !== 'undefined' &&
- typeof(Intl.PluralRules.supportedLocalesOf) !== 'undefined') {
- if (ilib._getPlatform() === 'nodejs') {
- var version = process.versions["node"];
- if (!version) return false;
- var majorVersion = version.split(".")[0];
- if (Number(majorVersion) >= 10 && (Intl.PluralRules.supportedLocalesOf(locale.getSpec()).length > 0)) {
- return true;
- }
- return false;
- } else if (Intl.PluralRules.supportedLocalesOf(locale.getSpec()).length > 0) {
- return true;
- } else {
- return false;
- }
- }
- return false;
- },
-
- /**
- * Format a string as one of a choice of strings dependent on the value of
- * a particular argument index or array of indices.- * - * The syntax of the choice string is as follows. The string contains a - * series of choices separated by a vertical bar character "|". Each choice - * has a value or range of values to match followed by a hash character "#" - * followed by the string to use if the variable matches the criteria.
- * - * Example string: - * - *
- * var num = 2;
- * var str = new IString("0#There are no objects.|1#There is one object.|2#There are {number} objects.");
- * console.log(str.formatChoice(num, {
- * number: num
- * }));
- *
- *
- * Gives the output:
- *
- * - * "There are 2 objects." - *- * - * The strings to format may contain replacement variables that will be formatted - * using the format() method above and the params argument as a source of values - * to use while formatting those variables.
- * - * If the criterion for a particular choice is empty, that choice will be used - * as the default one for use when none of the other choice's criteria match.
- * - * Example string: - * - *
- * var num = 22;
- * var str = new IString("0#There are no objects.|1#There is one object.|#There are {number} objects.");
- * console.log(str.formatChoice(num, {
- * number: num
- * }));
- *
- *
- * Gives the output:
- *
- * - * "There are 22 objects." - *- * - * If multiple choice patterns can match a given argument index, the first one - * encountered in the string will be used. If no choice patterns match the - * argument index, then the default choice will be used. If there is no default - * choice defined, then this method will return an empty string.
- * - * Special Syntax
- * - * For any choice format string, all of the patterns in the string should be - * of a single type: numeric, boolean, or string/regexp. The type of the - * patterns is determined by the type of the argument index parameter.
- * - * If the argument index is numeric, then some special syntax can be used - * in the patterns to match numeric ranges.
- * - *
- * - * The definition of what numbers are included in a class is locale-dependent. - * They are defined in the data file plurals.json. If your string is in a - * different locale than the default for ilib, you should call the setLocale() - * method of the string instance before calling this method.
- * - * Other Pattern Types
- * - * If the argument index is a boolean, the string values "true" and "false" - * may appear as the choice patterns.
- * - * If the argument index is of type string, then the choice patterns may contain - * regular expressions, or static strings as degenerate regexps.
- * - * Multiple Indexes
- * - * If you have 2 or more indexes to format into a string, you can pass them as - * an array. When you do that, the patterns to match should be a comma-separate - * list of patterns as per the rules above.
- * - * Example string: - * - *
- * var str = new IString("zero,zero#There are no objects on zero pages.|one,one#There is 1 object on 1 page.|other,one#There are {number} objects on 1 page.|#There are {number} objects on {pages} pages.");
- * var num = 4, pages = 1;
- * console.log(str.formatChoice([num, pages], {
- * number: num,
- * pages: pages
- * }));
- *
- *
- * Gives the output:- * - *
- * "There are 4 objects on 1 page." - *- * - * Note that when there is a single index, you would typically leave the pattern blank to - * indicate the default choice. When there are multiple indices, sometimes one of the - * patterns has to be the default case when the other is not. Rather than leaving one or - * more of the patterns blank with commas that look out-of-place in the middle of it, you - * can use the word "other" to indicate a match with the default or other choice. The above example - * shows the use of the "other" pattern. That said, you are allowed to leave the pattern - * blank if you so choose. In the example above, the pattern for the third string could - * easily have been written as ",one" instead of "other,one" and the result will be the same. - * - * @param {*|Array.<*>} argIndex The index into the choice array of the current parameter, - * or an array of indices - * @param {Object} params The hash of parameter values that replace the replacement - * variables in the string - * * @param {boolean} useIntlPlural [optional] true if you are willing to use Intl.PluralRules object - * If it is omitted, the default value is true - * @throws "syntax error in choice format pattern: " if there is a syntax error - * @return {string} the formatted string - */ - formatChoice: function(argIndex, params, useIntlPlural) { - var choices = this.str.split("|"); - var limits = []; - var strings = []; - var limitsArr = []; - var i; - var parts; - var result = undefined; - var defaultCase = ""; - var checkArgsType; - var useIntl = typeof(useIntlPlural) !== 'undefined' ? useIntlPlural : true; - if (this.str.length === 0) { - // nothing to do - return ""; - } - - // first parse all the choices - for (i = 0; i < choices.length; i++) { - parts = choices[i].split("#"); - if (parts.length > 2) { - limits[i] = parts[0]; - parts = parts.shift(); - strings[i] = parts.join("#"); - } else if (parts.length === 2) { - limits[i] = parts[0]; - strings[i] = parts[1]; - } else { - // syntax error - throw "syntax error in choice format pattern: " + choices[i]; - } - } - - var args = (ilib.isArray(argIndex)) ? argIndex : [argIndex]; - - checkArgsType = args.filter(ilib.bind(this, function(item){ - if (typeof(item) !== "number") { - return false; - } - return true; - })); - - if (useIntl && this.intlPlural && (args.length === checkArgsType.length)){ - this.cateArr = []; - for(i = 0; i < args.length;i++) { - var r = this.intlPlural.select(args[i]); - this.cateArr.push(r); - } - if (args.length === 1) { - var idx = limits.indexOf(this.cateArr[0]); - if (idx == -1) { - idx = limits.indexOf(""); - } - result = new IString(strings[idx]); - } else { - if (limits.length === 0) { - defaultCase = new IString(strings[i]); - } else { - this.findOne = false; - - for(i = 0; !this.findOne && i < limits.length; i++){ - limitsArr = (limits[i].indexOf(",") > -1) ? limits[i].split(",") : [limits[i]]; - - if (limitsArr.length > 1 && (limitsArr.length < this.cateArr.length)){ - this.cateArr = this.cateArr.slice(0,limitsArr.length); - } - limitsArr = limitsArr.map(function(item){ - return item.trim(); - }) - limitsArr.filter(ilib.bind(this, function(element, idx, arr){ - if (JSON.stringify(arr) === JSON.stringify(this.cateArr)){ - this.number = i; - this.fineOne = true; - } - })); - } - if (this.number === -1){ - this.number = limits.indexOf(""); - } - result = new IString(strings[this.number]); - } - } - } else { - // then apply the argument index (or indices) - for (i = 0; i < limits.length; i++) { - if (limits[i].length === 0) { - // this is default case - defaultCase = new IString(strings[i]); - } else { - limitsArr = (limits[i].indexOf(",") > -1) ? limits[i].split(",") : [limits[i]]; - - var applicable = true; - for (var j = 0; applicable && j < args.length && j < limitsArr.length; j++) { - applicable = this._testChoice(args[j], limitsArr[j]); - } - - if (applicable) { - result = new IString(strings[i]); - i = limits.length; - } - } - } - } - if (!result) { - result = defaultCase || new IString(""); - } - - result = result.format(params); - - return result.toString(); - }, - // delegates /** * Same as String.toString() @@ -1091,47 +679,6 @@ IString.prototype = { return (count < 0) ? ch : -1; }, - /** - * Set the locale to use when processing choice formats. The locale - * affects how number classes are interpretted. In some cultures, - * the limit "few" maps to "any integer that ends in the digits 2 to 9" and - * in yet others, "few" maps to "any integer that ends in the digits - * 3 or 4". - * @param {Locale|string} locale locale to use when processing choice - * formats with this string - * @param {boolean=} sync [optional] whether to load the locale data synchronously - * or not - * @param {Object=} loadParams [optional] parameters to pass to the loader function - * @param {function(*)=} onLoad [optional] function to call when the loading is done - */ - setLocale: function (locale, sync, loadParams, onLoad) { - if (typeof(locale) === 'object') { - this.locale = locale; - } else { - this.localeSpec = locale; - this.locale = new Locale(locale); - } - - if (this._isIntlPluralAvailable(this.locale)){ - this.intlPlural = new Intl.PluralRules(this.locale.getSpec()); - } - - IString.loadPlurals(typeof(sync) !== 'undefined' ? sync : true, this.locale, loadParams, onLoad); - }, - - /** - * Return the locale to use when processing choice formats. The locale - * affects how number classes are interpretted. In some cultures, - * the limit "few" maps to "any integer that ends in the digits 2 to 9" and - * in yet others, "few" maps to "any integer that ends in the digits - * 3 or 4". - * @return {string} localespec to use when processing choice - * formats with this string - */ - getLocale: function () { - return (this.locale ? this.locale.getSpec() : this.localeSpec) || ilib.getLocale(); - }, - /** * Return the number of code points in this string. This may be different * than the number of characters, as the UTF-16 encoding that Javascript @@ -1155,4 +702,11 @@ IString.prototype = { } }; +// restore the constructor reference lost when the prototype was replaced with +// an object literal above; IStringFmt.formatChoice relies on this.constructor +// to create new IString instances without requiring IString.js itself +IString.prototype.constructor = IString; + +Object.assign(IString.prototype, IStringFmt); + module.exports = IString; diff --git a/js/lib/IStringFmt.js b/js/lib/IStringFmt.js new file mode 100644 index 000000000..8d2a52550 --- /dev/null +++ b/js/lib/IStringFmt.js @@ -0,0 +1,482 @@ +/* + * IStringFmt.js - String formatting methods for IString + * + * Copyright © 2026 JEDLSoft + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// !data plurals + +var ilib = require("../index.js"); +var Locale = require("./Locale.js"); +var PluralUtils = require("./PluralUtils.js"); + +var IStringFmt = {}; + +/** + * Format this string instance as a message, replacing the parameters with + * the given values.
+ * + * The string can contain any text that a regular Javascript string can + * contain. Replacement parameters have the syntax: + * + *
+ * {name}
+ *
+ *
+ * Where "name" can be any string surrounded by curly brackets. The value of
+ * "name" is taken from the parameters argument.+ * + * Example: + * + *
+ * var str = new IString("There are {num} objects.");
+ * console.log(str.format({
+ * num: 12
+ * });
+ *
+ *
+ * Would give the output:
+ *
+ * + * There are 12 objects. + *+ * + * If a property is missing from the parameter block, the replacement + * parameter substring is left untouched in the string, and a different + * set of parameters may be applied a second time. This way, different + * parts of the code may format different parts of the message that they + * happen to know about.
+ * + * Example: + * + *
+ * var str = new IString("There are {num} objects in the {container}.");
+ * console.log(str.format({
+ * num: 12
+ * });
+ *
+ *
+ * Would give the output:+ * + *
+ * There are 12 objects in the {container}.
+ *
+ *
+ * The result can then be formatted again with a different parameter block that
+ * specifies a value for the container property.
+ *
+ * @param params a Javascript object containing values for the replacement
+ * parameters in the current string
+ * @return a new IString instance with as many replacement parameters filled
+ * out as possible with real values.
+ */
+IStringFmt.format = function (params) {
+ var formatted = this.str;
+ if (params) {
+ var regex;
+ for (var p in params) {
+ if (typeof(params[p]) !== 'undefined') {
+ regex = new RegExp("\{"+p+"\}", "g");
+ formatted = formatted.replace(regex, params[p]);
+ }
+ }
+ }
+ return formatted.toString();
+};
+
+/** @private */
+IStringFmt._testChoice = function(index, limit) {
+ var operandValue = {};
+
+ switch (typeof(index)) {
+ case 'number':
+ operandValue = PluralUtils._fncs.calculateNumberDigits(index);
+
+ if (limit.substring(0,2) === "<=") {
+ limit = parseFloat(limit.substring(2));
+ return operandValue.n <= limit;
+ } else if (limit.substring(0,2) === ">=") {
+ limit = parseFloat(limit.substring(2));
+ return operandValue.n >= limit;
+ } else if (limit.charAt(0) === "<") {
+ limit = parseFloat(limit.substring(1));
+ return operandValue.n < limit;
+ } else if (limit.charAt(0) === ">") {
+ limit = parseFloat(limit.substring(1));
+ return operandValue.n > limit;
+ } else {
+ this.locale = this.locale || new Locale(this.localeSpec);
+ switch (limit) {
+ case "zero":
+ case "one":
+ case "two":
+ case "few":
+ case "many":
+ // CLDR locale-dependent number classes
+ var ruleset = ilib.data["plurals_" + this.locale.getLanguage()+ "_" + this.locale.getRegion()] || ilib.data["plurals_" + this.locale.getLanguage()]|| PluralUtils.plurals_default;
+ if (ruleset) {
+ var rule = ruleset[limit];
+ return PluralUtils._fncs.getValue(rule, operandValue);
+ }
+ break;
+ case "":
+ case "other":
+ // matches anything
+ return true;
+ default:
+ var dash = limit.indexOf("-");
+ if (dash !== -1) {
+ // range
+ var start = limit.substring(0, dash);
+ var end = limit.substring(dash+1);
+ return operandValue.n >= parseInt(start, 10) && operandValue.n <= parseInt(end, 10);
+ } else {
+ return operandValue.n === parseInt(limit, 10);
+ }
+ }
+ }
+ break;
+ case 'boolean':
+ return (limit === "true" && index === true) || (limit === "false" && index === false);
+
+ case 'string':
+ var regexp = new RegExp(limit, "i");
+ return regexp.test(index);
+
+ case 'object':
+ throw "syntax error: formatChoice parameter for the argument index cannot be an object";
+ }
+
+ return false;
+};
+
+/** @private */
+IStringFmt._isIntlPluralAvailable = function(locale) {
+ if (typeof (locale.getVariant()) !== 'undefined'){
+ return false;
+ }
+
+ if (typeof(Intl) !== 'undefined' &&
+ typeof(Intl.PluralRules) !== 'undefined' &&
+ typeof(Intl.PluralRules.supportedLocalesOf) !== 'undefined') {
+ if (ilib._getPlatform() === 'nodejs') {
+ var version = process.versions["node"];
+ if (!version) return false;
+ var majorVersion = version.split(".")[0];
+ if (Number(majorVersion) >= 10 && (Intl.PluralRules.supportedLocalesOf(locale.getSpec()).length > 0)) {
+ return true;
+ }
+ return false;
+ } else if (Intl.PluralRules.supportedLocalesOf(locale.getSpec()).length > 0) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+ return false;
+};
+
+/**
+ * Format a string as one of a choice of strings dependent on the value of
+ * a particular argument index or array of indices.+ * + * The syntax of the choice string is as follows. The string contains a + * series of choices separated by a vertical bar character "|". Each choice + * has a value or range of values to match followed by a hash character "#" + * followed by the string to use if the variable matches the criteria.
+ * + * Example string: + * + *
+ * var num = 2;
+ * var str = new IString("0#There are no objects.|1#There is one object.|2#There are {number} objects.");
+ * console.log(str.formatChoice(num, {
+ * number: num
+ * }));
+ *
+ *
+ * Gives the output:
+ *
+ * + * "There are 2 objects." + *+ * + * The strings to format may contain replacement variables that will be formatted + * using the format() method above and the params argument as a source of values + * to use while formatting those variables.
+ * + * If the criterion for a particular choice is empty, that choice will be used + * as the default one for use when none of the other choice's criteria match.
+ * + * Example string: + * + *
+ * var num = 22;
+ * var str = new IString("0#There are no objects.|1#There is one object.|#There are {number} objects.");
+ * console.log(str.formatChoice(num, {
+ * number: num
+ * }));
+ *
+ *
+ * Gives the output:
+ *
+ * + * "There are 22 objects." + *+ * + * If multiple choice patterns can match a given argument index, the first one + * encountered in the string will be used. If no choice patterns match the + * argument index, then the default choice will be used. If there is no default + * choice defined, then this method will return an empty string.
+ * + * Special Syntax
+ * + * For any choice format string, all of the patterns in the string should be + * of a single type: numeric, boolean, or string/regexp. The type of the + * patterns is determined by the type of the argument index parameter.
+ * + * If the argument index is numeric, then some special syntax can be used + * in the patterns to match numeric ranges.
+ * + *
+ * + * The definition of what numbers are included in a class is locale-dependent. + * They are defined in the data file plurals.json. If your string is in a + * different locale than the default for ilib, you should call the setLocale() + * method of the string instance before calling this method.
+ * + * Other Pattern Types
+ * + * If the argument index is a boolean, the string values "true" and "false" + * may appear as the choice patterns.
+ * + * If the argument index is of type string, then the choice patterns may contain + * regular expressions, or static strings as degenerate regexps.
+ * + * Multiple Indexes
+ * + * If you have 2 or more indexes to format into a string, you can pass them as + * an array. When you do that, the patterns to match should be a comma-separate + * list of patterns as per the rules above.
+ * + * Example string: + * + *
+ * var str = new IString("zero,zero#There are no objects on zero pages.|one,one#There is 1 object on 1 page.|other,one#There are {number} objects on 1 page.|#There are {number} objects on {pages} pages.");
+ * var num = 4, pages = 1;
+ * console.log(str.formatChoice([num, pages], {
+ * number: num,
+ * pages: pages
+ * }));
+ *
+ *
+ * Gives the output:+ * + *
+ * "There are 4 objects on 1 page." + *+ * + * Note that when there is a single index, you would typically leave the pattern blank to + * indicate the default choice. When there are multiple indices, sometimes one of the + * patterns has to be the default case when the other is not. Rather than leaving one or + * more of the patterns blank with commas that look out-of-place in the middle of it, you + * can use the word "other" to indicate a match with the default or other choice. The above example + * shows the use of the "other" pattern. That said, you are allowed to leave the pattern + * blank if you so choose. In the example above, the pattern for the third string could + * easily have been written as ",one" instead of "other,one" and the result will be the same. + * + * @param {*|Array.<*>} argIndex The index into the choice array of the current parameter, + * or an array of indices + * @param {Object} params The hash of parameter values that replace the replacement + * variables in the string + * * @param {boolean} useIntlPlural [optional] true if you are willing to use Intl.PluralRules object + * If it is omitted, the default value is true + * @throws "syntax error in choice format pattern: " if there is a syntax error + * @return {string} the formatted string + */ +IStringFmt.formatChoice = function(argIndex, params, useIntlPlural) { + var choices = this.str.split("|"); + var limits = []; + var strings = []; + var limitsArr = []; + var i; + var parts; + var result = undefined; + var defaultCase = ""; + var checkArgsType; + var useIntl = typeof(useIntlPlural) !== 'undefined' ? useIntlPlural : true; + if (this.str.length === 0) { + // nothing to do + return ""; + } + + // first parse all the choices + for (i = 0; i < choices.length; i++) { + parts = choices[i].split("#"); + if (parts.length > 2) { + limits[i] = parts[0]; + parts = parts.shift(); + strings[i] = parts.join("#"); + } else if (parts.length === 2) { + limits[i] = parts[0]; + strings[i] = parts[1]; + } else { + // syntax error + throw "syntax error in choice format pattern: " + choices[i]; + } + } + + var args = (ilib.isArray(argIndex)) ? argIndex : [argIndex]; + + checkArgsType = args.filter(ilib.bind(this, function(item){ + if (typeof(item) !== "number") { + return false; + } + return true; + })); + + if (useIntl && this.intlPlural && (args.length === checkArgsType.length)){ + this.cateArr = []; + for(i = 0; i < args.length;i++) { + var r = this.intlPlural.select(args[i]); + this.cateArr.push(r); + } + if (args.length === 1) { + var idx = limits.indexOf(this.cateArr[0]); + if (idx == -1) { + idx = limits.indexOf(""); + } + result = new this.constructor(strings[idx]); + } else { + if (limits.length === 0) { + defaultCase = new this.constructor(strings[i]); + } else { + this.findOne = false; + + for(i = 0; !this.findOne && i < limits.length; i++){ + limitsArr = (limits[i].indexOf(",") > -1) ? limits[i].split(",") : [limits[i]]; + + if (limitsArr.length > 1 && (limitsArr.length < this.cateArr.length)){ + this.cateArr = this.cateArr.slice(0,limitsArr.length); + } + limitsArr = limitsArr.map(function(item){ + return item.trim(); + }) + limitsArr.filter(ilib.bind(this, function(element, idx, arr){ + if (JSON.stringify(arr) === JSON.stringify(this.cateArr)){ + this.number = i; + this.fineOne = true; + } + })); + } + if (this.number === -1){ + this.number = limits.indexOf(""); + } + result = new this.constructor(strings[this.number]); + } + } + } else { + // then apply the argument index (or indices) + for (i = 0; i < limits.length; i++) { + if (limits[i].length === 0) { + // this is default case + defaultCase = new this.constructor(strings[i]); + } else { + limitsArr = (limits[i].indexOf(",") > -1) ? limits[i].split(",") : [limits[i]]; + + var applicable = true; + for (var j = 0; applicable && j < args.length && j < limitsArr.length; j++) { + applicable = this._testChoice(args[j], limitsArr[j]); + } + + if (applicable) { + result = new this.constructor(strings[i]); + i = limits.length; + } + } + } + } + if (!result) { + result = defaultCase || new this.constructor(""); + } + + result = result.format(params); + + return result.toString(); +}; + +/** + * Set the locale to use when processing choice formats. The locale + * affects how number classes are interpretted. In some cultures, + * the limit "few" maps to "any integer that ends in the digits 2 to 9" and + * in yet others, "few" maps to "any integer that ends in the digits + * 3 or 4". + * @param {Locale|string} locale locale to use when processing choice + * formats with this string + * @param {boolean=} sync [optional] whether to load the locale data synchronously + * or not + * @param {Object=} loadParams [optional] parameters to pass to the loader function + * @param {function(*)=} onLoad [optional] function to call when the loading is done + */ +IStringFmt.setLocale = function (locale, sync, loadParams, onLoad) { + if (typeof(locale) === 'object') { + this.locale = locale; + } else { + this.localeSpec = locale; + this.locale = new Locale(locale); + } + + if (this._isIntlPluralAvailable(this.locale)){ + this.intlPlural = new Intl.PluralRules(this.locale.getSpec()); + } + + PluralUtils.loadPlurals(typeof(sync) !== 'undefined' ? sync : true, this.locale, loadParams, onLoad); +}; + +/** + * Return the locale to use when processing choice formats. The locale + * affects how number classes are interpretted. In some cultures, + * the limit "few" maps to "any integer that ends in the digits 2 to 9" and + * in yet others, "few" maps to "any integer that ends in the digits + * 3 or 4". + * @return {string} localespec to use when processing choice + * formats with this string + */ +IStringFmt.getLocale = function () { + return (this.locale ? this.locale.getSpec() : this.localeSpec) || ilib.getLocale(); +}; + +module.exports = IStringFmt; diff --git a/js/test/root/testSuiteFiles.js b/js/test/root/testSuiteFiles.js index 3f8e90f65..0dbfa7d7b 100644 --- a/js/test/root/testSuiteFiles.js +++ b/js/test/root/testSuiteFiles.js @@ -29,6 +29,7 @@ module.exports.files = [ "testrequire.js", "testresources.js", "testscriptinfo.js", + "teststringfmt.js", "teststrings.js", "testcharsetasync.js", "testcountryasync.js", diff --git a/js/test/root/teststringfmt.js b/js/test/root/teststringfmt.js new file mode 100644 index 000000000..a6b717b05 --- /dev/null +++ b/js/test/root/teststringfmt.js @@ -0,0 +1,228 @@ +/* + * teststringfmt.js - test the formatting methods mixed into IString.prototype + * from IStringFmt.js (format, formatChoice, _testChoice, setLocale, getLocale) + * + * Copyright © 2026, JEDLSoft + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +if (typeof(ilib) === "undefined") { + var ilib = require("../../lib/ilib.js"); +} +if (typeof(Locale) === "undefined") { + var Locale = require("../../lib/Locale.js"); +} +if (typeof(IString) === "undefined") { + var IString = require("../../lib/IString.js"); +} + +module.exports.teststringfmt = { + setUp: function(callback) { + ilib.clearCache(); + callback(); + }, + + // --------- format --------- + + testStringFmtFormatSubstitutesParam: function(test) { + test.expect(1); + var str = new IString("There are {num} objects."); + test.equal(str.format({num: 12}).toString(), "There are 12 objects."); + test.done(); + }, + + testStringFmtFormatLeavesMissingParamUntouched: function(test) { + test.expect(1); + var str = new IString("There are {num} objects in the {container}."); + test.equal(str.format({num: 12}).toString(), "There are 12 objects in the {container}."); + test.done(); + }, + + testStringFmtFormatCanBeAppliedTwice: function(test) { + test.expect(1); + var str = new IString("There are {num} objects in the {container}."); + var partial = new IString(str.format({num: 12})); + var full = partial.format({container: "box"}); + test.equal(full.toString(), "There are 12 objects in the box."); + test.done(); + }, + + testStringFmtFormatNoParams: function(test) { + test.expect(1); + var str = new IString("Nothing to replace."); + test.equal(str.format().toString(), "Nothing to replace."); + test.done(); + }, + + // --------- formatChoice: single index --------- + + testStringFmtFormatChoiceExactMatch: function(test) { + test.expect(1); + var str = new IString("0#There are no objects.|1#There is one object.|2#There are {number} objects."); + test.equal(str.formatChoice(2, {number: 2}, false), "There are 2 objects."); + test.done(); + }, + + testStringFmtFormatChoiceDefaultCase: function(test) { + test.expect(1); + var str = new IString("0#There are no objects.|1#There is one object.|#There are {number} objects."); + test.equal(str.formatChoice(22, {number: 22}, false), "There are 22 objects."); + test.done(); + }, + + testStringFmtFormatChoiceEmptyStringReturnsEmpty: function(test) { + test.expect(1); + var str = new IString(""); + test.equal(str.formatChoice(2, {number: 2}, false), ""); + test.done(); + }, + + testStringFmtFormatChoiceNoMatchNoDefaultReturnsEmpty: function(test) { + test.expect(1); + var str = new IString("0#There are no objects.|1#There is one object."); + test.equal(str.formatChoice(5, {number: 5}, false), ""); + test.done(); + }, + + testStringFmtFormatChoiceThrowsOnSyntaxError: function(test) { + test.expect(1); + var str = new IString("this has no hash separator"); + test.throws(function() { + str.formatChoice(1, {}, false); + }); + test.done(); + }, + + // --------- formatChoice: numeric range syntax --------- + + testStringFmtFormatChoiceGreaterThan: function(test) { + test.expect(1); + var str = new IString(">10#big|#small"); + test.equal(str.formatChoice(11, {}, false), "big"); + test.done(); + }, + + testStringFmtFormatChoiceGreaterThanOrEqual: function(test) { + test.expect(1); + var str = new IString(">=10#big|#small"); + test.equal(str.formatChoice(10, {}, false), "big"); + test.done(); + }, + + testStringFmtFormatChoiceLessThan: function(test) { + test.expect(1); + var str = new IString("<10#small|#big"); + test.equal(str.formatChoice(5, {}, false), "small"); + test.done(); + }, + + testStringFmtFormatChoiceLessThanOrEqual: function(test) { + test.expect(1); + var str = new IString("<=10#small|#big"); + test.equal(str.formatChoice(10, {}, false), "small"); + test.done(); + }, + + testStringFmtFormatChoiceRange: function(test) { + test.expect(1); + var str = new IString("1-5#a few|#many"); + test.equal(str.formatChoice(3, {}, false), "a few"); + test.done(); + }, + + // --------- formatChoice: multiple indices --------- + + testStringFmtFormatChoiceMultipleIndices: function(test) { + test.expect(1); + var str = new IString("1,1#one and one|#other"); + test.equal(str.formatChoice([1, 1], {}, false), "one and one"); + test.done(); + }, + + // --------- formatChoice: boolean and string index types --------- + + testStringFmtFormatChoiceBooleanTrue: function(test) { + test.expect(1); + var str = new IString("true#yes|false#no"); + test.equal(str.formatChoice(true, {}, false), "yes"); + test.done(); + }, + + testStringFmtFormatChoiceBooleanFalse: function(test) { + test.expect(1); + var str = new IString("true#yes|false#no"); + test.equal(str.formatChoice(false, {}, false), "no"); + test.done(); + }, + + testStringFmtFormatChoiceStringRegexp: function(test) { + test.expect(1); + var str = new IString("^a.*#starts with a|#other"); + test.equal(str.formatChoice("apple", {}, false), "starts with a"); + test.done(); + }, + + // --------- formatChoice: locale-dependent plural classes --------- + + testStringFmtFormatChoicePluralClassOne: function(test) { + test.expect(1); + var str = new IString("one#singular|other#plural"); + str.setLocale("en-US", true); + test.equal(str.formatChoice(1, {}, false), "singular"); + test.done(); + }, + + testStringFmtFormatChoicePluralClassOther: function(test) { + test.expect(1); + var str = new IString("one#singular|other#plural"); + str.setLocale("en-US", true); + test.equal(str.formatChoice(5, {}, false), "plural"); + test.done(); + }, + + // --------- setLocale / getLocale --------- + + testStringFmtGetLocaleDefaultsToIlibLocale: function(test) { + test.expect(1); + var str = new IString("test"); + test.equal(str.getLocale(), ilib.getLocale()); + test.done(); + }, + + testStringFmtSetLocaleWithString: function(test) { + test.expect(1); + var str = new IString("test"); + str.setLocale("ko-KR", true); + test.equal(str.getLocale(), "ko-KR"); + test.done(); + }, + + testStringFmtSetLocaleWithLocaleObject: function(test) { + test.expect(1); + var str = new IString("test"); + str.setLocale(new Locale("ja-JP"), true); + test.equal(str.getLocale(), "ja-JP"); + test.done(); + }, + + testStringFmtSetLocaleThenFormatChoiceUsesNewLocale: function(test) { + test.expect(1); + var str = new IString("one#singular|other#plural"); + str.setLocale("ko-KR", true); + // Korean has no plural distinction, so any number maps to "other" + test.equal(str.formatChoice(1, {}, false), "plural"); + test.done(); + } +};