From 8862cf549466507edee38ab50ea0e5254de2307f Mon Sep 17 00:00:00 2001 From: duonglaiquang Date: Thu, 12 Mar 2026 15:23:19 +0900 Subject: [PATCH 1/4] Locale: implement Intl.Locale --- .../htmlunit/javascript/host/intl/Intl.java | 1 + .../htmlunit/javascript/host/intl/Locale.java | 326 ++++++++++++++++++ .../javascript/host/intl/IntlTest.java | 10 + .../javascript/host/intl/LocaleTest.java | 270 +++++++++++++++ 4 files changed, 607 insertions(+) create mode 100644 src/main/java/org/htmlunit/javascript/host/intl/Locale.java create mode 100644 src/test/java/org/htmlunit/javascript/host/intl/LocaleTest.java diff --git a/src/main/java/org/htmlunit/javascript/host/intl/Intl.java b/src/main/java/org/htmlunit/javascript/host/intl/Intl.java index 72964eb813..52768c7eae 100644 --- a/src/main/java/org/htmlunit/javascript/host/intl/Intl.java +++ b/src/main/java/org/htmlunit/javascript/host/intl/Intl.java @@ -40,6 +40,7 @@ public class Intl extends HtmlUnitScriptable { public void defineProperties(final Scriptable scope, final BrowserVersion browserVersion) { define(scope, Collator.class, browserVersion); define(scope, DateTimeFormat.class, browserVersion); + define(scope, Locale.class, browserVersion); define(scope, NumberFormat.class, browserVersion); if (browserVersion.hasFeature(JS_INTL_V8_BREAK_ITERATOR)) { define(scope, V8BreakIterator.class, browserVersion); diff --git a/src/main/java/org/htmlunit/javascript/host/intl/Locale.java b/src/main/java/org/htmlunit/javascript/host/intl/Locale.java new file mode 100644 index 0000000000..d3c414b320 --- /dev/null +++ b/src/main/java/org/htmlunit/javascript/host/intl/Locale.java @@ -0,0 +1,326 @@ +/* + * Copyright (c) 2002-2026 Gargoyle Software Inc. + * + * 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 + * https://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. + */ +package org.htmlunit.javascript.host.intl; + +import java.util.IllformedLocaleException; +import java.util.List; + +import org.apache.commons.lang3.LocaleUtils; +import org.htmlunit.corejs.javascript.Context; +import org.htmlunit.corejs.javascript.Function; +import org.htmlunit.corejs.javascript.FunctionObject; +import org.htmlunit.corejs.javascript.Scriptable; +import org.htmlunit.corejs.javascript.ScriptableObject; +import org.htmlunit.javascript.HtmlUnitScriptable; +import org.htmlunit.javascript.JavaScriptEngine; +import org.htmlunit.javascript.configuration.JsxClass; +import org.htmlunit.javascript.configuration.JsxConstructor; +import org.htmlunit.javascript.configuration.JsxFunction; +import org.htmlunit.javascript.configuration.JsxGetter; + +/** + * A JavaScript object for {@code Intl.Locale}. + * + * @author Lai Quang Duong + */ +@JsxClass +public class Locale extends HtmlUnitScriptable { + + private static final List ALLOWED_HOUR_CYCLES = List.of("h11", "h12", "h23", "h24"); + private static final List ALLOWED_CASE_FIRSTS = List.of("upper", "lower", "false"); + + private java.util.Locale locale_; + private String language_; + private String script_; + private String region_; + private String calendar_; + private String collation_; + private String numberingSystem_; + private String caseFirst_; + private String hourCycle_; + private boolean numeric_; + + /** + * Default constructor. + */ + public Locale() { + super(); + } + + private Locale(final java.util.Locale locale) { + super(); + locale_ = locale; + language_ = locale.getLanguage(); + if (!locale.getScript().isEmpty()) { + script_ = locale.getScript(); + } + if (!locale.getCountry().isEmpty()) { + region_ = locale.getCountry(); + } + if (locale.hasExtensions()) { + calendar_ = locale.getUnicodeLocaleType("ca"); + collation_ = locale.getUnicodeLocaleType("co"); + numberingSystem_ = locale.getUnicodeLocaleType("nu"); + caseFirst_ = locale.getUnicodeLocaleType("kf"); + hourCycle_ = locale.getUnicodeLocaleType("hc"); + numeric_ = Boolean.parseBoolean(locale.getUnicodeLocaleType("kn")); + } + } + + /** + * JavaScript constructor. + * @param cx the current context + * @param scope the scope + * @param args the arguments + * @param ctorObj the constructor function + * @param inNewExpr whether called via new + * @return the new Locale instance + */ + @JsxConstructor + public static Scriptable jsConstructor(final Context cx, final Scriptable scope, + final Object[] args, final Function ctorObj, final boolean inNewExpr) { + if (args.length == 0 || JavaScriptEngine.isUndefined(args[0])) { + throw JavaScriptEngine.typeError("Invalid element in locales argument"); + } + + final String languageTag; + if (args[0] instanceof Locale loc) { + languageTag = loc.toString(); + } + else { + languageTag = JavaScriptEngine.toString(args[0]); + } + if (languageTag.isEmpty()) { + throw JavaScriptEngine.rangeError("Invalid language tag: "); + } + + java.util.Locale locale; + try { + locale = new java.util.Locale.Builder() + .setLanguageTag(languageTag) + .build(); + } + catch (final IllformedLocaleException e) { + throw JavaScriptEngine.rangeError("Invalid language tag: " + languageTag); + } + + // Override by options if present + if (args.length > 1 && !JavaScriptEngine.isUndefined(args[1])) { + locale = overrideExistingWithOptions(locale, + ScriptableObject.ensureScriptableObject(args[1])); + } + + final Locale l = new Locale(locale); + l.setParentScope(getTopLevelScope(ctorObj)); + l.setPrototype(((FunctionObject) ctorObj).getClassPrototype()); + return l; + } + + private static java.util.Locale overrideExistingWithOptions( + final java.util.Locale existing, final ScriptableObject options) { + final java.util.Locale.Builder builder = new java.util.Locale.Builder().setLocale(existing); + + setStringOption(builder, options, "language"); + setStringOption(builder, options, "script"); + setStringOption(builder, options, "region"); + setUnicodeKeyword(builder, options, "calendar", "ca", null); + setUnicodeKeyword(builder, options, "collation", "co", null); + setUnicodeKeyword(builder, options, "numberingSystem", "nu", null); + setUnicodeKeyword(builder, options, "caseFirst", "kf", ALLOWED_CASE_FIRSTS); + setUnicodeKeyword(builder, options, "hourCycle", "hc", ALLOWED_HOUR_CYCLES); + + final Object numeric = ScriptableObject.getProperty(options, "numeric"); + if (numeric != Scriptable.NOT_FOUND && !JavaScriptEngine.isUndefined(numeric)) { + final boolean isNumeric = numeric instanceof Boolean ? (Boolean) numeric : true; + builder.setUnicodeLocaleKeyword("kn", Boolean.toString(isNumeric)); + } + + return builder.build(); + } + + private static void setStringOption(final java.util.Locale.Builder builder, + final ScriptableObject options, final String optionName) { + final Object value = ScriptableObject.getProperty(options, optionName); + if (value == Scriptable.NOT_FOUND || JavaScriptEngine.isUndefined(value)) { + return; + } + try { + final String s = JavaScriptEngine.toString(value); + switch (optionName) { + case "language": + builder.setLanguage(s); + break; + case "script": + builder.setScript(s); + break; + case "region": + builder.setRegion(s); + break; + default: + break; + } + } + catch (final Exception e) { + throw JavaScriptEngine.rangeError("Invalid value for option \"" + optionName + "\""); + } + } + + private static void setUnicodeKeyword(final java.util.Locale.Builder builder, + final ScriptableObject options, final String optionName, final String unicodeKey, + final List allowedValues) { + final Object value = ScriptableObject.getProperty(options, optionName); + if (value == Scriptable.NOT_FOUND || JavaScriptEngine.isUndefined(value)) { + return; + } + final String s; + try { + s = JavaScriptEngine.toString(value); + } + catch (final Exception e) { + throw JavaScriptEngine.rangeError("Invalid value for option \"" + optionName + "\""); + } + if (allowedValues != null && !allowedValues.contains(s)) { + throw JavaScriptEngine.rangeError("Invalid value for option \"" + optionName + "\""); + } + builder.setUnicodeLocaleKeyword(unicodeKey, s); + } + + /** @return the language */ + @JsxGetter + public Object getLanguage() { + return language_ != null ? language_ : JavaScriptEngine.UNDEFINED; + } + + /** @return the script */ + @JsxGetter + public Object getScript() { + return script_ != null ? script_ : JavaScriptEngine.UNDEFINED; + } + + /** @return the region */ + @JsxGetter + public Object getRegion() { + return region_ != null ? region_ : JavaScriptEngine.UNDEFINED; + } + + /** @return the calendar type */ + @JsxGetter + public Object getCalendar() { + return calendar_ != null ? calendar_ : JavaScriptEngine.UNDEFINED; + } + + /** @return the collation type */ + @JsxGetter + public Object getCollation() { + return collation_ != null ? collation_ : JavaScriptEngine.UNDEFINED; + } + + /** @return the numbering system */ + @JsxGetter + public Object getNumberingSystem() { + return numberingSystem_ != null ? numberingSystem_ : JavaScriptEngine.UNDEFINED; + } + + /** @return the case first setting */ + @JsxGetter + public Object getCaseFirst() { + return caseFirst_ != null ? caseFirst_ : JavaScriptEngine.UNDEFINED; + } + + /** @return the hour cycle */ + @JsxGetter + public Object getHourCycle() { + return hourCycle_ != null ? hourCycle_ : JavaScriptEngine.UNDEFINED; + } + + /** @return whether numeric sorting is used */ + @JsxGetter + public boolean isNumeric() { + return numeric_; + } + + /** @return the base name (without Unicode extensions) */ + @JsxGetter + public Object getBaseName() { + final String variant = locale_.getVariant().replace("_", "-"); + return language_ + + (script_ != null ? "-" + script_ : "") + + (region_ != null ? "-" + region_ : "") + + (!variant.isEmpty() ? "-" + variant : ""); + } + + /** + * Returns a Locale with maximized subtags. + * @return a new Locale instance with maximized subtags + */ + @JsxFunction + public Object maximize() { + final String region; + if (region_ != null) { + region = region_; + } + else { + final java.util.List locales = + LocaleUtils.countriesByLanguage(language_); + if (!locales.isEmpty()) { + region = locales.get(0).getCountry(); + } + else { + region = null; + } + } + + final java.util.Locale locale = new java.util.Locale.Builder() + .setLanguage(language_) + .setScript(script_) + .setRegion(region) + .setExtension('u', locale_.getExtension('u')) + .build(); + + final Locale l = new Locale(locale); + l.setParentScope(getWindow(this)); + l.setPrototype(this.getPrototype()); + return l; + } + + /** + * Returns a Locale with minimized subtags. + * @return a new Locale instance with minimized subtags + */ + @JsxFunction + public Object minimize() { + final java.util.Locale locale = new java.util.Locale.Builder() + .setLanguage(language_) + .setExtension('u', locale_.getExtension('u')) + .build(); + + final Locale l = new Locale(locale); + l.setParentScope(getWindow(this)); + l.setPrototype(this.getPrototype()); + return l; + } + + /** + * @return the locale's Unicode locale identifier string + */ + @Override + @JsxFunction + public String toString() { + if (locale_ == null) { + return super.toString(); + } + return locale_.toLanguageTag(); + } +} diff --git a/src/test/java/org/htmlunit/javascript/host/intl/IntlTest.java b/src/test/java/org/htmlunit/javascript/host/intl/IntlTest.java index 67564be292..9ca1320703 100644 --- a/src/test/java/org/htmlunit/javascript/host/intl/IntlTest.java +++ b/src/test/java/org/htmlunit/javascript/host/intl/IntlTest.java @@ -23,6 +23,7 @@ * * @author Ahmed Ashour * @author Ronald Brill + * @author Lai Quang Duong */ public class IntlTest extends WebDriverTestCase { @@ -79,6 +80,15 @@ public void numberFormat() throws Exception { test("Intl.NumberFormat"); } + /** + * @throws Exception if the test fails + */ + @Test + @Alerts("function Locale() { [native code] }") + public void locale() throws Exception { + test("Intl.Locale"); + } + /** * @throws Exception if the test fails */ diff --git a/src/test/java/org/htmlunit/javascript/host/intl/LocaleTest.java b/src/test/java/org/htmlunit/javascript/host/intl/LocaleTest.java new file mode 100644 index 0000000000..78fb36b486 --- /dev/null +++ b/src/test/java/org/htmlunit/javascript/host/intl/LocaleTest.java @@ -0,0 +1,270 @@ +/* + * Copyright (c) 2002-2026 Gargoyle Software Inc. + * + * 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 + * https://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. + */ +package org.htmlunit.javascript.host.intl; + +import org.htmlunit.WebDriverTestCase; +import org.htmlunit.junit.annotation.Alerts; +import org.htmlunit.junit.annotation.HtmlUnitNYI; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link Locale}. + * + * @author Lai Quang Duong + */ +public class LocaleTest extends WebDriverTestCase { + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"en", "undefined", "undefined", "en", "en", + "zh", "undefined", "Hant", "zh-Hant", "zh-Hant", + "zh", "TW", "Hant", "zh-Hant-TW", "zh-Hant-TW", + "ja", "JP", "undefined", "ja-JP", "ja-JP", + "en", "US", "undefined", "en-US", "en-US", + "ja-JP"}) + public void construction() throws Exception { + final String html = DOCTYPE_HTML + + "\n" + + "\n" + + "\n" + + ""; + + loadPageVerifyTitle2(html); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"TypeError", "RangeError", "RangeError", "RangeError", + "invalid", "en-US", "RangeError", "RangeError"}) + public void constructorErrors() throws Exception { + final String html = DOCTYPE_HTML + + "\n" + + "\n" + + "\n" + + ""; + + loadPageVerifyTitle2(html); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"undefined", "undefined", "undefined", "undefined", "undefined", "false", + "buddhist", "emoji", "latn", "h23", "lower", "true", + "ja", "JP", "Jpan", "japanese", "h12", "ja-Jpan-JP", + "de", "phonebk", "de-u-co-phonebk", + "th", "TH", "thai", "th-TH-u-nu-thai", + "en", "upper", "en-u-kf-upper"}) + public void extensions() throws Exception { + final String html = DOCTYPE_HTML + + "\n" + + "\n" + + "\n" + + ""; + + loadPageVerifyTitle2(html); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"en", "true", "en-u-kn", + "false", "en-u-kn-false", + "false", "en-u-kn-false"}) + @HtmlUnitNYI(CHROME = {"en", "true", "en-u-kn-true", + "false", "en-u-kn-false", + "false", "en-u-kn-false"}, + EDGE = {"en", "true", "en-u-kn-true", + "false", "en-u-kn-false", + "false", "en-u-kn-false"}, + FF = {"en", "true", "en-u-kn-true", + "false", "en-u-kn-false", + "false", "en-u-kn-false"}, + FF_ESR = {"en", "true", "en-u-kn-true", + "false", "en-u-kn-false", + "false", "en-u-kn-false"}) + public void numeric() throws Exception { + final String html = DOCTYPE_HTML + + "\n" + + "\n" + + "\n" + + ""; + + loadPageVerifyTitle2(html); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"en", "RU", "Cyrl", "buddhist", "zhuyin", "thai", "h11", "true", + "en", "CA", "en-CA", + "en", "GB", "en-GB", + "islamic", "en-US-u-ca-islamic", + "buddhist", "en-US-u-ca-buddhist"}) + public void optionsOverride() throws Exception { + final String html = DOCTYPE_HTML + + "\n" + + "\n" + + "\n" + + ""; + + loadPageVerifyTitle2(html); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"RangeError", "RangeError", "RangeError", "RangeError", "RangeError"}) + public void invalidOptions() throws Exception { + final String html = DOCTYPE_HTML + + "\n" + + "\n" + + "\n" + + ""; + + loadPageVerifyTitle2(html); + } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"ja-Jpan-JP-u-ca-japanese-hc-h12", + "ja-u-ca-japanese-hc-h12", + "ja-Jpan-JP-u-ca-japanese-hc-h12", + "ja-Jpan-JP"}) + @HtmlUnitNYI(CHROME = {"ja-Jpan-JP-u-ca-japanese-hc-h12", + "ja-u-ca-japanese-hc-h12", + "ja-JP-u-ca-japanese-hc-h12", + "ja-Jpan-JP"}, + EDGE = {"ja-Jpan-JP-u-ca-japanese-hc-h12", + "ja-u-ca-japanese-hc-h12", + "ja-JP-u-ca-japanese-hc-h12", + "ja-Jpan-JP"}, + FF = {"ja-Jpan-JP-u-ca-japanese-hc-h12", + "ja-u-ca-japanese-hc-h12", + "ja-JP-u-ca-japanese-hc-h12", + "ja-Jpan-JP"}, + FF_ESR = {"ja-Jpan-JP-u-ca-japanese-hc-h12", + "ja-u-ca-japanese-hc-h12", + "ja-JP-u-ca-japanese-hc-h12", + "ja-Jpan-JP"}) + public void minimizeMaximize() throws Exception { + final String html = DOCTYPE_HTML + + "\n" + + "\n" + + "\n" + + ""; + + loadPageVerifyTitle2(html); + } +} From ab0ae78ed86ec77e7ded47b3438be37a545ad863 Mon Sep 17 00:00:00 2001 From: duonglaiquang Date: Thu, 12 Mar 2026 15:00:19 +0900 Subject: [PATCH 2/4] Intl: refactor initialization into static init() --- .../htmlunit/javascript/JavaScriptEngine.java | 5 +- .../htmlunit/javascript/host/intl/Intl.java | 48 ++++++++++++++++--- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/htmlunit/javascript/JavaScriptEngine.java b/src/main/java/org/htmlunit/javascript/JavaScriptEngine.java index 0046f93c57..a95e54480c 100644 --- a/src/main/java/org/htmlunit/javascript/JavaScriptEngine.java +++ b/src/main/java/org/htmlunit/javascript/JavaScriptEngine.java @@ -454,10 +454,7 @@ public static void configureRhino(final WebClient webClient, final BrowserVersio } // add Intl - final Intl intl = new Intl(); - intl.setParentScope(scope); - globalThis.defineProperty(intl.getClassName(), intl, ScriptableObject.DONTENUM); - intl.defineProperties(scope, browserVersion); + Intl.init(scope, globalThis, browserVersion); } /** diff --git a/src/main/java/org/htmlunit/javascript/host/intl/Intl.java b/src/main/java/org/htmlunit/javascript/host/intl/Intl.java index 52768c7eae..f29f9a5baf 100644 --- a/src/main/java/org/htmlunit/javascript/host/intl/Intl.java +++ b/src/main/java/org/htmlunit/javascript/host/intl/Intl.java @@ -16,6 +16,9 @@ import static org.htmlunit.BrowserVersionFeatures.JS_INTL_V8_BREAK_ITERATOR; +import java.lang.reflect.Method; +import java.util.Map; + import org.htmlunit.BrowserVersion; import org.htmlunit.corejs.javascript.FunctionObject; import org.htmlunit.corejs.javascript.Scriptable; @@ -24,20 +27,39 @@ import org.htmlunit.javascript.JavaScriptEngine; import org.htmlunit.javascript.configuration.AbstractJavaScriptConfiguration; import org.htmlunit.javascript.configuration.ClassConfiguration; +import org.htmlunit.javascript.configuration.JsxClass; /** * A JavaScript object for {@code Intl}. * * @author Ahmed Ashour + * @author Lai Quang Duong */ +@JsxClass public class Intl extends HtmlUnitScriptable { /** - * Define needed properties. - * @param scope the scope + * Initialize the Intl object and register it on the global scope. + * @param scope the top-level scope + * @param globalThis the global object * @param browserVersion the browser version */ - public void defineProperties(final Scriptable scope, final BrowserVersion browserVersion) { + public static void init(final Scriptable scope, final ScriptableObject globalThis, + final BrowserVersion browserVersion) { + final Intl intl = new Intl(); + intl.setParentScope(scope); + intl.defineProperties(scope, browserVersion); + + // Configure static functions + final ClassConfiguration intlConfig = AbstractJavaScriptConfiguration.getClassConfiguration(Intl.class, browserVersion); + if (intlConfig != null) { + defineStaticFunctions(intlConfig, intl, intl); + } + + globalThis.defineProperty(intl.getClassName(), intl, ScriptableObject.DONTENUM); + } + + private void defineProperties(final Scriptable scope, final BrowserVersion browserVersion) { define(scope, Collator.class, browserVersion); define(scope, DateTimeFormat.class, browserVersion); define(scope, Locale.class, browserVersion); @@ -52,13 +74,25 @@ private void define(final Scriptable scope, final Class staticFunctionMap = config.getStaticFunctionMap(); + if (staticFunctionMap != null) { + for (final Map.Entry entry : staticFunctionMap.entrySet()) { + final FunctionObject fn = new FunctionObject(entry.getKey(), entry.getValue(), scope); + target.defineProperty(entry.getKey(), fn, ScriptableObject.EMPTY); + } + } + } } From f9dcd8a7089c5be4db7efa18d65b9df35ddc9f25 Mon Sep 17 00:00:00 2001 From: duonglaiquang Date: Thu, 12 Mar 2026 15:26:43 +0900 Subject: [PATCH 3/4] Intl: implement getCanonicalLocales() --- .../htmlunit/javascript/host/intl/Intl.java | 78 +++++++++++++++++++ .../javascript/host/intl/IntlTest.java | 35 +++++++++ 2 files changed, 113 insertions(+) diff --git a/src/main/java/org/htmlunit/javascript/host/intl/Intl.java b/src/main/java/org/htmlunit/javascript/host/intl/Intl.java index f29f9a5baf..f69f8369bf 100644 --- a/src/main/java/org/htmlunit/javascript/host/intl/Intl.java +++ b/src/main/java/org/htmlunit/javascript/host/intl/Intl.java @@ -17,17 +17,27 @@ import static org.htmlunit.BrowserVersionFeatures.JS_INTL_V8_BREAK_ITERATOR; import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.IllformedLocaleException; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; +import java.util.Set; import org.htmlunit.BrowserVersion; +import org.htmlunit.corejs.javascript.Context; +import org.htmlunit.corejs.javascript.Function; import org.htmlunit.corejs.javascript.FunctionObject; +import org.htmlunit.corejs.javascript.NativeArray; import org.htmlunit.corejs.javascript.Scriptable; import org.htmlunit.corejs.javascript.ScriptableObject; +import org.htmlunit.corejs.javascript.TopLevel; import org.htmlunit.javascript.HtmlUnitScriptable; import org.htmlunit.javascript.JavaScriptEngine; import org.htmlunit.javascript.configuration.AbstractJavaScriptConfiguration; import org.htmlunit.javascript.configuration.ClassConfiguration; import org.htmlunit.javascript.configuration.JsxClass; +import org.htmlunit.javascript.configuration.JsxStaticFunction; /** * A JavaScript object for {@code Intl}. @@ -95,4 +105,72 @@ private static void defineStaticFunctions(final ClassConfiguration config, } } } + + /** + * Returns an array containing the canonical locale names. + * Duplicates will be omitted and elements will be validated as structurally valid language tags. + * + * @param cx the current context + * @param thisObj the scriptable this + * @param args the arguments + * @param funObj the function object + * @return an array of canonical locale names + * + * @see spec + */ + @JsxStaticFunction + public static Object getCanonicalLocales(final Context cx, final Scriptable thisObj, + final Object[] args, final Function funObj) { + if (args.length == 0 || JavaScriptEngine.isUndefined(args[0])) { + return cx.newArray(TopLevel.getTopLevelScope(thisObj), new Object[0]); + } + + final Object localesArgument = args[0]; + if (localesArgument == null) { + throw JavaScriptEngine.typeError("Cannot convert null to object"); + } + + final List languageTags = new ArrayList<>(); + if (localesArgument instanceof String s) { + languageTags.add(s); + } + else if (localesArgument instanceof Scriptable scriptable) { + if ("String".equals(scriptable.getClassName()) || scriptable instanceof Locale) { + languageTags.add(scriptable.toString()); + } + else if (scriptable instanceof NativeArray array) { + for (int i = 0; i < array.getLength(); i++) { + final Object elem = array.get(i); + if (elem instanceof String s) { + languageTags.add(s); + } + else if (elem instanceof Locale) { + languageTags.add(elem.toString()); + } + else if (elem instanceof ScriptableObject) { + languageTags.add(JavaScriptEngine.toString(elem)); + } + else { + throw JavaScriptEngine.typeError("Invalid element in locales argument"); + } + } + } + else { + languageTags.add(JavaScriptEngine.toString(localesArgument)); + } + } + + final Set canonicalLocales = new LinkedHashSet<>(languageTags.size()); + for (final String tag : languageTags) { + try { + canonicalLocales.add( + new java.util.Locale.Builder().setLanguageTag(tag).build().toLanguageTag()); + } + catch (final IllformedLocaleException e) { + throw JavaScriptEngine.rangeError("Invalid language tag: " + tag); + } + } + + return cx.newArray(TopLevel.getTopLevelScope(thisObj), canonicalLocales.toArray()); + } } diff --git a/src/test/java/org/htmlunit/javascript/host/intl/IntlTest.java b/src/test/java/org/htmlunit/javascript/host/intl/IntlTest.java index 9ca1320703..b521f96c35 100644 --- a/src/test/java/org/htmlunit/javascript/host/intl/IntlTest.java +++ b/src/test/java/org/htmlunit/javascript/host/intl/IntlTest.java @@ -100,4 +100,39 @@ public void v8BreakIterator() throws Exception { test("Intl.v8BreakIterator"); } + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"en-US", "en-US,fr-FR,ja-JP", + "zh-Hant-TW", "en-Latn-US", + "ja-JP", "en-US,fr", "en-US", "ja-JP", + "TypeError", "", "", "RangeError", "RangeError", ""}) + public void getCanonicalLocales() throws Exception { + final String html = DOCTYPE_HTML + + "\n" + + "\n" + + "\n" + + ""; + + loadPageVerifyTitle2(html); + } } From ec940751c67339d4cfe9346c87948bcf94af3189 Mon Sep 17 00:00:00 2001 From: duonglaiquang Date: Thu, 12 Mar 2026 15:20:13 +0900 Subject: [PATCH 4/4] Intl: add supportedLocalesOf() --- .../javascript/host/intl/DateTimeFormat.java | 13 +++++++ .../htmlunit/javascript/host/intl/Intl.java | 35 ++++++++++++++++++- .../javascript/host/intl/NumberFormat.java | 13 +++++++ .../host/intl/DateTimeFormatTest.java | 23 ++++++++++++ .../host/intl/NumberFormatTest.java | 23 ++++++++++++ 5 files changed, 106 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/htmlunit/javascript/host/intl/DateTimeFormat.java b/src/main/java/org/htmlunit/javascript/host/intl/DateTimeFormat.java index c6ea1cd226..83b258e47e 100644 --- a/src/main/java/org/htmlunit/javascript/host/intl/DateTimeFormat.java +++ b/src/main/java/org/htmlunit/javascript/host/intl/DateTimeFormat.java @@ -37,6 +37,7 @@ import org.htmlunit.javascript.configuration.JsxClass; import org.htmlunit.javascript.configuration.JsxConstructor; import org.htmlunit.javascript.configuration.JsxFunction; +import org.htmlunit.javascript.configuration.JsxStaticFunction; import org.htmlunit.javascript.host.Window; import org.htmlunit.util.StringUtils; @@ -305,6 +306,18 @@ public Scriptable resolvedOptions() { return options; } + /** + * Returns an array containing those of the provided locales that are supported + * without having to fall back to the default locale. + * @param localesArgument A string with a BCP 47 language tag, or an array of such strings + * @param options unused + * @return an array containing supported locales + */ + @JsxStaticFunction + public static Scriptable supportedLocalesOf(final Scriptable localesArgument, final Scriptable options) { + return Intl.supportedLocalesOf(localesArgument); + } + /** * Helper. */ diff --git a/src/main/java/org/htmlunit/javascript/host/intl/Intl.java b/src/main/java/org/htmlunit/javascript/host/intl/Intl.java index f69f8369bf..f9b286efce 100644 --- a/src/main/java/org/htmlunit/javascript/host/intl/Intl.java +++ b/src/main/java/org/htmlunit/javascript/host/intl/Intl.java @@ -24,6 +24,7 @@ import java.util.Map; import java.util.Set; +import org.apache.commons.lang3.LocaleUtils; import org.htmlunit.BrowserVersion; import org.htmlunit.corejs.javascript.Context; import org.htmlunit.corejs.javascript.Function; @@ -60,7 +61,7 @@ public static void init(final Scriptable scope, final ScriptableObject globalThi intl.setParentScope(scope); intl.defineProperties(scope, browserVersion); - // Configure static functions + // Configure static functions (getCanonicalLocales) final ClassConfiguration intlConfig = AbstractJavaScriptConfiguration.getClassConfiguration(Intl.class, browserVersion); if (intlConfig != null) { defineStaticFunctions(intlConfig, intl, intl); @@ -173,4 +174,36 @@ else if (elem instanceof ScriptableObject) { return cx.newArray(TopLevel.getTopLevelScope(thisObj), canonicalLocales.toArray()); } + + /** + * Shared utility for {@code supportedLocalesOf} implementations. + * @param localesArgument the locales argument + * @return a Scriptable array of supported locale strings + */ + static Scriptable supportedLocalesOf(final Scriptable localesArgument) { + final String[] locales; + if (localesArgument instanceof NativeArray array) { + locales = new String[(int) array.getLength()]; + for (int i = 0; i < locales.length; i++) { + locales[i] = JavaScriptEngine.toString(array.get(i)); + } + } + else { + locales = new String[] {JavaScriptEngine.toString(localesArgument)}; + } + + final List supportedLocales = new ArrayList<>(); + for (final String locale : locales) { + if (locale.isEmpty()) { + throw JavaScriptEngine.rangeError("Invalid language tag: " + locale); + } + final java.util.Locale l = java.util.Locale.forLanguageTag(locale); + if (LocaleUtils.isAvailableLocale(l)) { + supportedLocales.add(locale); + } + } + + return Context.getCurrentContext().newArray( + TopLevel.getTopLevelScope(localesArgument), supportedLocales.toArray()); + } } diff --git a/src/main/java/org/htmlunit/javascript/host/intl/NumberFormat.java b/src/main/java/org/htmlunit/javascript/host/intl/NumberFormat.java index c1a3129fe2..039a329d7d 100644 --- a/src/main/java/org/htmlunit/javascript/host/intl/NumberFormat.java +++ b/src/main/java/org/htmlunit/javascript/host/intl/NumberFormat.java @@ -32,6 +32,7 @@ import org.htmlunit.javascript.configuration.JsxClass; import org.htmlunit.javascript.configuration.JsxConstructor; import org.htmlunit.javascript.configuration.JsxFunction; +import org.htmlunit.javascript.configuration.JsxStaticFunction; import org.htmlunit.javascript.host.Window; import org.htmlunit.util.StringUtils; @@ -209,6 +210,18 @@ public Scriptable resolvedOptions() { return JavaScriptEngine.newObject(getParentScope()); } + /** + * Returns an array containing those of the provided locales that are supported + * without having to fall back to the default locale. + * @param localesArgument A string with a BCP 47 language tag, or an array of such strings + * @param options unused + * @return an array containing supported locales + */ + @JsxStaticFunction + public static Scriptable supportedLocalesOf(final Scriptable localesArgument, final Scriptable options) { + return Intl.supportedLocalesOf(localesArgument); + } + /** * Helper. */ diff --git a/src/test/java/org/htmlunit/javascript/host/intl/DateTimeFormatTest.java b/src/test/java/org/htmlunit/javascript/host/intl/DateTimeFormatTest.java index 78467d2df5..abc2e50627 100644 --- a/src/test/java/org/htmlunit/javascript/host/intl/DateTimeFormatTest.java +++ b/src/test/java/org/htmlunit/javascript/host/intl/DateTimeFormatTest.java @@ -434,4 +434,27 @@ private void locale(final String language) throws Exception { shutDownAll(); } } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"en", "en,ja", ""}) + public void supportedLocalesOf() throws Exception { + final String html = DOCTYPE_HTML + + "\n" + + "\n" + + "\n" + + "\n" + + ""; + + loadPageVerifyTitle2(html); + } } diff --git a/src/test/java/org/htmlunit/javascript/host/intl/NumberFormatTest.java b/src/test/java/org/htmlunit/javascript/host/intl/NumberFormatTest.java index 5d659bcab8..88ee0e674e 100644 --- a/src/test/java/org/htmlunit/javascript/host/intl/NumberFormatTest.java +++ b/src/test/java/org/htmlunit/javascript/host/intl/NumberFormatTest.java @@ -111,4 +111,27 @@ public void numberFormat() throws Exception { loadPageVerifyTitle2(html); } + + /** + * @throws Exception if the test fails + */ + @Test + @Alerts({"en", "en,ja", ""}) + public void supportedLocalesOf() throws Exception { + final String html = DOCTYPE_HTML + + "\n" + + "\n" + + "\n" + + "\n" + + ""; + + loadPageVerifyTitle2(html); + } }