summaryrefslogtreecommitdiffstats
path: root/dom/l10n
diff options
context:
space:
mode:
Diffstat (limited to 'dom/l10n')
-rw-r--r--dom/l10n/DOMLocalization.cpp672
-rw-r--r--dom/l10n/DOMLocalization.h137
-rw-r--r--dom/l10n/DocumentL10n.cpp347
-rw-r--r--dom/l10n/DocumentL10n.h88
-rw-r--r--dom/l10n/L10nMutations.cpp362
-rw-r--r--dom/l10n/L10nMutations.h101
-rw-r--r--dom/l10n/L10nOverlays.cpp557
-rw-r--r--dom/l10n/L10nOverlays.h111
-rw-r--r--dom/l10n/components.conf14
-rw-r--r--dom/l10n/moz.build35
-rw-r--r--dom/l10n/tests/gtest/TestL10nOverlays.cpp77
-rw-r--r--dom/l10n/tests/gtest/moz.build11
-rw-r--r--dom/l10n/tests/mochitest/browser.ini6
-rw-r--r--dom/l10n/tests/mochitest/chrome.ini47
-rw-r--r--dom/l10n/tests/mochitest/document_l10n/README.txt3
-rw-r--r--dom/l10n/tests/mochitest/document_l10n/non-system-principal/README.txt3
-rw-r--r--dom/l10n/tests/mochitest/document_l10n/non-system-principal/browser_resource_uri.js109
-rw-r--r--dom/l10n/tests/mochitest/document_l10n/non-system-principal/localization/test.ftl4
-rw-r--r--dom/l10n/tests/mochitest/document_l10n/non-system-principal/test.html37
-rw-r--r--dom/l10n/tests/mochitest/document_l10n/test_connectRoot_webcomponent.html90
-rw-r--r--dom/l10n/tests/mochitest/document_l10n/test_connectRoot_webcomponent_lazy.html98
-rw-r--r--dom/l10n/tests/mochitest/document_l10n/test_docl10n.html66
-rw-r--r--dom/l10n/tests/mochitest/document_l10n/test_docl10n.xhtml60
-rw-r--r--dom/l10n/tests/mochitest/document_l10n/test_docl10n_lazy.html44
-rw-r--r--dom/l10n/tests/mochitest/document_l10n/test_docl10n_ready_rejected.html29
-rw-r--r--dom/l10n/tests/mochitest/document_l10n/test_docl10n_removeResourceIds.html59
-rw-r--r--dom/l10n/tests/mochitest/document_l10n/test_docl10n_sync.html54
-rw-r--r--dom/l10n/tests/mochitest/document_l10n/test_telemetry.html83
-rw-r--r--dom/l10n/tests/mochitest/document_l10n/test_unpriv_iframe.html26
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_attr_sanitized.html49
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_connectRoot.html45
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_connectRoot_webcomponent.html72
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_disconnectRoot.html60
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_domloc.xhtml68
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_getAttributes.html49
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_l10n_mutations.html57
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_overlay.html60
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_overlay_missing_all.html37
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_overlay_missing_children.html53
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_overlay_repeated.html50
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_overlay_sanitized.html52
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_repeated_l10nid.html60
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_setAttributes.html79
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_translateElements.html47
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_translateFragment.html48
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_translateRoots.html56
-rw-r--r--dom/l10n/tests/mochitest/l10n_mutations/test_append_content_post_dcl.html30
-rw-r--r--dom/l10n/tests/mochitest/l10n_mutations/test_append_content_pre_dcl.html28
-rw-r--r--dom/l10n/tests/mochitest/l10n_mutations/test_append_fragment_post_dcl.html39
-rw-r--r--dom/l10n/tests/mochitest/l10n_mutations/test_disconnectedRoot_webcomponent.html148
-rw-r--r--dom/l10n/tests/mochitest/l10n_mutations/test_pause_observing.html44
-rw-r--r--dom/l10n/tests/mochitest/l10n_mutations/test_remove_element.html68
-rw-r--r--dom/l10n/tests/mochitest/l10n_mutations/test_remove_fragment.html67
-rw-r--r--dom/l10n/tests/mochitest/l10n_mutations/test_set_attributes.html37
-rw-r--r--dom/l10n/tests/mochitest/l10n_mutations/test_template.html37
-rw-r--r--dom/l10n/tests/mochitest/l10n_overlays/test_attributes.html86
-rw-r--r--dom/l10n/tests/mochitest/l10n_overlays/test_extra_text_markup.html136
-rw-r--r--dom/l10n/tests/mochitest/l10n_overlays/test_functional_children.html344
-rw-r--r--dom/l10n/tests/mochitest/l10n_overlays/test_l10n_overlays.xhtml87
-rw-r--r--dom/l10n/tests/mochitest/l10n_overlays/test_same_id.html57
-rw-r--r--dom/l10n/tests/mochitest/l10n_overlays/test_same_id_args.html57
-rw-r--r--dom/l10n/tests/mochitest/l10n_overlays/test_text_children.html74
-rw-r--r--dom/l10n/tests/mochitest/l10n_overlays/test_title.html60
-rw-r--r--dom/l10n/tests/mochitest/mochitest.ini1
64 files changed, 5672 insertions, 0 deletions
diff --git a/dom/l10n/DOMLocalization.cpp b/dom/l10n/DOMLocalization.cpp
new file mode 100644
index 0000000000..456b67ab22
--- /dev/null
+++ b/dom/l10n/DOMLocalization.cpp
@@ -0,0 +1,672 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "js/ForOfIterator.h" // JS::ForOfIterator
+#include "js/JSON.h" // JS_ParseJSON
+#include "nsContentUtils.h"
+#include "nsIScriptError.h"
+#include "DOMLocalization.h"
+#include "mozilla/intl/L10nRegistry.h"
+#include "mozilla/intl/LocaleService.h"
+#include "mozilla/dom/AutoEntryScript.h"
+#include "mozilla/dom/Element.h"
+#include "mozilla/dom/L10nOverlays.h"
+
+using namespace mozilla;
+using namespace mozilla::dom;
+using namespace mozilla::intl;
+
+NS_IMPL_CYCLE_COLLECTION_CLASS(DOMLocalization)
+NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN_INHERITED(DOMLocalization, Localization)
+ tmp->DisconnectMutations();
+ NS_IMPL_CYCLE_COLLECTION_UNLINK(mMutations)
+ NS_IMPL_CYCLE_COLLECTION_UNLINK(mRoots)
+ NS_IMPL_CYCLE_COLLECTION_UNLINK_PRESERVED_WRAPPER
+NS_IMPL_CYCLE_COLLECTION_UNLINK_END
+NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN_INHERITED(DOMLocalization, Localization)
+ NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mMutations)
+ NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mRoots)
+NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END
+
+NS_IMPL_ADDREF_INHERITED(DOMLocalization, Localization)
+NS_IMPL_RELEASE_INHERITED(DOMLocalization, Localization)
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(DOMLocalization)
+NS_INTERFACE_MAP_END_INHERITING(Localization)
+
+DOMLocalization::DOMLocalization(nsIGlobalObject* aGlobal, bool aSync)
+ : Localization(aGlobal, aSync) {
+ mMutations = new L10nMutations(this);
+}
+
+DOMLocalization::DOMLocalization(nsIGlobalObject* aGlobal, bool aIsSync,
+ const ffi::LocalizationRc* aRaw)
+ : Localization(aGlobal, aIsSync, aRaw) {
+ mMutations = new L10nMutations(this);
+}
+
+already_AddRefed<DOMLocalization> DOMLocalization::Constructor(
+ const GlobalObject& aGlobal,
+ const Sequence<dom::OwningUTF8StringOrResourceId>& aResourceIds,
+ bool aIsSync, const Optional<NonNull<L10nRegistry>>& aRegistry,
+ const Optional<Sequence<nsCString>>& aLocales, ErrorResult& aRv) {
+ auto ffiResourceIds{L10nRegistry::ResourceIdsToFFI(aResourceIds)};
+ Maybe<nsTArray<nsCString>> locales;
+
+ if (aLocales.WasPassed()) {
+ locales.emplace();
+ locales->SetCapacity(aLocales.Value().Length());
+ for (const auto& locale : aLocales.Value()) {
+ locales->AppendElement(locale);
+ }
+ }
+
+ RefPtr<const ffi::LocalizationRc> raw;
+ bool result;
+
+ if (aRegistry.WasPassed()) {
+ result = ffi::localization_new_with_locales(
+ &ffiResourceIds, aIsSync, aRegistry.Value().Raw(),
+ locales.ptrOr(nullptr), getter_AddRefs(raw));
+ } else {
+ result = ffi::localization_new_with_locales(&ffiResourceIds, aIsSync,
+ nullptr, locales.ptrOr(nullptr),
+ getter_AddRefs(raw));
+ }
+
+ if (result) {
+ nsCOMPtr<nsIGlobalObject> global =
+ do_QueryInterface(aGlobal.GetAsSupports());
+
+ return do_AddRef(new DOMLocalization(global, aIsSync, raw));
+ }
+ aRv.ThrowInvalidStateError(
+ "Failed to create the Localization. Check the locales arguments.");
+ return nullptr;
+}
+
+JSObject* DOMLocalization::WrapObject(JSContext* aCx,
+ JS::Handle<JSObject*> aGivenProto) {
+ return DOMLocalization_Binding::Wrap(aCx, this, aGivenProto);
+}
+
+void DOMLocalization::Destroy() { DisconnectMutations(); }
+
+DOMLocalization::~DOMLocalization() { Destroy(); }
+
+bool DOMLocalization::HasPendingMutations() const {
+ return mMutations && mMutations->HasPendingMutations();
+}
+
+/**
+ * DOMLocalization API
+ */
+
+void DOMLocalization::ConnectRoot(nsINode& aNode) {
+ nsCOMPtr<nsIGlobalObject> global = aNode.GetOwnerGlobal();
+ if (!global) {
+ return;
+ }
+ MOZ_ASSERT(global == mGlobal,
+ "Cannot add a root that overlaps with existing root.");
+
+#ifdef DEBUG
+ for (nsINode* root : mRoots) {
+ MOZ_ASSERT(
+ root != &aNode && !root->Contains(&aNode) && !aNode.Contains(root),
+ "Cannot add a root that overlaps with existing root.");
+ }
+#endif
+
+ mRoots.Insert(&aNode);
+
+ aNode.AddMutationObserverUnlessExists(mMutations);
+}
+
+void DOMLocalization::DisconnectRoot(nsINode& aNode) {
+ if (mRoots.Contains(&aNode)) {
+ aNode.RemoveMutationObserver(mMutations);
+ mRoots.Remove(&aNode);
+ }
+}
+
+void DOMLocalization::PauseObserving() { mMutations->PauseObserving(); }
+
+void DOMLocalization::ResumeObserving() { mMutations->ResumeObserving(); }
+
+void DOMLocalization::SetAttributes(
+ JSContext* aCx, Element& aElement, const nsAString& aId,
+ const Optional<JS::Handle<JSObject*>>& aArgs, ErrorResult& aRv) {
+ if (aArgs.WasPassed() && aArgs.Value()) {
+ nsAutoString data;
+ JS::Rooted<JS::Value> val(aCx, JS::ObjectValue(*aArgs.Value()));
+ if (!nsContentUtils::StringifyJSON(aCx, val, data,
+ UndefinedIsNullStringLiteral)) {
+ aRv.NoteJSContextException(aCx);
+ return;
+ }
+ if (!aElement.AttrValueIs(kNameSpaceID_None, nsGkAtoms::datal10nargs, data,
+ eCaseMatters)) {
+ aElement.SetAttr(kNameSpaceID_None, nsGkAtoms::datal10nargs, data, true);
+ }
+ } else {
+ aElement.UnsetAttr(kNameSpaceID_None, nsGkAtoms::datal10nargs, true);
+ }
+
+ if (!aElement.AttrValueIs(kNameSpaceID_None, nsGkAtoms::datal10nid, aId,
+ eCaseMatters)) {
+ aElement.SetAttr(kNameSpaceID_None, nsGkAtoms::datal10nid, aId, true);
+ }
+}
+
+void DOMLocalization::GetAttributes(Element& aElement, L10nIdArgs& aResult,
+ ErrorResult& aRv) {
+ nsAutoString l10nId;
+ nsAutoString l10nArgs;
+
+ if (aElement.GetAttr(kNameSpaceID_None, nsGkAtoms::datal10nid, l10nId)) {
+ CopyUTF16toUTF8(l10nId, aResult.mId);
+ }
+
+ if (aElement.GetAttr(kNameSpaceID_None, nsGkAtoms::datal10nargs, l10nArgs)) {
+ ConvertStringToL10nArgs(l10nArgs, aResult.mArgs.SetValue(), aRv);
+ }
+}
+
+void DOMLocalization::SetArgs(JSContext* aCx, Element& aElement,
+ const Optional<JS::Handle<JSObject*>>& aArgs,
+ ErrorResult& aRv) {
+ if (aArgs.WasPassed() && aArgs.Value()) {
+ nsAutoString data;
+ JS::Rooted<JS::Value> val(aCx, JS::ObjectValue(*aArgs.Value()));
+ if (!nsContentUtils::StringifyJSON(aCx, val, data,
+ UndefinedIsNullStringLiteral)) {
+ aRv.NoteJSContextException(aCx);
+ return;
+ }
+ if (!aElement.AttrValueIs(kNameSpaceID_None, nsGkAtoms::datal10nargs, data,
+ eCaseMatters)) {
+ aElement.SetAttr(kNameSpaceID_None, nsGkAtoms::datal10nargs, data, true);
+ }
+ } else {
+ aElement.UnsetAttr(kNameSpaceID_None, nsGkAtoms::datal10nargs, true);
+ }
+}
+
+already_AddRefed<Promise> DOMLocalization::TranslateFragment(nsINode& aNode,
+ ErrorResult& aRv) {
+ Sequence<OwningNonNull<Element>> elements;
+ GetTranslatables(aNode, elements, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return nullptr;
+ }
+ return TranslateElements(elements, aRv);
+}
+
+/**
+ * A Promise Handler used to apply the result of
+ * a call to Localization::FormatMessages onto the list
+ * of translatable elements.
+ */
+class ElementTranslationHandler : public PromiseNativeHandler {
+ public:
+ explicit ElementTranslationHandler(DOMLocalization* aDOMLocalization,
+ nsXULPrototypeDocument* aProto)
+ : mDOMLocalization(aDOMLocalization), mProto(aProto){};
+
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_CLASS(ElementTranslationHandler)
+
+ nsTArray<nsCOMPtr<Element>>& Elements() { return mElements; }
+
+ void SetReturnValuePromise(Promise* aReturnValuePromise) {
+ mReturnValuePromise = aReturnValuePromise;
+ }
+
+ virtual void ResolvedCallback(JSContext* aCx, JS::Handle<JS::Value> aValue,
+ ErrorResult& aRv) override {
+ ErrorResult rv;
+
+ nsTArray<Nullable<L10nMessage>> l10nData;
+ if (aValue.isObject()) {
+ JS::ForOfIterator iter(aCx);
+ if (!iter.init(aValue, JS::ForOfIterator::AllowNonIterable)) {
+ mReturnValuePromise->MaybeRejectWithUndefined();
+ return;
+ }
+ if (!iter.valueIsIterable()) {
+ mReturnValuePromise->MaybeRejectWithUndefined();
+ return;
+ }
+
+ JS::Rooted<JS::Value> temp(aCx);
+ while (true) {
+ bool done;
+ if (!iter.next(&temp, &done)) {
+ mReturnValuePromise->MaybeRejectWithUndefined();
+ return;
+ }
+
+ if (done) {
+ break;
+ }
+
+ Nullable<L10nMessage>* slotPtr =
+ l10nData.AppendElement(mozilla::fallible);
+ if (!slotPtr) {
+ mReturnValuePromise->MaybeRejectWithUndefined();
+ return;
+ }
+
+ if (!temp.isNull()) {
+ if (!slotPtr->SetValue().Init(aCx, temp)) {
+ mReturnValuePromise->MaybeRejectWithUndefined();
+ return;
+ }
+ }
+ }
+ }
+
+ bool allTranslated =
+ mDOMLocalization->ApplyTranslations(mElements, l10nData, mProto, rv);
+ if (NS_WARN_IF(rv.Failed()) || !allTranslated) {
+ mReturnValuePromise->MaybeRejectWithUndefined();
+ return;
+ }
+
+ mReturnValuePromise->MaybeResolveWithUndefined();
+ }
+
+ virtual void RejectedCallback(JSContext* aCx, JS::Handle<JS::Value> aValue,
+ ErrorResult& aRv) override {
+ mReturnValuePromise->MaybeRejectWithClone(aCx, aValue);
+ }
+
+ private:
+ ~ElementTranslationHandler() = default;
+
+ nsTArray<nsCOMPtr<Element>> mElements;
+ RefPtr<DOMLocalization> mDOMLocalization;
+ RefPtr<Promise> mReturnValuePromise;
+ RefPtr<nsXULPrototypeDocument> mProto;
+};
+
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(ElementTranslationHandler)
+ NS_INTERFACE_MAP_ENTRY(nsISupports)
+NS_INTERFACE_MAP_END
+
+NS_IMPL_CYCLE_COLLECTION_CLASS(ElementTranslationHandler)
+
+NS_IMPL_CYCLE_COLLECTING_ADDREF(ElementTranslationHandler)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(ElementTranslationHandler)
+
+NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(ElementTranslationHandler)
+ NS_IMPL_CYCLE_COLLECTION_UNLINK(mElements)
+ NS_IMPL_CYCLE_COLLECTION_UNLINK(mDOMLocalization)
+ NS_IMPL_CYCLE_COLLECTION_UNLINK(mReturnValuePromise)
+ NS_IMPL_CYCLE_COLLECTION_UNLINK(mProto)
+NS_IMPL_CYCLE_COLLECTION_UNLINK_END
+
+NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(ElementTranslationHandler)
+ NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mElements)
+ NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mDOMLocalization)
+ NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mReturnValuePromise)
+ NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mProto)
+NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END
+
+already_AddRefed<Promise> DOMLocalization::TranslateElements(
+ const nsTArray<OwningNonNull<Element>>& aElements, ErrorResult& aRv) {
+ return TranslateElements(aElements, nullptr, aRv);
+}
+
+already_AddRefed<Promise> DOMLocalization::TranslateElements(
+ const nsTArray<OwningNonNull<Element>>& aElements,
+ nsXULPrototypeDocument* aProto, ErrorResult& aRv) {
+ Sequence<OwningUTF8StringOrL10nIdArgs> l10nKeys;
+ RefPtr<ElementTranslationHandler> nativeHandler =
+ new ElementTranslationHandler(this, aProto);
+ nsTArray<nsCOMPtr<Element>>& domElements = nativeHandler->Elements();
+ domElements.SetCapacity(aElements.Length());
+
+ if (!mGlobal) {
+ aRv.Throw(NS_ERROR_UNEXPECTED);
+ return nullptr;
+ }
+
+ for (auto& domElement : aElements) {
+ if (!domElement->HasAttr(nsGkAtoms::datal10nid)) {
+ continue;
+ }
+
+ OwningUTF8StringOrL10nIdArgs* key = l10nKeys.AppendElement(fallible);
+ if (!key) {
+ aRv.Throw(NS_ERROR_OUT_OF_MEMORY);
+ return nullptr;
+ }
+
+ GetAttributes(*domElement, key->SetAsL10nIdArgs(), aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return nullptr;
+ }
+
+ if (!domElements.AppendElement(domElement, fallible)) {
+ // This can't really happen, we SetCapacity'd above...
+ aRv.Throw(NS_ERROR_OUT_OF_MEMORY);
+ return nullptr;
+ }
+ }
+
+ RefPtr<Promise> promise = Promise::Create(mGlobal, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return nullptr;
+ }
+
+ if (IsSync()) {
+ nsTArray<Nullable<L10nMessage>> l10nMessages;
+
+ FormatMessagesSync(l10nKeys, l10nMessages, aRv);
+
+ if (NS_WARN_IF(aRv.Failed())) {
+ promise->MaybeRejectWithUndefined();
+ return promise.forget();
+ }
+
+ bool allTranslated =
+ ApplyTranslations(domElements, l10nMessages, aProto, aRv);
+ if (NS_WARN_IF(aRv.Failed()) || !allTranslated) {
+ promise->MaybeRejectWithUndefined();
+ return promise.forget();
+ }
+
+ promise->MaybeResolveWithUndefined();
+ return promise.forget();
+ }
+ RefPtr<Promise> callbackResult = FormatMessages(l10nKeys, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return nullptr;
+ }
+ nativeHandler->SetReturnValuePromise(promise);
+ callbackResult->AppendNativeHandler(nativeHandler);
+ return MaybeWrapPromise(promise);
+}
+
+/**
+ * Promise handler used to set localization data on
+ * roots of elements that got successfully translated.
+ */
+class L10nRootTranslationHandler final : public PromiseNativeHandler {
+ public:
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_CLASS(L10nRootTranslationHandler)
+
+ explicit L10nRootTranslationHandler(Element* aRoot) : mRoot(aRoot) {}
+
+ void ResolvedCallback(JSContext* aCx, JS::Handle<JS::Value> aValue,
+ ErrorResult& aRv) override {
+ DOMLocalization::SetRootInfo(mRoot);
+ }
+
+ void RejectedCallback(JSContext* aCx, JS::Handle<JS::Value> aValue,
+ ErrorResult& aRv) override {}
+
+ private:
+ ~L10nRootTranslationHandler() = default;
+
+ RefPtr<Element> mRoot;
+};
+
+NS_IMPL_CYCLE_COLLECTION(L10nRootTranslationHandler, mRoot)
+
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(L10nRootTranslationHandler)
+ NS_INTERFACE_MAP_ENTRY(nsISupports)
+NS_INTERFACE_MAP_END
+
+NS_IMPL_CYCLE_COLLECTING_ADDREF(L10nRootTranslationHandler)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(L10nRootTranslationHandler)
+
+already_AddRefed<Promise> DOMLocalization::TranslateRoots(ErrorResult& aRv) {
+ nsTArray<RefPtr<Promise>> promises;
+
+ for (nsINode* root : mRoots) {
+ RefPtr<Promise> promise = TranslateFragment(*root, aRv);
+ if (MOZ_UNLIKELY(aRv.Failed())) {
+ return nullptr;
+ }
+
+ // If the root is an element, we'll add a native handler
+ // to set root info (language, direction etc.) on it
+ // once the localization finishes.
+ if (root->IsElement()) {
+ RefPtr<L10nRootTranslationHandler> nativeHandler =
+ new L10nRootTranslationHandler(root->AsElement());
+ promise->AppendNativeHandler(nativeHandler);
+ }
+
+ promises.AppendElement(promise);
+ }
+ AutoEntryScript aes(mGlobal, "DOMLocalization TranslateRoots");
+ return Promise::All(aes.cx(), promises, aRv);
+}
+
+/**
+ * Helper methods
+ */
+
+/* static */
+void DOMLocalization::GetTranslatables(
+ nsINode& aNode, Sequence<OwningNonNull<Element>>& aElements,
+ ErrorResult& aRv) {
+ nsIContent* node =
+ aNode.IsContent() ? aNode.AsContent() : aNode.GetFirstChild();
+ for (; node; node = node->GetNextNode(&aNode)) {
+ if (!node->IsElement()) {
+ continue;
+ }
+
+ Element* domElement = node->AsElement();
+
+ if (!domElement->HasAttr(kNameSpaceID_None, nsGkAtoms::datal10nid)) {
+ continue;
+ }
+
+ if (!aElements.AppendElement(*domElement, fallible)) {
+ aRv.Throw(NS_ERROR_OUT_OF_MEMORY);
+ return;
+ }
+ }
+}
+
+/* static */
+void DOMLocalization::SetRootInfo(Element* aElement) {
+ nsAutoCString primaryLocale;
+ LocaleService::GetInstance()->GetAppLocaleAsBCP47(primaryLocale);
+ aElement->SetAttr(kNameSpaceID_None, nsGkAtoms::lang,
+ NS_ConvertUTF8toUTF16(primaryLocale), true);
+
+ nsAutoString dir;
+ if (LocaleService::GetInstance()->IsAppLocaleRTL()) {
+ nsGkAtoms::rtl->ToString(dir);
+ } else {
+ nsGkAtoms::ltr->ToString(dir);
+ }
+
+ uint32_t nameSpace = aElement->GetNameSpaceID();
+ nsAtom* dirAtom =
+ nameSpace == kNameSpaceID_XUL ? nsGkAtoms::localedir : nsGkAtoms::dir;
+
+ aElement->SetAttr(kNameSpaceID_None, dirAtom, dir, true);
+}
+
+bool DOMLocalization::ApplyTranslations(
+ nsTArray<nsCOMPtr<Element>>& aElements,
+ nsTArray<Nullable<L10nMessage>>& aTranslations,
+ nsXULPrototypeDocument* aProto, ErrorResult& aRv) {
+ if (aElements.Length() != aTranslations.Length()) {
+ aRv.Throw(NS_ERROR_FAILURE);
+ return false;
+ }
+
+ PauseObserving();
+
+ bool hasMissingTranslation = false;
+
+ nsTArray<L10nOverlaysError> errors;
+ for (size_t i = 0; i < aTranslations.Length(); ++i) {
+ nsCOMPtr elem = aElements[i];
+ if (aTranslations[i].IsNull()) {
+ hasMissingTranslation = true;
+ continue;
+ }
+ // If we have a proto, we expect all elements are connected up.
+ // If they're not, they may have been removed by earlier translations.
+ // We will have added an error in L10nOverlays in this case.
+ // This is an error in fluent use, but shouldn't be crashing. There's
+ // also no point translating the element - skip it:
+ if (aProto && !elem->IsInComposedDoc()) {
+ continue;
+ }
+
+ // It is possible that someone removed the `data-l10n-id` from the element
+ // before the async translation completed. In that case, skip applying
+ // the translation.
+ if (!elem->HasAttr(kNameSpaceID_None, nsGkAtoms::datal10nid)) {
+ continue;
+ }
+ L10nOverlays::TranslateElement(*elem, aTranslations[i].Value(), errors,
+ aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ hasMissingTranslation = true;
+ continue;
+ }
+ if (aProto) {
+ // We only need to rebuild deep if the translation has a value.
+ // Otherwise we'll only rebuild the attributes.
+ aProto->RebuildL10nPrototype(elem,
+ !aTranslations[i].Value().mValue.IsVoid());
+ }
+ }
+
+ ReportL10nOverlaysErrors(errors);
+
+ ResumeObserving();
+
+ return !hasMissingTranslation;
+}
+
+/* Protected */
+
+void DOMLocalization::OnChange() {
+ Localization::OnChange();
+ RefPtr<Promise> promise = TranslateRoots(IgnoreErrors());
+}
+
+void DOMLocalization::DisconnectMutations() {
+ if (mMutations) {
+ mMutations->Disconnect();
+ DisconnectRoots();
+ }
+}
+
+void DOMLocalization::DisconnectRoots() {
+ for (nsINode* node : mRoots) {
+ node->RemoveMutationObserver(mMutations);
+ }
+ mRoots.Clear();
+}
+
+void DOMLocalization::ReportL10nOverlaysErrors(
+ nsTArray<L10nOverlaysError>& aErrors) {
+ nsAutoString msg;
+
+ for (auto& error : aErrors) {
+ if (error.mCode.WasPassed()) {
+ msg = u"[fluent-dom] "_ns;
+ switch (error.mCode.Value()) {
+ case L10nOverlays_Binding::ERROR_FORBIDDEN_TYPE:
+ msg += u"An element of forbidden type \""_ns +
+ error.mTranslatedElementName.Value() +
+ nsLiteralString(
+ u"\" was found in the translation. Only safe text-level "
+ "elements and elements with data-l10n-name are allowed.");
+ break;
+ case L10nOverlays_Binding::ERROR_NAMED_ELEMENT_MISSING:
+ msg += u"An element named \""_ns + error.mL10nName.Value() +
+ u"\" wasn't found in the source."_ns;
+ break;
+ case L10nOverlays_Binding::ERROR_NAMED_ELEMENT_TYPE_MISMATCH:
+ msg += u"An element named \""_ns + error.mL10nName.Value() +
+ nsLiteralString(
+ u"\" was found in the translation but its type ") +
+ error.mTranslatedElementName.Value() +
+ nsLiteralString(
+ u" didn't match the element found in the source ") +
+ error.mSourceElementName.Value() + u"."_ns;
+ break;
+ case L10nOverlays_Binding::ERROR_TRANSLATED_ELEMENT_DISCONNECTED:
+ msg += u"The element using message \""_ns + error.mL10nName.Value() +
+ nsLiteralString(
+ u"\" was removed from the DOM when translating its \"") +
+ error.mTranslatedElementName.Value() + u"\" parent."_ns;
+ break;
+ case L10nOverlays_Binding::ERROR_TRANSLATED_ELEMENT_DISALLOWED_DOM:
+ msg += nsLiteralString(
+ u"While translating an element with fluent ID \"") +
+ error.mL10nName.Value() + u"\" a child element of type \""_ns +
+ error.mTranslatedElementName.Value() +
+ nsLiteralString(
+ u"\" was removed. Either the fluent message "
+ "does not contain markup, or it does not contain markup "
+ "of this type.");
+ break;
+ case L10nOverlays_Binding::ERROR_UNKNOWN:
+ default:
+ msg += nsLiteralString(
+ u"Unknown error happened while translating an element.");
+ break;
+ }
+ nsPIDOMWindowInner* innerWindow = GetParentObject()->AsInnerWindow();
+ Document* doc = innerWindow ? innerWindow->GetExtantDoc() : nullptr;
+ if (doc) {
+ nsContentUtils::ReportToConsoleNonLocalized(
+ msg, nsIScriptError::warningFlag, "DOM"_ns, doc);
+ } else {
+ NS_WARNING("Failed to report l10n DOM Overlay errors to console.");
+ }
+ printf_stderr("%s\n", NS_ConvertUTF16toUTF8(msg).get());
+ }
+ }
+}
+
+void DOMLocalization::ConvertStringToL10nArgs(const nsString& aInput,
+ intl::L10nArgs& aRetVal,
+ ErrorResult& aRv) {
+ if (aInput.IsEmpty()) {
+ // There are no properties.
+ return;
+ }
+ // This method uses a temporary dictionary to automate
+ // converting a JSON string into an IDL Record via a dictionary.
+ //
+ // Once we get Record::Init(const nsAString& aJSON), we'll switch to
+ // that.
+ L10nArgsHelperDict helperDict;
+ if (!helperDict.Init(u"{\"args\": "_ns + aInput + u"}"_ns)) {
+ nsTArray<nsCString> errors{
+ "[dom/l10n] Failed to parse l10n-args JSON: "_ns +
+ NS_ConvertUTF16toUTF8(aInput),
+ };
+ MaybeReportErrorsToGecko(errors, aRv, GetParentObject());
+ return;
+ }
+ for (auto& entry : helperDict.mArgs.Entries()) {
+ L10nArgs::EntryType* newEntry = aRetVal.Entries().AppendElement(fallible);
+ if (!newEntry) {
+ aRv.Throw(NS_ERROR_OUT_OF_MEMORY);
+ return;
+ }
+ newEntry->mKey = entry.mKey;
+ newEntry->mValue = entry.mValue;
+ }
+}
diff --git a/dom/l10n/DOMLocalization.h b/dom/l10n/DOMLocalization.h
new file mode 100644
index 0000000000..ab66194d31
--- /dev/null
+++ b/dom/l10n/DOMLocalization.h
@@ -0,0 +1,137 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef mozilla_dom_l10n_DOMLocalization_h
+#define mozilla_dom_l10n_DOMLocalization_h
+
+#include "nsTHashSet.h"
+#include "nsXULPrototypeDocument.h"
+#include "mozilla/intl/Localization.h"
+#include "mozilla/dom/DOMLocalizationBinding.h"
+#include "mozilla/dom/L10nMutations.h"
+#include "mozilla/dom/L10nOverlaysBinding.h"
+#include "mozilla/dom/LocalizationBinding.h"
+#include "mozilla/dom/PromiseNativeHandler.h"
+#include "mozilla/intl/L10nRegistry.h"
+
+// XXX Avoid including this here by moving function bodies to the cpp file
+#include "nsINode.h"
+
+namespace mozilla::dom {
+
+class Element;
+class L10nMutations;
+
+class DOMLocalization : public intl::Localization {
+ public:
+ NS_DECL_ISUPPORTS_INHERITED
+ NS_DECL_CYCLE_COLLECTION_CLASS_INHERITED(DOMLocalization, Localization)
+
+ void Destroy();
+
+ static already_AddRefed<DOMLocalization> Constructor(
+ const dom::GlobalObject& aGlobal,
+ const dom::Sequence<dom::OwningUTF8StringOrResourceId>& aResourceIds,
+ bool aIsSync,
+ const dom::Optional<dom::NonNull<intl::L10nRegistry>>& aRegistry,
+ const dom::Optional<dom::Sequence<nsCString>>& aLocales,
+ ErrorResult& aRv);
+
+ JSObject* WrapObject(JSContext*, JS::Handle<JSObject*> aGivenProto) override;
+
+ bool HasPendingMutations() const;
+
+ /**
+ * DOMLocalization API
+ *
+ * Methods documentation in DOMLocalization.webidl
+ */
+
+ void ConnectRoot(nsINode& aNode);
+ void DisconnectRoot(nsINode& aNode);
+
+ void PauseObserving();
+ void ResumeObserving();
+
+ void SetAttributes(JSContext* aCx, Element& aElement, const nsAString& aId,
+ const Optional<JS::Handle<JSObject*>>& aArgs,
+ ErrorResult& aRv);
+ void GetAttributes(Element& aElement, L10nIdArgs& aResult, ErrorResult& aRv);
+
+ void SetArgs(JSContext* aCx, Element& aElement,
+ const Optional<JS::Handle<JSObject*>>& aArgs, ErrorResult& aRv);
+
+ already_AddRefed<Promise> TranslateFragment(nsINode& aNode, ErrorResult& aRv);
+
+ already_AddRefed<Promise> TranslateElements(
+ const nsTArray<OwningNonNull<Element>>& aElements, ErrorResult& aRv);
+ already_AddRefed<Promise> TranslateElements(
+ const nsTArray<OwningNonNull<Element>>& aElements,
+ nsXULPrototypeDocument* aProto, ErrorResult& aRv);
+
+ already_AddRefed<Promise> TranslateRoots(ErrorResult& aRv);
+
+ /**
+ * Helper methods
+ */
+
+ /**
+ * Accumulates all translatable elements (ones containing
+ * a `data-l10n-id` attribute) from under a node into
+ * a list of elements.
+ */
+ static void GetTranslatables(nsINode& aNode,
+ Sequence<OwningNonNull<Element>>& aElements,
+ ErrorResult& aRv);
+
+ /**
+ * Sets the root information such as locale and direction.
+ */
+ static void SetRootInfo(Element* aElement);
+
+ /**
+ * Applies l10n translations on translatable elements.
+ *
+ * If `aProto` gets passed, it'll be used to cache
+ * the localized elements.
+ *
+ * Result is `true` if all translations were applied
+ * successfully, and `false` otherwise.
+ */
+ bool ApplyTranslations(nsTArray<nsCOMPtr<Element>>& aElements,
+ nsTArray<Nullable<L10nMessage>>& aTranslations,
+ nsXULPrototypeDocument* aProto, ErrorResult& aRv);
+
+ bool SubtreeRootInRoots(nsINode* aSubtreeRoot) {
+ for (const auto* key : mRoots) {
+ nsINode* subtreeRoot = key->SubtreeRoot();
+ if (subtreeRoot == aSubtreeRoot) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ DOMLocalization(nsIGlobalObject* aGlobal, bool aSync);
+ DOMLocalization(nsIGlobalObject* aGlobal, bool aIsSync,
+ const intl::ffi::LocalizationRc* aRaw);
+
+ protected:
+ virtual ~DOMLocalization();
+ void OnChange() override;
+ void DisconnectMutations();
+ void DisconnectRoots();
+ void ReportL10nOverlaysErrors(nsTArray<L10nOverlaysError>& aErrors);
+ void ConvertStringToL10nArgs(const nsString& aInput, intl::L10nArgs& aRetVal,
+ ErrorResult& aRv);
+
+ RefPtr<L10nMutations> mMutations;
+ nsTHashSet<RefPtr<nsINode>> mRoots;
+};
+
+} // namespace mozilla::dom
+
+#endif
diff --git a/dom/l10n/DocumentL10n.cpp b/dom/l10n/DocumentL10n.cpp
new file mode 100644
index 0000000000..828a0583e3
--- /dev/null
+++ b/dom/l10n/DocumentL10n.cpp
@@ -0,0 +1,347 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "DocumentL10n.h"
+#include "nsIContentSink.h"
+#include "nsContentUtils.h"
+#include "mozilla/dom/AutoEntryScript.h"
+#include "mozilla/dom/Document.h"
+#include "mozilla/dom/DocumentL10nBinding.h"
+#include "mozilla/Telemetry.h"
+
+using namespace mozilla;
+using namespace mozilla::intl;
+using namespace mozilla::dom;
+
+NS_IMPL_CYCLE_COLLECTION_CLASS(DocumentL10n)
+NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN_INHERITED(DocumentL10n, DOMLocalization)
+ NS_IMPL_CYCLE_COLLECTION_UNLINK(mDocument)
+ NS_IMPL_CYCLE_COLLECTION_UNLINK(mReady)
+ NS_IMPL_CYCLE_COLLECTION_UNLINK(mContentSink)
+ NS_IMPL_CYCLE_COLLECTION_UNLINK_PRESERVED_WRAPPER
+NS_IMPL_CYCLE_COLLECTION_UNLINK_END
+NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN_INHERITED(DocumentL10n, DOMLocalization)
+ NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mDocument)
+ NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mReady)
+ NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mContentSink)
+NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END
+
+NS_IMPL_ADDREF_INHERITED(DocumentL10n, DOMLocalization)
+NS_IMPL_RELEASE_INHERITED(DocumentL10n, DOMLocalization)
+
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(DocumentL10n)
+NS_INTERFACE_MAP_END_INHERITING(DOMLocalization)
+
+bool DocumentL10n::mIsFirstBrowserWindow = true;
+
+/* static */
+RefPtr<DocumentL10n> DocumentL10n::Create(Document* aDocument, bool aSync) {
+ RefPtr<DocumentL10n> l10n = new DocumentL10n(aDocument, aSync);
+
+ IgnoredErrorResult rv;
+ l10n->mReady = Promise::Create(l10n->mGlobal, rv);
+ if (NS_WARN_IF(rv.Failed())) {
+ return nullptr;
+ }
+
+ return l10n.forget();
+}
+
+DocumentL10n::DocumentL10n(Document* aDocument, bool aSync)
+ : DOMLocalization(aDocument->GetScopeObject(), aSync),
+ mDocument(aDocument),
+ mState(DocumentL10nState::Constructed) {
+ mContentSink = do_QueryInterface(aDocument->GetCurrentContentSink());
+}
+
+JSObject* DocumentL10n::WrapObject(JSContext* aCx,
+ JS::Handle<JSObject*> aGivenProto) {
+ return DocumentL10n_Binding::Wrap(aCx, this, aGivenProto);
+}
+
+class L10nReadyHandler final : public PromiseNativeHandler {
+ public:
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_CLASS(L10nReadyHandler)
+
+ explicit L10nReadyHandler(Promise* aPromise, DocumentL10n* aDocumentL10n)
+ : mPromise(aPromise), mDocumentL10n(aDocumentL10n) {}
+
+ void ResolvedCallback(JSContext* aCx, JS::Handle<JS::Value> aValue,
+ ErrorResult& aRv) override {
+ mDocumentL10n->InitialTranslationCompleted(true);
+ mPromise->MaybeResolveWithUndefined();
+ }
+
+ void RejectedCallback(JSContext* aCx, JS::Handle<JS::Value> aValue,
+ ErrorResult& aRv) override {
+ mDocumentL10n->InitialTranslationCompleted(false);
+
+ nsTArray<nsCString> errors{
+ "[dom/l10n] Could not complete initial document translation."_ns,
+ };
+ IgnoredErrorResult rv;
+ MaybeReportErrorsToGecko(errors, rv, mDocumentL10n->GetParentObject());
+
+ /**
+ * We resolve the mReady here even if we encountered failures, because
+ * there is nothing actionable for the user pending on `mReady` to do here
+ * and we don't want to incentivized consumers of this API to plan the
+ * same pending operation for resolve and reject scenario.
+ *
+ * Additionally, without it, the stderr received "uncaught promise
+ * rejection" warning, which is noisy and not-actionable.
+ *
+ * So instead, we just resolve and report errors.
+ */
+ mPromise->MaybeResolveWithUndefined();
+ }
+
+ private:
+ ~L10nReadyHandler() = default;
+
+ RefPtr<Promise> mPromise;
+ RefPtr<DocumentL10n> mDocumentL10n;
+};
+
+NS_IMPL_CYCLE_COLLECTION(L10nReadyHandler, mPromise, mDocumentL10n)
+
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(L10nReadyHandler)
+ NS_INTERFACE_MAP_ENTRY(nsISupports)
+NS_INTERFACE_MAP_END
+
+NS_IMPL_CYCLE_COLLECTING_ADDREF(L10nReadyHandler)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(L10nReadyHandler)
+
+void DocumentL10n::TriggerInitialTranslation() {
+ MOZ_ASSERT(nsContentUtils::IsSafeToRunScript());
+ if (mState >= DocumentL10nState::InitialTranslationTriggered) {
+ return;
+ }
+ if (!mReady) {
+ // If we don't have `mReady` it means that we are in shutdown mode.
+ // See bug 1687118 for details.
+ InitialTranslationCompleted(false);
+ return;
+ }
+
+ mInitialTranslationStart = mozilla::TimeStamp::Now();
+
+ AutoAllowLegacyScriptExecution exemption;
+
+ nsTArray<RefPtr<Promise>> promises;
+
+ ErrorResult rv;
+ promises.AppendElement(TranslateDocument(rv));
+ if (NS_WARN_IF(rv.Failed())) {
+ rv.SuppressException();
+ InitialTranslationCompleted(false);
+ mReady->MaybeRejectWithUndefined();
+ return;
+ }
+ promises.AppendElement(TranslateRoots(rv));
+ Element* documentElement = mDocument->GetDocumentElement();
+ if (!documentElement) {
+ InitialTranslationCompleted(false);
+ mReady->MaybeRejectWithUndefined();
+ return;
+ }
+
+ DOMLocalization::ConnectRoot(*documentElement);
+
+ AutoEntryScript aes(mGlobal, "DocumentL10n InitialTranslation");
+ RefPtr<Promise> promise = Promise::All(aes.cx(), promises, rv);
+
+ if (promise->State() == Promise::PromiseState::Resolved) {
+ // If the promise is already resolved, we can fast-track
+ // to initial translation completed.
+ InitialTranslationCompleted(true);
+ mReady->MaybeResolveWithUndefined();
+ } else {
+ RefPtr<PromiseNativeHandler> l10nReadyHandler =
+ new L10nReadyHandler(mReady, this);
+ promise->AppendNativeHandler(l10nReadyHandler);
+
+ mState = DocumentL10nState::InitialTranslationTriggered;
+ }
+}
+
+already_AddRefed<Promise> DocumentL10n::TranslateDocument(ErrorResult& aRv) {
+ MOZ_ASSERT(mState == DocumentL10nState::Constructed,
+ "This method should be called only from Constructed state.");
+ RefPtr<Promise> promise = Promise::Create(mGlobal, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return nullptr;
+ }
+
+ Element* elem = mDocument->GetDocumentElement();
+ if (!elem) {
+ promise->MaybeRejectWithUndefined();
+ return promise.forget();
+ }
+
+ // 1. Collect all localizable elements.
+ Sequence<OwningNonNull<Element>> elements;
+ GetTranslatables(*elem, elements, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ promise->MaybeRejectWithUndefined();
+ return promise.forget();
+ }
+
+ RefPtr<nsXULPrototypeDocument> proto = mDocument->GetPrototype();
+
+ // 2. Check if the document has a prototype that may cache
+ // translated elements.
+ if (proto) {
+ // 2.1. Handle the case when we have proto.
+
+ // 2.1.1. Move elements that are not in the proto to a separate
+ // array.
+ Sequence<OwningNonNull<Element>> nonProtoElements;
+
+ uint32_t i = elements.Length();
+ while (i > 0) {
+ Element* elem = elements.ElementAt(i - 1);
+ MOZ_RELEASE_ASSERT(elem->HasAttr(nsGkAtoms::datal10nid));
+ if (!elem->HasElementCreatedFromPrototypeAndHasUnmodifiedL10n()) {
+ if (NS_WARN_IF(!nonProtoElements.AppendElement(*elem, fallible))) {
+ promise->MaybeRejectWithUndefined();
+ return promise.forget();
+ }
+ elements.RemoveElement(elem);
+ }
+ i--;
+ }
+
+ // We populate the sequence in reverse order. Let's bring it
+ // back to top->bottom one.
+ nonProtoElements.Reverse();
+
+ AutoAllowLegacyScriptExecution exemption;
+
+ nsTArray<RefPtr<Promise>> promises;
+
+ // 2.1.2. If we're not loading from cache, push the elements that
+ // are in the prototype to be translated and cached.
+ if (!proto->WasL10nCached() && !elements.IsEmpty()) {
+ RefPtr<Promise> translatePromise =
+ TranslateElements(elements, proto, aRv);
+ if (NS_WARN_IF(!translatePromise || aRv.Failed())) {
+ promise->MaybeRejectWithUndefined();
+ return promise.forget();
+ }
+ promises.AppendElement(translatePromise);
+ }
+
+ // 2.1.3. If there are elements that are not in the prototype,
+ // localize them without attempting to cache and
+ // independently of if we're loading from cache.
+ if (!nonProtoElements.IsEmpty()) {
+ RefPtr<Promise> nonProtoTranslatePromise =
+ TranslateElements(nonProtoElements, nullptr, aRv);
+ if (NS_WARN_IF(!nonProtoTranslatePromise || aRv.Failed())) {
+ promise->MaybeRejectWithUndefined();
+ return promise.forget();
+ }
+ promises.AppendElement(nonProtoTranslatePromise);
+ }
+
+ // 2.1.4. Collect promises with Promise::All (maybe empty).
+ AutoEntryScript aes(mGlobal, "DocumentL10n InitialTranslationCompleted");
+ promise = Promise::All(aes.cx(), promises, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return nullptr;
+ }
+ } else {
+ // 2.2. Handle the case when we don't have proto.
+
+ // 2.2.1. Otherwise, translate all available elements,
+ // without attempting to cache them.
+ promise = TranslateElements(elements, nullptr, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return nullptr;
+ }
+ }
+
+ return promise.forget();
+}
+
+void DocumentL10n::MaybeRecordTelemetry() {
+ mozilla::TimeStamp initialTranslationEnd = mozilla::TimeStamp::Now();
+
+ nsAutoString documentURI;
+ ErrorResult rv;
+ rv = mDocument->GetDocumentURI(documentURI);
+ if (rv.Failed()) {
+ return;
+ }
+
+ nsCString key;
+
+ if (documentURI.Find(u"chrome://browser/content/browser.xhtml") == 0) {
+ if (mIsFirstBrowserWindow) {
+ key = "browser_first_window";
+ mIsFirstBrowserWindow = false;
+ } else {
+ key = "browser_new_window";
+ }
+ } else if (documentURI.Find(u"about:home") == 0) {
+ key = "about:home";
+ } else if (documentURI.Find(u"about:newtab") == 0) {
+ key = "about:newtab";
+ } else if (documentURI.Find(u"about:preferences") == 0) {
+ key = "about:preferences";
+ } else {
+ return;
+ }
+
+ mozilla::TimeDuration totalTime(initialTranslationEnd -
+ mInitialTranslationStart);
+ Accumulate(Telemetry::L10N_DOCUMENT_INITIAL_TRANSLATION_TIME_US, key,
+ totalTime.ToMicroseconds());
+}
+
+void DocumentL10n::InitialTranslationCompleted(bool aL10nCached) {
+ if (mState >= DocumentL10nState::Ready) {
+ return;
+ }
+
+ Element* documentElement = mDocument->GetDocumentElement();
+ if (documentElement) {
+ SetRootInfo(documentElement);
+ }
+
+ mState = DocumentL10nState::Ready;
+
+ MaybeRecordTelemetry();
+
+ RefPtr<Document> doc = mDocument;
+ doc->InitialTranslationCompleted(aL10nCached);
+
+ // In XUL scenario contentSink is nullptr.
+ if (mContentSink) {
+ nsCOMPtr<nsIContentSink> sink = mContentSink.forget();
+ sink->InitialTranslationCompleted();
+ }
+
+ // From now on, the state of Localization is unconditionally
+ // async.
+ SetAsync();
+}
+
+void DocumentL10n::ConnectRoot(nsINode& aNode, bool aTranslate,
+ ErrorResult& aRv) {
+ if (aTranslate) {
+ if (mState >= DocumentL10nState::InitialTranslationTriggered) {
+ RefPtr<Promise> promise = TranslateFragment(aNode, aRv);
+ }
+ }
+ DOMLocalization::ConnectRoot(aNode);
+}
+
+Promise* DocumentL10n::Ready() { return mReady; }
+
+void DocumentL10n::OnCreatePresShell() { mMutations->OnCreatePresShell(); }
diff --git a/dom/l10n/DocumentL10n.h b/dom/l10n/DocumentL10n.h
new file mode 100644
index 0000000000..e7b2fd5189
--- /dev/null
+++ b/dom/l10n/DocumentL10n.h
@@ -0,0 +1,88 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef mozilla_dom_l10n_DocumentL10n_h
+#define mozilla_dom_l10n_DocumentL10n_h
+
+#include "mozilla/dom/DOMLocalization.h"
+
+class nsIContentSink;
+
+namespace mozilla::dom {
+
+class Document;
+
+enum class DocumentL10nState {
+ // State set when the DocumentL10n gets constructed.
+ Constructed = 0,
+
+ // State set when the initial translation got triggered. This happens
+ // if DocumentL10n was constructed during parsing of the document.
+ //
+ // If the DocumentL10n gets constructed later, we'll skip directly to
+ // Ready state.
+ InitialTranslationTriggered,
+
+ // State set the DocumentL10n has been fully initialized, potentially
+ // with initial translation being completed.
+ Ready,
+};
+
+/**
+ * This class maintains localization status of the document.
+ *
+ * The document will initialize it lazily when a link with a localization
+ * resource is added to the document.
+ *
+ * Once initialized, DocumentL10n relays all API methods to an
+ * instance of mozILocalization and maintains a single promise
+ * which gets resolved the first time the document gets translated.
+ */
+class DocumentL10n final : public DOMLocalization {
+ public:
+ NS_DECL_ISUPPORTS_INHERITED
+ NS_DECL_CYCLE_COLLECTION_CLASS_INHERITED(DocumentL10n, DOMLocalization)
+
+ static RefPtr<DocumentL10n> Create(Document* aDocument, bool aSync);
+
+ protected:
+ explicit DocumentL10n(Document* aDocument, bool aSync);
+ virtual ~DocumentL10n() = default;
+
+ RefPtr<Document> mDocument;
+ RefPtr<Promise> mReady;
+ DocumentL10nState mState;
+ nsCOMPtr<nsIContentSink> mContentSink;
+
+ public:
+ virtual JSObject* WrapObject(JSContext* aCx,
+ JS::Handle<JSObject*> aGivenProto) override;
+
+ Promise* Ready();
+
+ void TriggerInitialTranslation();
+ already_AddRefed<Promise> TranslateDocument(ErrorResult& aRv);
+
+ void InitialTranslationCompleted(bool aL10nCached);
+
+ Document* GetDocument() const { return mDocument; };
+ void OnCreatePresShell();
+
+ void ConnectRoot(nsINode& aNode, bool aTranslate, ErrorResult& aRv);
+
+ DocumentL10nState GetState() { return mState; };
+
+ void MaybeRecordTelemetry();
+
+ bool mBlockingLayout = false;
+
+ mozilla::TimeStamp mInitialTranslationStart;
+ static bool mIsFirstBrowserWindow;
+};
+
+} // namespace mozilla::dom
+
+#endif // mozilla_dom_l10n_DocumentL10n_h
diff --git a/dom/l10n/L10nMutations.cpp b/dom/l10n/L10nMutations.cpp
new file mode 100644
index 0000000000..63e30c2d83
--- /dev/null
+++ b/dom/l10n/L10nMutations.cpp
@@ -0,0 +1,362 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "L10nMutations.h"
+#include "mozilla/dom/DocumentInlines.h"
+#include "nsRefreshDriver.h"
+#include "DOMLocalization.h"
+#include "mozilla/intl/Localization.h"
+#include "nsThreadManager.h"
+
+using namespace mozilla;
+using namespace mozilla::intl;
+using namespace mozilla::dom;
+
+NS_IMPL_CYCLE_COLLECTION_CLASS(L10nMutations)
+
+NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(L10nMutations)
+ NS_IMPL_CYCLE_COLLECTION_UNLINK(mPendingElements)
+ NS_IMPL_CYCLE_COLLECTION_UNLINK(mPendingElementsHash)
+NS_IMPL_CYCLE_COLLECTION_UNLINK_END
+
+NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(L10nMutations)
+ NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mPendingElements)
+ NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mPendingElementsHash)
+NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END
+
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(L10nMutations)
+ NS_INTERFACE_MAP_ENTRY(nsIMutationObserver)
+ NS_INTERFACE_MAP_ENTRY(nsISupports)
+NS_INTERFACE_MAP_END
+
+NS_IMPL_CYCLE_COLLECTING_ADDREF(L10nMutations)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(L10nMutations)
+
+L10nMutations::L10nMutations(DOMLocalization* aDOMLocalization)
+ : mDOMLocalization(aDOMLocalization) {
+ mObserving = true;
+}
+
+L10nMutations::~L10nMutations() {
+ StopRefreshObserver();
+ MOZ_ASSERT(!mDOMLocalization,
+ "DOMLocalization<-->L10nMutations cycle should be broken.");
+}
+
+void L10nMutations::AttributeChanged(Element* aElement, int32_t aNameSpaceID,
+ nsAtom* aAttribute, int32_t aModType,
+ const nsAttrValue* aOldValue) {
+ if (!mObserving) {
+ return;
+ }
+
+ if (aNameSpaceID == kNameSpaceID_None &&
+ (aAttribute == nsGkAtoms::datal10nid ||
+ aAttribute == nsGkAtoms::datal10nargs)) {
+ if (IsInRoots(aElement)) {
+ L10nElementChanged(aElement);
+ }
+ }
+}
+
+void L10nMutations::ContentAppended(nsIContent* aChild) {
+ if (!mObserving) {
+ return;
+ }
+
+ if (!IsInRoots(aChild)) {
+ return;
+ }
+
+ Sequence<OwningNonNull<Element>> elements;
+ for (nsIContent* node = aChild; node; node = node->GetNextSibling()) {
+ if (node->IsElement()) {
+ DOMLocalization::GetTranslatables(*node, elements, IgnoreErrors());
+ }
+ }
+
+ for (auto& elem : elements) {
+ L10nElementChanged(elem);
+ }
+}
+
+void L10nMutations::ContentInserted(nsIContent* aChild) {
+ if (!mObserving) {
+ return;
+ }
+
+ if (!aChild->IsElement()) {
+ return;
+ }
+ Element* elem = aChild->AsElement();
+
+ if (!IsInRoots(elem)) {
+ return;
+ }
+
+ Sequence<OwningNonNull<Element>> elements;
+ DOMLocalization::GetTranslatables(*aChild, elements, IgnoreErrors());
+
+ for (auto& elem : elements) {
+ L10nElementChanged(elem);
+ }
+}
+
+void L10nMutations::ContentRemoved(nsIContent* aChild,
+ nsIContent* aPreviousSibling) {
+ if (!mObserving || mPendingElements.IsEmpty()) {
+ return;
+ }
+
+ Element* elem = Element::FromNode(*aChild);
+ if (!elem || !IsInRoots(elem)) {
+ return;
+ }
+
+ Sequence<OwningNonNull<Element>> elements;
+ DOMLocalization::GetTranslatables(*aChild, elements, IgnoreErrors());
+
+ for (auto& elem : elements) {
+ if (mPendingElementsHash.EnsureRemoved(elem)) {
+ mPendingElements.RemoveElement(elem);
+ }
+ }
+
+ if (!HasPendingMutations()) {
+ nsContentUtils::AddScriptRunner(NewRunnableMethod(
+ "MaybeFirePendingTranslationsFinished", this,
+ &L10nMutations::MaybeFirePendingTranslationsFinished));
+ }
+}
+
+void L10nMutations::L10nElementChanged(Element* aElement) {
+ const bool wasEmpty = mPendingElements.IsEmpty();
+
+ if (mPendingElementsHash.EnsureInserted(aElement)) {
+ mPendingElements.AppendElement(aElement);
+ }
+
+ if (!wasEmpty) {
+ return;
+ }
+
+ if (!mRefreshDriver) {
+ StartRefreshObserver();
+ }
+
+ if (!mBlockingLoad) {
+ Document* doc = GetDocument();
+ if (doc && doc->GetReadyStateEnum() != Document::READYSTATE_COMPLETE) {
+ doc->BlockOnload();
+ mBlockingLoad = true;
+ }
+ }
+
+ if (mBlockingLoad && !mPendingBlockingLoadFlush) {
+ // We want to make sure we flush translations and don't block the load
+ // indefinitely (and, in fact, that we do it rather soon, even if the
+ // refresh driver is not ticking yet).
+ //
+ // In some platforms (mainly Wayland) the load of the main document
+ // causes vsync to start running and start ticking the refresh driver,
+ // so we can't rely on the refresh driver ticking yet.
+ RefPtr<nsIRunnable> task =
+ NewRunnableMethod("FlushPendingTranslationsBeforeLoad", this,
+ &L10nMutations::FlushPendingTranslationsBeforeLoad);
+ nsThreadManager::get().DispatchDirectTaskToCurrentThread(task);
+ mPendingBlockingLoadFlush = true;
+ }
+}
+
+void L10nMutations::PauseObserving() { mObserving = false; }
+
+void L10nMutations::ResumeObserving() { mObserving = true; }
+
+void L10nMutations::WillRefresh(mozilla::TimeStamp aTime) {
+ StopRefreshObserver();
+ FlushPendingTranslations();
+}
+
+/**
+ * The handler for the `TranslateElements` promise used to turn
+ * a potential rejection into a console warning.
+ **/
+class L10nMutationFinalizationHandler final : public PromiseNativeHandler {
+ public:
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_CLASS(L10nMutationFinalizationHandler)
+
+ explicit L10nMutationFinalizationHandler(L10nMutations* aMutations,
+ nsIGlobalObject* aGlobal)
+ : mMutations(aMutations), mGlobal(aGlobal) {}
+
+ MOZ_CAN_RUN_SCRIPT void Settled() {
+ if (RefPtr mutations = mMutations) {
+ mutations->PendingPromiseSettled();
+ }
+ }
+
+ MOZ_CAN_RUN_SCRIPT void ResolvedCallback(JSContext* aCx,
+ JS::Handle<JS::Value> aValue,
+ ErrorResult& aRv) override {
+ Settled();
+ }
+
+ MOZ_CAN_RUN_SCRIPT void RejectedCallback(JSContext* aCx,
+ JS::Handle<JS::Value> aValue,
+ ErrorResult& aRv) override {
+ nsTArray<nsCString> errors{
+ "[dom/l10n] Errors during l10n mutation frame."_ns,
+ };
+ MaybeReportErrorsToGecko(errors, IgnoreErrors(), mGlobal);
+ Settled();
+ }
+
+ private:
+ ~L10nMutationFinalizationHandler() = default;
+
+ RefPtr<L10nMutations> mMutations;
+ nsCOMPtr<nsIGlobalObject> mGlobal;
+};
+
+NS_IMPL_CYCLE_COLLECTION(L10nMutationFinalizationHandler, mGlobal, mMutations)
+
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(L10nMutationFinalizationHandler)
+ NS_INTERFACE_MAP_ENTRY(nsISupports)
+NS_INTERFACE_MAP_END
+
+NS_IMPL_CYCLE_COLLECTING_ADDREF(L10nMutationFinalizationHandler)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(L10nMutationFinalizationHandler)
+
+void L10nMutations::FlushPendingTranslationsBeforeLoad() {
+ MOZ_ASSERT(mPendingBlockingLoadFlush);
+ mPendingBlockingLoadFlush = false;
+ FlushPendingTranslations();
+}
+
+void L10nMutations::FlushPendingTranslations() {
+ if (!mDOMLocalization) {
+ return;
+ }
+
+ nsTArray<OwningNonNull<Element>> elements;
+ for (auto& elem : mPendingElements) {
+ if (elem->HasAttr(nsGkAtoms::datal10nid)) {
+ elements.AppendElement(*elem);
+ }
+ }
+
+ mPendingElementsHash.Clear();
+ mPendingElements.Clear();
+
+ RefPtr<Promise> promise =
+ mDOMLocalization->TranslateElements(elements, IgnoreErrors());
+ if (promise && promise->State() == Promise::PromiseState::Pending) {
+ mPendingPromises++;
+ auto l10nMutationFinalizationHandler =
+ MakeRefPtr<L10nMutationFinalizationHandler>(
+ this, mDOMLocalization->GetParentObject());
+ promise->AppendNativeHandler(l10nMutationFinalizationHandler);
+ }
+
+ MaybeFirePendingTranslationsFinished();
+}
+
+void L10nMutations::PendingPromiseSettled() {
+ MOZ_DIAGNOSTIC_ASSERT(mPendingPromises);
+ mPendingPromises--;
+ MaybeFirePendingTranslationsFinished();
+}
+
+void L10nMutations::MaybeFirePendingTranslationsFinished() {
+ if (HasPendingMutations()) {
+ return;
+ }
+
+ RefPtr doc = GetDocument();
+ if (NS_WARN_IF(!doc)) {
+ return;
+ }
+
+ if (mBlockingLoad) {
+ mBlockingLoad = false;
+ doc->UnblockOnload(false);
+ }
+ nsContentUtils::DispatchEventOnlyToChrome(
+ doc, ToSupports(doc), u"L10nMutationsFinished"_ns, CanBubble::eNo,
+ Cancelable::eNo, Composed::eNo, nullptr);
+}
+
+void L10nMutations::Disconnect() {
+ StopRefreshObserver();
+ mDOMLocalization = nullptr;
+}
+
+Document* L10nMutations::GetDocument() const {
+ if (!mDOMLocalization) {
+ return nullptr;
+ }
+ auto* innerWindow = mDOMLocalization->GetParentObject()->AsInnerWindow();
+ if (!innerWindow) {
+ return nullptr;
+ }
+ return innerWindow->GetExtantDoc();
+}
+
+void L10nMutations::StartRefreshObserver() {
+ if (!mDOMLocalization || mRefreshDriver) {
+ return;
+ }
+ if (Document* doc = GetDocument()) {
+ if (nsPresContext* ctx = doc->GetPresContext()) {
+ mRefreshDriver = ctx->RefreshDriver();
+ }
+ }
+
+ // If we can't start the refresh driver, it means
+ // that the presContext is not available yet.
+ // In that case, we'll trigger the flush of pending
+ // elements in Document::CreatePresShell.
+ if (mRefreshDriver) {
+ mRefreshDriver->AddRefreshObserver(this, FlushType::Style,
+ "L10n mutations");
+ } else {
+ NS_WARNING("[l10n][mutations] Failed to start a refresh observer.");
+ }
+}
+
+void L10nMutations::StopRefreshObserver() {
+ if (!mDOMLocalization) {
+ return;
+ }
+
+ if (mRefreshDriver) {
+ mRefreshDriver->RemoveRefreshObserver(this, FlushType::Style);
+ mRefreshDriver = nullptr;
+ }
+}
+
+void L10nMutations::OnCreatePresShell() {
+ if (!mPendingElements.IsEmpty()) {
+ StartRefreshObserver();
+ }
+}
+
+bool L10nMutations::IsInRoots(nsINode* aNode) {
+ // If the root of the mutated element is in the light DOM,
+ // we know it must be covered by our observer directly.
+ //
+ // Otherwise, we need to check if its subtree root is the same
+ // as any of the `DOMLocalization::mRoots` subtree roots.
+ nsINode* root = aNode->SubtreeRoot();
+
+ // If element is in light DOM, it must be covered by one of
+ // the DOMLocalization roots to end up here.
+ MOZ_ASSERT_IF(!root->IsShadowRoot(),
+ mDOMLocalization->SubtreeRootInRoots(root));
+
+ return !root->IsShadowRoot() || mDOMLocalization->SubtreeRootInRoots(root);
+}
diff --git a/dom/l10n/L10nMutations.h b/dom/l10n/L10nMutations.h
new file mode 100644
index 0000000000..afc08445e0
--- /dev/null
+++ b/dom/l10n/L10nMutations.h
@@ -0,0 +1,101 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef mozilla_dom_l10n_L10nMutations_h
+#define mozilla_dom_l10n_L10nMutations_h
+
+#include "nsCycleCollectionParticipant.h"
+#include "nsHashKeys.h"
+#include "nsRefreshObservers.h"
+#include "nsStubMutationObserver.h"
+#include "nsTHashSet.h"
+
+class nsRefreshDriver;
+
+namespace mozilla::dom {
+class Document;
+class DOMLocalization;
+/**
+ * L10nMutations manage observing roots for localization
+ * changes and coalescing pending translations into
+ * batches - one per animation frame.
+ */
+class L10nMutations final : public nsStubMultiMutationObserver,
+ public nsARefreshObserver {
+ public:
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_CLASS_AMBIGUOUS(L10nMutations, nsIMutationObserver)
+ NS_DECL_NSIMUTATIONOBSERVER_CONTENTAPPENDED
+ NS_DECL_NSIMUTATIONOBSERVER_CONTENTINSERTED
+ NS_DECL_NSIMUTATIONOBSERVER_CONTENTREMOVED
+ NS_DECL_NSIMUTATIONOBSERVER_ATTRIBUTECHANGED
+
+ explicit L10nMutations(DOMLocalization* aDOMLocalization);
+
+ /**
+ * Pause root observation.
+ * This is useful for injecting already-translated
+ * content into an observed root, without causing
+ * superflues translation.
+ */
+ void PauseObserving();
+
+ /**
+ * Resume root observation.
+ */
+ void ResumeObserving();
+
+ /**
+ * Disconnect roots, stop refresh observer
+ * and break the cycle collection deadlock
+ * by removing the reference to mDOMLocalization.
+ */
+ void Disconnect();
+
+ /**
+ * Called when PresShell gets created for the document.
+ * If there are already pending mutations, this
+ * will schedule the refresh driver to translate them.
+ */
+ void OnCreatePresShell();
+
+ bool HasPendingMutations() const {
+ return !mPendingElements.IsEmpty() || mPendingPromises;
+ }
+
+ MOZ_CAN_RUN_SCRIPT void PendingPromiseSettled();
+
+ private:
+ bool mObserving = false;
+ bool mBlockingLoad = false;
+ bool mPendingBlockingLoadFlush = false;
+ uint32_t mPendingPromises = 0;
+ RefPtr<nsRefreshDriver> mRefreshDriver;
+ DOMLocalization* mDOMLocalization;
+
+ // The hash is used to speed up lookups into mPendingElements, which we need
+ // to guarantee some consistent ordering of operations.
+ nsTHashSet<RefPtr<Element>> mPendingElementsHash;
+ nsTArray<RefPtr<Element>> mPendingElements;
+
+ Document* GetDocument() const;
+
+ MOZ_CAN_RUN_SCRIPT void WillRefresh(mozilla::TimeStamp aTime) override;
+
+ void StartRefreshObserver();
+ void StopRefreshObserver();
+ void L10nElementChanged(Element* aElement);
+ MOZ_CAN_RUN_SCRIPT void FlushPendingTranslations();
+ MOZ_CAN_RUN_SCRIPT void FlushPendingTranslationsBeforeLoad();
+ MOZ_CAN_RUN_SCRIPT void MaybeFirePendingTranslationsFinished();
+
+ ~L10nMutations();
+ bool IsInRoots(nsINode* aNode);
+};
+
+} // namespace mozilla::dom
+
+#endif // mozilla_dom_l10n_L10nMutations_h__
diff --git a/dom/l10n/L10nOverlays.cpp b/dom/l10n/L10nOverlays.cpp
new file mode 100644
index 0000000000..716479b381
--- /dev/null
+++ b/dom/l10n/L10nOverlays.cpp
@@ -0,0 +1,557 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "L10nOverlays.h"
+#include "mozilla/dom/HTMLTemplateElement.h"
+#include "mozilla/dom/HTMLInputElement.h"
+#include "HTMLSplitOnSpacesTokenizer.h"
+#include "nsHtml5StringParser.h"
+#include "nsTextNode.h"
+#include "nsIParserUtils.h"
+
+using namespace mozilla::dom;
+using namespace mozilla;
+
+/**
+ * Check if attribute is allowed for the given element.
+ *
+ * This method is used by the sanitizer when the translation markup contains DOM
+ * attributes, or when the translation has traits which map to DOM attributes.
+ *
+ * `aExplicitlyAllowed` can be passed as a list of attributes explicitly allowed
+ * on this element.
+ */
+static bool IsAttrNameLocalizable(
+ const nsAtom* aAttrName, Element* aElement,
+ const nsTArray<nsString>& aExplicitlyAllowed) {
+ if (!aExplicitlyAllowed.IsEmpty()) {
+ nsAutoString name;
+ aAttrName->ToString(name);
+ if (aExplicitlyAllowed.Contains(name)) {
+ return true;
+ }
+ }
+
+ nsAtom* elemName = aElement->NodeInfo()->NameAtom();
+ uint32_t nameSpace = aElement->NodeInfo()->NamespaceID();
+
+ if (nameSpace == kNameSpaceID_XHTML) {
+ // Is it a globally safe attribute?
+ if (aAttrName == nsGkAtoms::title || aAttrName == nsGkAtoms::aria_label ||
+ aAttrName == nsGkAtoms::aria_description) {
+ return true;
+ }
+
+ // Is it allowed on this element?
+ if (elemName == nsGkAtoms::a) {
+ return aAttrName == nsGkAtoms::download;
+ }
+ if (elemName == nsGkAtoms::area) {
+ return aAttrName == nsGkAtoms::download || aAttrName == nsGkAtoms::alt;
+ }
+ if (elemName == nsGkAtoms::input) {
+ // Special case for value on HTML inputs with type button, reset, submit
+ if (aAttrName == nsGkAtoms::value) {
+ HTMLInputElement* input = HTMLInputElement::FromNode(aElement);
+ if (input) {
+ auto type = input->ControlType();
+ if (type == FormControlType::InputSubmit ||
+ type == FormControlType::InputButton ||
+ type == FormControlType::InputReset) {
+ return true;
+ }
+ }
+ }
+ return aAttrName == nsGkAtoms::alt || aAttrName == nsGkAtoms::placeholder;
+ }
+ if (elemName == nsGkAtoms::menuitem) {
+ return aAttrName == nsGkAtoms::label;
+ }
+ if (elemName == nsGkAtoms::menu) {
+ return aAttrName == nsGkAtoms::label;
+ }
+ if (elemName == nsGkAtoms::optgroup) {
+ return aAttrName == nsGkAtoms::label;
+ }
+ if (elemName == nsGkAtoms::option) {
+ return aAttrName == nsGkAtoms::label;
+ }
+ if (elemName == nsGkAtoms::track) {
+ return aAttrName == nsGkAtoms::label;
+ }
+ if (elemName == nsGkAtoms::img) {
+ return aAttrName == nsGkAtoms::alt;
+ }
+ if (elemName == nsGkAtoms::textarea) {
+ return aAttrName == nsGkAtoms::placeholder;
+ }
+ if (elemName == nsGkAtoms::th) {
+ return aAttrName == nsGkAtoms::abbr;
+ }
+
+ } else if (nameSpace == kNameSpaceID_XUL) {
+ // Is it a globally safe attribute?
+ if (aAttrName == nsGkAtoms::accesskey ||
+ aAttrName == nsGkAtoms::aria_label || aAttrName == nsGkAtoms::label ||
+ aAttrName == nsGkAtoms::title || aAttrName == nsGkAtoms::tooltiptext) {
+ return true;
+ }
+
+ // Is it allowed on this element?
+ if (elemName == nsGkAtoms::description) {
+ return aAttrName == nsGkAtoms::value;
+ }
+ if (elemName == nsGkAtoms::key) {
+ return aAttrName == nsGkAtoms::key || aAttrName == nsGkAtoms::keycode;
+ }
+ if (elemName == nsGkAtoms::label) {
+ return aAttrName == nsGkAtoms::value;
+ }
+ }
+
+ return false;
+}
+
+already_AddRefed<nsINode> L10nOverlays::CreateTextNodeFromTextContent(
+ Element* aElement, ErrorResult& aRv) {
+ nsAutoString content;
+ aElement->GetTextContent(content, aRv);
+
+ if (NS_WARN_IF(aRv.Failed())) {
+ return nullptr;
+ }
+
+ return aElement->OwnerDoc()->CreateTextNode(content);
+}
+
+class AttributeNameValueComparator {
+ public:
+ bool Equals(const AttributeNameValue& aAttribute,
+ const nsAttrName* aAttrName) const {
+ return aAttrName->Equals(NS_ConvertUTF8toUTF16(aAttribute.mName));
+ }
+};
+
+void L10nOverlays::OverlayAttributes(
+ const Nullable<Sequence<AttributeNameValue>>& aTranslation,
+ Element* aToElement, ErrorResult& aRv) {
+ nsTArray<nsString> explicitlyAllowed;
+
+ {
+ nsAutoString l10nAttrs;
+ if (aToElement->GetAttr(kNameSpaceID_None, nsGkAtoms::datal10nattrs,
+ l10nAttrs)) {
+ HTMLSplitOnSpacesTokenizer tokenizer(l10nAttrs, ',');
+ while (tokenizer.hasMoreTokens()) {
+ const nsAString& token = tokenizer.nextToken();
+ if (!token.IsEmpty() && !explicitlyAllowed.Contains(token)) {
+ explicitlyAllowed.AppendElement(token);
+ }
+ }
+ }
+ }
+
+ uint32_t i = aToElement->GetAttrCount();
+ while (i > 0) {
+ const nsAttrName* attrName = aToElement->GetAttrNameAt(i - 1);
+
+ if (IsAttrNameLocalizable(attrName->LocalName(), aToElement,
+ explicitlyAllowed) &&
+ (aTranslation.IsNull() ||
+ !aTranslation.Value().Contains(attrName,
+ AttributeNameValueComparator()))) {
+ RefPtr<nsAtom> localName = attrName->LocalName();
+ aToElement->UnsetAttr(localName, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return;
+ }
+ }
+ i--;
+ }
+
+ if (aTranslation.IsNull()) {
+ return;
+ }
+
+ for (auto& attribute : aTranslation.Value()) {
+ RefPtr<nsAtom> nameAtom = NS_Atomize(attribute.mName);
+ if (IsAttrNameLocalizable(nameAtom, aToElement, explicitlyAllowed)) {
+ NS_ConvertUTF8toUTF16 value(attribute.mValue);
+ if (!aToElement->AttrValueIs(kNameSpaceID_None, nameAtom, value,
+ eCaseMatters)) {
+ aToElement->SetAttr(nameAtom, value, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return;
+ }
+ }
+ }
+ }
+}
+
+void L10nOverlays::OverlayAttributes(Element* aFromElement, Element* aToElement,
+ ErrorResult& aRv) {
+ Nullable<Sequence<AttributeNameValue>> attributes;
+ uint32_t attrCount = aFromElement->GetAttrCount();
+
+ if (attrCount == 0) {
+ attributes.SetNull();
+ } else {
+ Sequence<AttributeNameValue> sequence;
+
+ uint32_t i = 0;
+ while (BorrowedAttrInfo info = aFromElement->GetAttrInfoAt(i++)) {
+ AttributeNameValue* attr = sequence.AppendElement(fallible);
+ MOZ_ASSERT(info.mName->NamespaceEquals(kNameSpaceID_None),
+ "No namespaced attributes allowed.");
+ info.mName->LocalName()->ToUTF8String(attr->mName);
+
+ nsAutoString value;
+ info.mValue->ToString(value);
+ attr->mValue.Assign(NS_ConvertUTF16toUTF8(value));
+ }
+
+ attributes.SetValue(sequence);
+ }
+
+ return OverlayAttributes(attributes, aToElement, aRv);
+}
+
+void L10nOverlays::ShallowPopulateUsing(Element* aFromElement,
+ Element* aToElement, ErrorResult& aRv) {
+ nsAutoString content;
+ aFromElement->GetTextContent(content, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return;
+ }
+
+ aToElement->SetTextContent(content, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return;
+ }
+
+ OverlayAttributes(aFromElement, aToElement, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return;
+ }
+}
+
+already_AddRefed<nsINode> L10nOverlays::GetNodeForNamedElement(
+ Element* aSourceElement, Element* aTranslatedChild,
+ nsTArray<L10nOverlaysError>& aErrors, ErrorResult& aRv) {
+ nsAutoString childName;
+ aTranslatedChild->GetAttr(kNameSpaceID_None, nsGkAtoms::datal10nname,
+ childName);
+ RefPtr<Element> sourceChild = nullptr;
+
+ nsINodeList* childNodes = aSourceElement->ChildNodes();
+ for (uint32_t i = 0; i < childNodes->Length(); i++) {
+ nsINode* childNode = childNodes->Item(i);
+
+ if (!childNode->IsElement()) {
+ continue;
+ }
+ Element* childElement = childNode->AsElement();
+
+ if (childElement->AttrValueIs(kNameSpaceID_None, nsGkAtoms::datal10nname,
+ childName, eCaseMatters)) {
+ sourceChild = childElement;
+ break;
+ }
+ }
+
+ if (!sourceChild) {
+ L10nOverlaysError error;
+ error.mCode.Construct(L10nOverlays_Binding::ERROR_NAMED_ELEMENT_MISSING);
+ error.mL10nName.Construct(childName);
+ aErrors.AppendElement(error);
+ return CreateTextNodeFromTextContent(aTranslatedChild, aRv);
+ }
+
+ nsAtom* sourceChildName = sourceChild->NodeInfo()->NameAtom();
+ nsAtom* translatedChildName = aTranslatedChild->NodeInfo()->NameAtom();
+ if (sourceChildName != translatedChildName &&
+ // Create a specific exception for img vs. image mismatches,
+ // see bug 1543493
+ !(translatedChildName == nsGkAtoms::img &&
+ sourceChildName == nsGkAtoms::image)) {
+ L10nOverlaysError error;
+ error.mCode.Construct(
+ L10nOverlays_Binding::ERROR_NAMED_ELEMENT_TYPE_MISMATCH);
+ error.mL10nName.Construct(childName);
+ error.mTranslatedElementName.Construct(
+ aTranslatedChild->NodeInfo()->LocalName());
+ error.mSourceElementName.Construct(sourceChild->NodeInfo()->LocalName());
+ aErrors.AppendElement(error);
+ return CreateTextNodeFromTextContent(aTranslatedChild, aRv);
+ }
+
+ aSourceElement->RemoveChild(*sourceChild, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return nullptr;
+ }
+ RefPtr<nsINode> clone = sourceChild->CloneNode(false, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return nullptr;
+ }
+ ShallowPopulateUsing(aTranslatedChild, clone->AsElement(), aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return nullptr;
+ }
+ return clone.forget();
+}
+
+bool L10nOverlays::IsElementAllowed(Element* aElement) {
+ uint32_t nameSpace = aElement->NodeInfo()->NamespaceID();
+ if (nameSpace != kNameSpaceID_XHTML) {
+ return false;
+ }
+
+ nsAtom* nameAtom = aElement->NodeInfo()->NameAtom();
+
+ return nameAtom == nsGkAtoms::em || nameAtom == nsGkAtoms::strong ||
+ nameAtom == nsGkAtoms::small || nameAtom == nsGkAtoms::s ||
+ nameAtom == nsGkAtoms::cite || nameAtom == nsGkAtoms::q ||
+ nameAtom == nsGkAtoms::dfn || nameAtom == nsGkAtoms::abbr ||
+ nameAtom == nsGkAtoms::data || nameAtom == nsGkAtoms::time ||
+ nameAtom == nsGkAtoms::code || nameAtom == nsGkAtoms::var ||
+ nameAtom == nsGkAtoms::samp || nameAtom == nsGkAtoms::kbd ||
+ nameAtom == nsGkAtoms::sub || nameAtom == nsGkAtoms::sup ||
+ nameAtom == nsGkAtoms::i || nameAtom == nsGkAtoms::b ||
+ nameAtom == nsGkAtoms::u || nameAtom == nsGkAtoms::mark ||
+ nameAtom == nsGkAtoms::bdi || nameAtom == nsGkAtoms::bdo ||
+ nameAtom == nsGkAtoms::span || nameAtom == nsGkAtoms::br ||
+ nameAtom == nsGkAtoms::wbr;
+}
+
+already_AddRefed<Element> L10nOverlays::CreateSanitizedElement(
+ Element* aElement, ErrorResult& aRv) {
+ // Start with an empty element of the same type to remove nested children
+ // and non-localizable attributes defined by the translation.
+
+ nsAutoString nameSpaceURI;
+ aElement->NodeInfo()->GetNamespaceURI(nameSpaceURI);
+ ElementCreationOptionsOrString options;
+ RefPtr<Element> clone = aElement->OwnerDoc()->CreateElementNS(
+ nameSpaceURI, aElement->NodeInfo()->LocalName(), options, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return nullptr;
+ }
+
+ ShallowPopulateUsing(aElement, clone, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return nullptr;
+ }
+ return clone.forget();
+}
+
+void L10nOverlays::OverlayChildNodes(DocumentFragment* aFromFragment,
+ Element* aToElement,
+ nsTArray<L10nOverlaysError>& aErrors,
+ ErrorResult& aRv) {
+ nsINodeList* childNodes = aFromFragment->ChildNodes();
+ for (uint32_t i = 0; i < childNodes->Length(); i++) {
+ nsINode* childNode = childNodes->Item(i);
+
+ if (!childNode->IsElement()) {
+ // Keep the translated text node.
+ continue;
+ }
+
+ RefPtr<Element> childElement = childNode->AsElement();
+
+ if (childElement->HasAttr(kNameSpaceID_None, nsGkAtoms::datal10nname)) {
+ RefPtr<nsINode> sanitized =
+ GetNodeForNamedElement(aToElement, childElement, aErrors, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return;
+ }
+ aFromFragment->ReplaceChild(*sanitized, *childNode, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return;
+ }
+ continue;
+ }
+
+ if (IsElementAllowed(childElement)) {
+ RefPtr<Element> sanitized = CreateSanitizedElement(childElement, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return;
+ }
+ aFromFragment->ReplaceChild(*sanitized, *childNode, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return;
+ }
+ continue;
+ }
+
+ L10nOverlaysError error;
+ error.mCode.Construct(L10nOverlays_Binding::ERROR_FORBIDDEN_TYPE);
+ error.mTranslatedElementName.Construct(
+ childElement->NodeInfo()->LocalName());
+ aErrors.AppendElement(error);
+
+ // If all else fails, replace the element with its text content.
+ RefPtr<nsINode> textNode = CreateTextNodeFromTextContent(childElement, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return;
+ }
+
+ aFromFragment->ReplaceChild(*textNode, *childNode, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return;
+ }
+ }
+
+ while (aToElement->HasChildren()) {
+ nsIContent* child = aToElement->GetLastChild();
+#ifdef DEBUG
+ if (child->IsElement()) {
+ if (child->AsElement()->HasAttr(kNameSpaceID_None,
+ nsGkAtoms::datal10nid)) {
+ L10nOverlaysError error;
+ error.mCode.Construct(
+ L10nOverlays_Binding::ERROR_TRANSLATED_ELEMENT_DISCONNECTED);
+ nsAutoString id;
+ child->AsElement()->GetAttr(nsGkAtoms::datal10nid, id);
+ error.mL10nName.Construct(id);
+ error.mTranslatedElementName.Construct(
+ aToElement->NodeInfo()->LocalName());
+ aErrors.AppendElement(error);
+ } else if (child->AsElement()->ChildElementCount() > 0) {
+ L10nOverlaysError error;
+ error.mCode.Construct(
+ L10nOverlays_Binding::ERROR_TRANSLATED_ELEMENT_DISALLOWED_DOM);
+ nsAutoString id;
+ aToElement->GetAttr(nsGkAtoms::datal10nid, id);
+ error.mL10nName.Construct(id);
+ error.mTranslatedElementName.Construct(
+ aToElement->NodeInfo()->LocalName());
+ aErrors.AppendElement(error);
+ }
+ }
+#endif
+ aToElement->RemoveChildNode(child, true);
+ }
+ aToElement->AppendChild(*aFromFragment, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return;
+ }
+}
+
+void L10nOverlays::TranslateElement(
+ const GlobalObject& aGlobal, Element& aElement,
+ const L10nMessage& aTranslation,
+ Nullable<nsTArray<L10nOverlaysError>>& aErrors) {
+ nsTArray<L10nOverlaysError> errors;
+
+ ErrorResult rv;
+
+ TranslateElement(aElement, aTranslation, errors, rv);
+ if (NS_WARN_IF(rv.Failed())) {
+ L10nOverlaysError error;
+ error.mCode.Construct(L10nOverlays_Binding::ERROR_UNKNOWN);
+ errors.AppendElement(error);
+ }
+ if (!errors.IsEmpty()) {
+ aErrors.SetValue(std::move(errors));
+ }
+}
+
+bool L10nOverlays::ContainsMarkup(const nsACString& aStr) {
+ // We use our custom ContainsMarkup rather than the
+ // one from FragmentOrElement.cpp, because we don't
+ // want to trigger HTML parsing on every `Preferences & Options`
+ // type of string.
+ const char* start = aStr.BeginReading();
+ const char* end = aStr.EndReading();
+
+ while (start != end) {
+ char c = *start;
+ if (c == '<') {
+ return true;
+ }
+ ++start;
+
+ if (c == '&' && start != end) {
+ c = *start;
+ if (c == '#' || (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') ||
+ (c >= 'A' && c <= 'Z')) {
+ return true;
+ }
+ ++start;
+ }
+ }
+
+ return false;
+}
+
+void L10nOverlays::TranslateElement(Element& aElement,
+ const L10nMessage& aTranslation,
+ nsTArray<L10nOverlaysError>& aErrors,
+ ErrorResult& aRv) {
+ if (!aTranslation.mValue.IsVoid()) {
+ NodeInfo* nodeInfo = aElement.NodeInfo();
+ if (nodeInfo->NameAtom() == nsGkAtoms::title &&
+ nodeInfo->NamespaceID() == kNameSpaceID_XHTML) {
+ // A special case for the HTML title element whose content must be text.
+ aElement.SetTextContent(NS_ConvertUTF8toUTF16(aTranslation.mValue), aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return;
+ }
+ } else if (!ContainsMarkup(aTranslation.mValue)) {
+#ifdef DEBUG
+ if (aElement.ChildElementCount() > 0) {
+ L10nOverlaysError error;
+ error.mCode.Construct(
+ L10nOverlays_Binding::ERROR_TRANSLATED_ELEMENT_DISALLOWED_DOM);
+ nsAutoString id;
+ aElement.GetAttr(nsGkAtoms::datal10nid, id);
+ error.mL10nName.Construct(id);
+ error.mTranslatedElementName.Construct(
+ aElement.GetLastElementChild()->NodeInfo()->LocalName());
+ aErrors.AppendElement(error);
+ }
+#endif
+ // If the translation doesn't contain any markup skip the overlay logic.
+ aElement.SetTextContent(NS_ConvertUTF8toUTF16(aTranslation.mValue), aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return;
+ }
+ } else {
+ // Else parse the translation's HTML into a DocumentFragment,
+ // sanitize it and replace the element's content.
+ RefPtr<DocumentFragment> fragment =
+ new (aElement.OwnerDoc()->NodeInfoManager())
+ DocumentFragment(aElement.OwnerDoc()->NodeInfoManager());
+ // Note: these flags should be no less restrictive than the ones in
+ // nsContentUtils::ParseFragmentHTML .
+ // We supply the flags here because otherwise the parsing of HTML can
+ // trip DEBUG-only crashes, see bug 1809902 for details.
+ auto sanitizationFlags = nsIParserUtils::SanitizerDropForms |
+ nsIParserUtils::SanitizerLogRemovals;
+ nsContentUtils::ParseFragmentHTML(
+ NS_ConvertUTF8toUTF16(aTranslation.mValue), fragment,
+ nsGkAtoms::_template, kNameSpaceID_XHTML, false, true,
+ sanitizationFlags);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return;
+ }
+
+ OverlayChildNodes(fragment, &aElement, aErrors, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return;
+ }
+ }
+ }
+
+ // Even if the translation doesn't define any localizable attributes, run
+ // overlayAttributes to remove any localizable attributes set by previous
+ // translations.
+ OverlayAttributes(aTranslation.mAttributes, &aElement, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return;
+ }
+}
diff --git a/dom/l10n/L10nOverlays.h b/dom/l10n/L10nOverlays.h
new file mode 100644
index 0000000000..95b1714761
--- /dev/null
+++ b/dom/l10n/L10nOverlays.h
@@ -0,0 +1,111 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef mozilla_dom_l10n_L10nOverlays_h
+#define mozilla_dom_l10n_L10nOverlays_h
+
+#include "mozilla/dom/L10nOverlaysBinding.h"
+#include "mozilla/dom/LocalizationBinding.h"
+
+class nsINode;
+
+namespace mozilla::dom {
+
+class DocumentFragment;
+class Element;
+
+class L10nOverlays {
+ public:
+ /**
+ * Translate an element.
+ *
+ * Translate the element's text content and attributes. Some HTML markup is
+ * allowed in the translation. The element's children with the data-l10n-name
+ * attribute will be treated as arguments to the translation. If the
+ * translation defines the same children, their attributes and text contents
+ * will be used for translating the matching source child.
+ */
+ static void TranslateElement(const GlobalObject& aGlobal, Element& aElement,
+ const L10nMessage& aTranslation,
+ Nullable<nsTArray<L10nOverlaysError>>& aErrors);
+ static void TranslateElement(Element& aElement,
+ const L10nMessage& aTranslation,
+ nsTArray<L10nOverlaysError>& aErrors,
+ ErrorResult& aRv);
+
+ private:
+ /**
+ * Create a text node from text content of an Element.
+ */
+ static already_AddRefed<nsINode> CreateTextNodeFromTextContent(
+ Element* aElement, ErrorResult& aRv);
+
+ /**
+ * Transplant localizable attributes of an element to another element.
+ *
+ * Any localizable attributes already set on the target element will be
+ * cleared.
+ */
+ static void OverlayAttributes(
+ const Nullable<Sequence<AttributeNameValue>>& aTranslation,
+ Element* aToElement, ErrorResult& aRv);
+ static void OverlayAttributes(Element* aFromElement, Element* aToElement,
+ ErrorResult& aRv);
+
+ /**
+ * Helper to set textContent and localizable attributes on an element.
+ */
+ static void ShallowPopulateUsing(Element* aFromElement, Element* aToElement,
+ ErrorResult& aRv);
+
+ /**
+ * Sanitize a child element created by the translation.
+ *
+ * Try to find a corresponding child in sourceElement and use it as the base
+ * for the sanitization. This will preserve functional attributes defined on
+ * the child element in the source HTML.
+ */
+ static already_AddRefed<nsINode> GetNodeForNamedElement(
+ Element* aSourceElement, Element* aTranslatedChild,
+ nsTArray<L10nOverlaysError>& aErrors, ErrorResult& aRv);
+
+ /**
+ * Check if element is allowed in the translation.
+ *
+ * This method is used by the sanitizer when the translation markup contains
+ * an element which is not present in the source code.
+ */
+ static bool IsElementAllowed(Element* aElement);
+
+ /**
+ * Sanitize an allowed element.
+ *
+ * Text-level elements allowed in translations may only use safe attributes
+ * and will have any nested markup stripped to text content.
+ */
+ static already_AddRefed<Element> CreateSanitizedElement(Element* aElement,
+ ErrorResult& aRv);
+
+ /**
+ * Replace child nodes of an element with child nodes of another element.
+ *
+ * The contents of the target element will be cleared and fully replaced with
+ * sanitized contents of the source element.
+ */
+ static void OverlayChildNodes(DocumentFragment* aFromFragment,
+ Element* aToElement,
+ nsTArray<L10nOverlaysError>& aErrors,
+ ErrorResult& aRv);
+
+ /**
+ * A helper used to test if the string contains HTML markup.
+ */
+ static bool ContainsMarkup(const nsACString& aStr);
+};
+
+} // namespace mozilla::dom
+
+#endif
diff --git a/dom/l10n/components.conf b/dom/l10n/components.conf
new file mode 100644
index 0000000000..cbb689e776
--- /dev/null
+++ b/dom/l10n/components.conf
@@ -0,0 +1,14 @@
+Classes = [
+ {
+ 'cid': '{a2017fd2-7d8d-11e9-b492-ab187f765b54}',
+ 'contract_ids': ['@mozilla.org/dom/l10n/localization;1'],
+ 'type': 'mozilla::dom::DOMLocalization',
+ 'headers': ['/dom/l10n/DOMLocalization.h'],
+ },
+ {
+ 'cid': '{8d85597c-3a92-11e9-9ffc-73d225b2d53f}',
+ 'contract_ids': ['@mozilla.org/dom/l10n/overlays;1'],
+ 'type': 'mozilla::dom::L10nOverlays',
+ 'headers': ['/dom/l10n/L10nOverlays.h'],
+ },
+]
diff --git a/dom/l10n/moz.build b/dom/l10n/moz.build
new file mode 100644
index 0000000000..0beb3d668e
--- /dev/null
+++ b/dom/l10n/moz.build
@@ -0,0 +1,35 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Core", "Internationalization")
+
+EXPORTS.mozilla.dom += [
+ "DocumentL10n.h",
+ "DOMLocalization.h",
+ "L10nMutations.h",
+ "L10nOverlays.h",
+]
+
+UNIFIED_SOURCES += [
+ "DocumentL10n.cpp",
+ "DOMLocalization.cpp",
+ "L10nMutations.cpp",
+ "L10nOverlays.cpp",
+]
+
+LOCAL_INCLUDES += [
+ "/dom/base",
+]
+
+FINAL_LIBRARY = "xul"
+
+MOCHITEST_MANIFESTS += ["tests/mochitest/mochitest.ini"]
+MOCHITEST_CHROME_MANIFESTS += ["tests/mochitest/chrome.ini"]
+BROWSER_CHROME_MANIFESTS += ["tests/mochitest/browser.ini"]
+
+if CONFIG["ENABLE_TESTS"]:
+ DIRS += ["tests/gtest"]
diff --git a/dom/l10n/tests/gtest/TestL10nOverlays.cpp b/dom/l10n/tests/gtest/TestL10nOverlays.cpp
new file mode 100644
index 0000000000..c6c9da9939
--- /dev/null
+++ b/dom/l10n/tests/gtest/TestL10nOverlays.cpp
@@ -0,0 +1,77 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "gtest/gtest.h"
+#include "mozilla/dom/L10nOverlays.h"
+#include "mozilla/dom/Document.h"
+#include "mozilla/dom/L10nOverlaysBinding.h"
+#include "mozilla/dom/Element.h"
+#include "mozilla/NullPrincipal.h"
+#include "nsNetUtil.h"
+
+using mozilla::NullPrincipal;
+using namespace mozilla::dom;
+
+static already_AddRefed<Document> SetUpDocument() {
+ nsCOMPtr<nsIURI> uri;
+ NS_NewURI(getter_AddRefs(uri), "about:blank");
+ nsCOMPtr<nsIPrincipal> principal =
+ NullPrincipal::CreateWithoutOriginAttributes();
+ nsCOMPtr<Document> document;
+ nsresult rv = NS_NewDOMDocument(getter_AddRefs(document),
+ u""_ns, // aNamespaceURI
+ u""_ns, // aQualifiedName
+ nullptr, // aDoctype
+ uri, uri, principal,
+ false, // aLoadedAsData
+ nullptr, // aEventObject
+ DocumentFlavorHTML);
+
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return nullptr;
+ }
+ return document.forget();
+}
+
+/**
+ * This test verifies that the basic C++ DOM L10nOverlays API
+ * works correctly.
+ */
+TEST(DOM_L10n_Overlays, Initial)
+{
+ mozilla::ErrorResult rv;
+
+ // 1. Set up an HTML document.
+ nsCOMPtr<Document> doc = SetUpDocument();
+
+ // 2. Create a simple Element with a child.
+ //
+ // <div>
+ // <a data-l10n-name="link" href="https://www.mozilla.org"></a>
+ // </div>
+ //
+ RefPtr<Element> elem = doc->CreateHTMLElement(nsGkAtoms::div);
+ RefPtr<Element> span = doc->CreateHTMLElement(nsGkAtoms::a);
+ span->SetAttribute(u"data-l10n-name"_ns, u"link"_ns, rv);
+ span->SetAttribute(u"href"_ns, u"https://www.mozilla.org"_ns, rv);
+ elem->AppendChild(*span, rv);
+
+ // 3. Create an L10nMessage with a translation for the element.
+ L10nMessage translation;
+ translation.mValue.AssignLiteral(
+ "Hello <a data-l10n-name=\"link\">World</a>.");
+
+ // 4. Translate the element.
+ nsTArray<L10nOverlaysError> errors;
+ L10nOverlays::TranslateElement(*elem, translation, errors, rv);
+
+ nsAutoString textContent;
+ elem->GetInnerHTML(textContent, rv);
+
+ // 5. Verify that the innerHTML matches the expectations.
+ ASSERT_STREQ(NS_ConvertUTF16toUTF8(textContent).get(),
+ "Hello <a data-l10n-name=\"link\" "
+ "href=\"https://www.mozilla.org\">World</a>.");
+}
diff --git a/dom/l10n/tests/gtest/moz.build b/dom/l10n/tests/gtest/moz.build
new file mode 100644
index 0000000000..0e1e2173a6
--- /dev/null
+++ b/dom/l10n/tests/gtest/moz.build
@@ -0,0 +1,11 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+UNIFIED_SOURCES += [
+ "TestL10nOverlays.cpp",
+]
+
+FINAL_LIBRARY = "xul-gtest"
diff --git a/dom/l10n/tests/mochitest/browser.ini b/dom/l10n/tests/mochitest/browser.ini
new file mode 100644
index 0000000000..966be56661
--- /dev/null
+++ b/dom/l10n/tests/mochitest/browser.ini
@@ -0,0 +1,6 @@
+[document_l10n/non-system-principal/browser_resource_uri.js]
+support-files =
+ document_l10n/non-system-principal/test.html
+ document_l10n/non-system-principal/localization/test.ftl
+skip-if =
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
diff --git a/dom/l10n/tests/mochitest/chrome.ini b/dom/l10n/tests/mochitest/chrome.ini
new file mode 100644
index 0000000000..68f3d89d40
--- /dev/null
+++ b/dom/l10n/tests/mochitest/chrome.ini
@@ -0,0 +1,47 @@
+[l10n_overlays/test_attributes.html]
+[l10n_overlays/test_functional_children.html]
+[l10n_overlays/test_text_children.html]
+[l10n_overlays/test_extra_text_markup.html]
+[l10n_overlays/test_l10n_overlays.xhtml]
+[l10n_overlays/test_same_id.html]
+[l10n_overlays/test_same_id_args.html]
+[l10n_overlays/test_title.html]
+
+[l10n_mutations/test_append_content_post_dcl.html]
+[l10n_mutations/test_append_content_pre_dcl.html]
+[l10n_mutations/test_append_fragment_post_dcl.html]
+[l10n_mutations/test_disconnectedRoot_webcomponent.html]
+[l10n_mutations/test_set_attributes.html]
+[l10n_mutations/test_pause_observing.html]
+[l10n_mutations/test_template.html]
+[l10n_mutations/test_remove_element.html]
+[l10n_mutations/test_remove_fragment.html]
+
+[dom_localization/test_attr_sanitized.html]
+[dom_localization/test_getAttributes.html]
+[dom_localization/test_setAttributes.html]
+[dom_localization/test_translateElements.html]
+[dom_localization/test_translateFragment.html]
+[dom_localization/test_connectRoot.html]
+[dom_localization/test_connectRoot_webcomponent.html]
+[dom_localization/test_disconnectRoot.html]
+[dom_localization/test_repeated_l10nid.html]
+[dom_localization/test_translateRoots.html]
+[dom_localization/test_l10n_mutations.html]
+[dom_localization/test_overlay.html]
+[dom_localization/test_overlay_repeated.html]
+[dom_localization/test_overlay_missing_all.html]
+[dom_localization/test_overlay_missing_children.html]
+[dom_localization/test_overlay_sanitized.html]
+[dom_localization/test_domloc.xhtml]
+
+
+[document_l10n/test_docl10n.xhtml]
+[document_l10n/test_docl10n.html]
+[document_l10n/test_docl10n_sync.html]
+[document_l10n/test_docl10n_ready_rejected.html]
+[document_l10n/test_docl10n_removeResourceIds.html]
+[document_l10n/test_docl10n_lazy.html]
+[document_l10n/test_connectRoot_webcomponent.html]
+[document_l10n/test_connectRoot_webcomponent_lazy.html]
+[document_l10n/test_telemetry.html]
diff --git a/dom/l10n/tests/mochitest/document_l10n/README.txt b/dom/l10n/tests/mochitest/document_l10n/README.txt
new file mode 100644
index 0000000000..b798a5039a
--- /dev/null
+++ b/dom/l10n/tests/mochitest/document_l10n/README.txt
@@ -0,0 +1,3 @@
+Tests in this directory cover support for DocumentL10n
+WebIDL API across different use cases such as
+processes, principals and so on.
diff --git a/dom/l10n/tests/mochitest/document_l10n/non-system-principal/README.txt b/dom/l10n/tests/mochitest/document_l10n/non-system-principal/README.txt
new file mode 100644
index 0000000000..d0cc074166
--- /dev/null
+++ b/dom/l10n/tests/mochitest/document_l10n/non-system-principal/README.txt
@@ -0,0 +1,3 @@
+Tests in this directory cover the functionality
+of DocumentL10n WebIDL API in non-system-principal
+scenario.
diff --git a/dom/l10n/tests/mochitest/document_l10n/non-system-principal/browser_resource_uri.js b/dom/l10n/tests/mochitest/document_l10n/non-system-principal/browser_resource_uri.js
new file mode 100644
index 0000000000..a658e88bec
--- /dev/null
+++ b/dom/l10n/tests/mochitest/document_l10n/non-system-principal/browser_resource_uri.js
@@ -0,0 +1,109 @@
+let uri =
+ "chrome://mochitests/content/browser/dom/l10n/tests/mochitest//document_l10n/non-system-principal/";
+let protocol = Services.io
+ .getProtocolHandler("resource")
+ .QueryInterface(Ci.nsIResProtocolHandler);
+
+protocol.setSubstitution("l10n-test", Services.io.newURI(uri));
+
+// Since we want the mock source to work with all locales, we're going
+// to register it for currently used locales, and we'll put the path that
+// doesn't use the `{locale}` component to make it work irrelevant of
+// what locale the mochitest is running in.
+//
+// Notice: we're using a `chrome://` protocol here only for convenience reasons.
+// Real sources should use `resource://` protocol.
+let locales = Services.locale.appLocalesAsBCP47;
+
+// This source is actually using a real `FileSource` instead of a mocked one,
+// because we want to test that fetching real I/O out of the `uri` works in non-system-principal.
+let source = new L10nFileSource("test", "app", locales, `${uri}localization/`);
+L10nRegistry.getInstance().registerSources([source]);
+
+registerCleanupFunction(() => {
+ protocol.setSubstitution("l10n-test", null);
+ L10nRegistry.getInstance().removeSources(["test"]);
+ SpecialPowers.pushPrefEnv({
+ set: [["dom.ipc.processPrelaunch.enabled", true]],
+ });
+});
+
+const kChildPage = getRootDirectory(gTestPath) + "test.html";
+
+const kAboutPagesRegistered = Promise.all([
+ BrowserTestUtils.registerAboutPage(
+ registerCleanupFunction,
+ "test-about-l10n-child",
+ kChildPage,
+ Ci.nsIAboutModule.URI_MUST_LOAD_IN_CHILD |
+ Ci.nsIAboutModule.URI_SAFE_FOR_UNTRUSTED_CONTENT |
+ Ci.nsIAboutModule.ALLOW_SCRIPT
+ ),
+]);
+
+add_task(async () => {
+ // Bug 1640333 - windows fails (sometimes) to ever get document.l10n.ready
+ // if e10s process caching is enabled
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["dom.ipc.processPrelaunch.enabled", false],
+ ["dom.security.skip_about_page_has_csp_assert", true],
+ ],
+ });
+ await kAboutPagesRegistered;
+ await BrowserTestUtils.withNewTab(
+ "about:test-about-l10n-child",
+ async browser => {
+ await SpecialPowers.spawn(browser, [], async function () {
+ let document = content.document;
+ let window = document.defaultView;
+
+ await document.testsReadyPromise;
+
+ let principal = SpecialPowers.wrap(document).nodePrincipal;
+ is(
+ principal.spec,
+ "about:test-about-l10n-child",
+ "correct content principal"
+ );
+
+ let desc = document.getElementById("main-desc");
+
+ // We can test here for a particular value because we're
+ // using a mock file source which is locale independent.
+ //
+ // If you're writing a test that verifies that a UI
+ // widget got real localization, you should not rely on
+ // the particular value, but rather on the content not
+ // being empty (to keep the test pass in non-en-US locales).
+ is(desc.textContent, "This is a mock page title");
+
+ // Test for l10n.getAttributes
+ let label = document.getElementById("label1");
+ let l10nArgs = document.l10n.getAttributes(label);
+ is(l10nArgs.id, "subtitle");
+ is(l10nArgs.args.name, "Firefox");
+
+ // Test for manual value formatting
+ let customMsg = document.getElementById("customMessage").textContent;
+ is(customMsg, "This is a custom message formatted from JS.");
+
+ // Since we applied the `data-l10n-id` attribute
+ // on `label` in this microtask, we have to wait for
+ // the next paint to verify that the MutationObserver
+ // applied the translation.
+ await new Promise(resolve => {
+ let verifyL10n = () => {
+ if (!label.textContent.includes("Firefox")) {
+ window.requestAnimationFrame(verifyL10n);
+ } else {
+ resolve();
+ }
+ };
+
+ window.requestAnimationFrame(verifyL10n);
+ });
+ });
+ }
+ );
+});
diff --git a/dom/l10n/tests/mochitest/document_l10n/non-system-principal/localization/test.ftl b/dom/l10n/tests/mochitest/document_l10n/non-system-principal/localization/test.ftl
new file mode 100644
index 0000000000..a5da5a8f00
--- /dev/null
+++ b/dom/l10n/tests/mochitest/document_l10n/non-system-principal/localization/test.ftl
@@ -0,0 +1,4 @@
+page-title = This is a mock page title
+subtitle = This is a label for { $name }
+
+custom-message = This is a custom message formatted from JS.
diff --git a/dom/l10n/tests/mochitest/document_l10n/non-system-principal/test.html b/dom/l10n/tests/mochitest/document_l10n/non-system-principal/test.html
new file mode 100644
index 0000000000..5d91f3da46
--- /dev/null
+++ b/dom/l10n/tests/mochitest/document_l10n/non-system-principal/test.html
@@ -0,0 +1,37 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test DocumentL10n in HTML environment</title>
+ <link rel="localization" href="test.ftl"/>
+ <script type="text/javascript">
+ document.testsReadyPromise = new Promise((resolve) => {
+ // The test is in this file to ensure that we're testing
+ // the behavior in a non-system principal.
+ document.addEventListener("DOMContentLoaded", async () => {
+ await document.l10n.ready;
+
+ // Assign the localization from JS
+ let label = document.getElementById("label1");
+ document.l10n.setAttributes(
+ label,
+ "subtitle",
+ {
+ name: "Firefox",
+ }
+ );
+
+ const customMsg = await document.l10n.formatValue("custom-message");
+ document.getElementById("customMessage").textContent = customMsg;
+ resolve();
+ }, {once: true});
+ });
+ </script>
+</head>
+<body>
+ <h1 id="main-desc" data-l10n-id="page-title"></h1>
+
+ <p id="label1"></p>
+ <p id="customMessage"></p>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/document_l10n/test_connectRoot_webcomponent.html b/dom/l10n/tests/mochitest/document_l10n/test_connectRoot_webcomponent.html
new file mode 100644
index 0000000000..3f2def3547
--- /dev/null
+++ b/dom/l10n/tests/mochitest/document_l10n/test_connectRoot_webcomponent.html
@@ -0,0 +1,90 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test Web Component connecting into Document's l10n</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <link rel="localization" href="browser/preferences/preferences.ftl"></link>
+ <script type="application/javascript">
+ SimpleTest.waitForExplicitFinish();
+
+ // In this test we are introducing two widgets. The only difference between them
+ // is that the first one is using `connectRoot` with the `aTranslate` argument set to `true`,
+ // and the other one to `false`.
+ //
+ // In this test, we will inject both of them into the DOM for parsing.
+ // For a test that verifies the behavior when they're injected lazily, see
+ // `test_connectRoot_webcomponent_lazy.html` test.
+ //
+ // Since both widgets get injected into DOM during parsing, we expect both of them
+ // to get translated before `document.l10n.ready` is resolved.
+
+ let passedTests = 0;
+
+ class FluentWidget extends HTMLElement {
+ constructor() {
+ super();
+ const shadowRoot = this.attachShadow({mode: "open"});
+ const t = document.querySelector("#fluent-widget-template");
+ const instance = t.content.cloneNode(true);
+ shadowRoot.appendChild(instance);
+ }
+ async connectedCallback() {
+ MozXULElement.insertFTLIfNeeded("browser/preferences/preferences.ftl");
+
+ document.l10n.connectRoot(this.shadowRoot, true);
+
+ let label = this.shadowRoot.getElementById("label");
+
+ await document.l10n.ready;
+ is(label.textContent, "Learn more", "localization content applied to element");
+ passedTests++;
+ if (passedTests == 2) {
+ SimpleTest.finish();
+ }
+ }
+ }
+
+ class FluentWidget2 extends HTMLElement {
+ constructor() {
+ super();
+ const shadowRoot = this.attachShadow({mode: "open"});
+ const t = document.querySelector("#fluent-widget-template");
+ const instance = t.content.cloneNode(true);
+ shadowRoot.appendChild(instance);
+ }
+ async connectedCallback() {
+ MozXULElement.insertFTLIfNeeded("browser/preferences/preferences.ftl");
+
+ document.l10n.connectRoot(this.shadowRoot, false);
+
+ let label = this.shadowRoot.getElementById("label");
+
+ await document.l10n.ready;
+ is(label.textContent, "Learn more", "localization content applied to element");
+ passedTests++;
+ if (passedTests == 2) {
+ SimpleTest.finish();
+ }
+ }
+ }
+
+ customElements.define("fluent-widget", FluentWidget);
+ customElements.define("fluent-widget2", FluentWidget2);
+ </script>
+</head>
+<body>
+ <template id="fluent-widget-template">
+ <div>
+ <button id="label" data-l10n-id="do-not-track-learn-more"></button>
+ </div>
+ </template>
+ <fluent-widget></fluent-widget>
+ <fluent-widget2></fluent-widget2>
+ <script>
+ // This trick makes sure that we connect the widgets before parsing is completed.
+ document.write("");
+ </script>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/document_l10n/test_connectRoot_webcomponent_lazy.html b/dom/l10n/tests/mochitest/document_l10n/test_connectRoot_webcomponent_lazy.html
new file mode 100644
index 0000000000..b74bbc00c8
--- /dev/null
+++ b/dom/l10n/tests/mochitest/document_l10n/test_connectRoot_webcomponent_lazy.html
@@ -0,0 +1,98 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test Web Component connecting into Document's l10n</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript">
+ SimpleTest.waitForExplicitFinish();
+
+ // In this test we are introducing two widgets. The only difference between them
+ // is that the first one is using `connectRoot` with the `aTranslate` argument set to `true`,
+ // and the other one to `false`.
+ //
+ // In this test, we will inject both of them lazily, after initial parsing is completed.
+ // For a test that verifies the behavior when they're injected during parsing, see
+ // `test_connectRoot_webcomponent.html` test.
+ //
+ // The expected difference is that when both get lazily injected into the DOM, the first one
+ // will get translated, while the other will not.
+ // The latter behavior will be used by widgets that will want to translate the initial DOM on their
+ // own before connecting the root.
+
+ let firstWidgetTranslated = false;
+
+ class FluentWidget extends HTMLElement {
+ constructor() {
+ super();
+ const shadowRoot = this.attachShadow({mode: "open"});
+ const t = document.querySelector("#fluent-widget-template");
+ const instance = t.content.cloneNode(true);
+ shadowRoot.appendChild(instance);
+ }
+ connectedCallback() {
+ MozXULElement.insertFTLIfNeeded("browser/preferences/preferences.ftl");
+
+ document.l10n.connectRoot(this.shadowRoot, true);
+
+ let label = this.shadowRoot.getElementById("label");
+
+ let verifyL10n = () => {
+ if (label.textContent.length) {
+ window.removeEventListener("MozAfterPaint", verifyL10n);
+ is(label.textContent, "Learn more", "localization content applied to element");
+ firstWidgetTranslated = true;
+ }
+ };
+ window.addEventListener("MozAfterPaint", verifyL10n);
+ }
+ }
+
+ class FluentWidget2 extends HTMLElement {
+ constructor() {
+ super();
+ const shadowRoot = this.attachShadow({mode: "open"});
+ const t = document.querySelector("#fluent-widget-template");
+ const instance = t.content.cloneNode(true);
+ shadowRoot.appendChild(instance);
+ }
+ connectedCallback() {
+ MozXULElement.insertFTLIfNeeded("browser/preferences/preferences.ftl");
+
+ document.l10n.connectRoot(this.shadowRoot, false);
+
+ let label = this.shadowRoot.getElementById("label");
+
+ let verifyL10n = () => {
+ if (firstWidgetTranslated) {
+ is(label.textContent.length, 0, "This widget should remain untranslated.");
+ window.removeEventListener("MozAfterPaint", verifyL10n);
+ SimpleTest.finish();
+ }
+ };
+ window.addEventListener("MozAfterPaint", verifyL10n);
+ }
+ }
+
+ customElements.define("fluent-widget", FluentWidget);
+ customElements.define("fluent-widget2", FluentWidget2);
+
+ window.addEventListener("load", () => {
+ window.requestIdleCallback(async () => {
+ let widget = document.createElement("fluent-widget");
+ document.body.appendChild(widget);
+ let widget2 = document.createElement("fluent-widget2");
+ document.body.appendChild(widget2);
+ });
+ }, { once: true });
+ </script>
+</head>
+<body>
+ <template id="fluent-widget-template">
+ <div>
+ <button id="label" data-l10n-id="do-not-track-learn-more"></button>
+ </div>
+ </template>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/document_l10n/test_docl10n.html b/dom/l10n/tests/mochitest/document_l10n/test_docl10n.html
new file mode 100644
index 0000000000..12ff623f5c
--- /dev/null
+++ b/dom/l10n/tests/mochitest/document_l10n/test_docl10n.html
@@ -0,0 +1,66 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test DocumentL10n in HTML environment</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <link rel="localization" href="crashreporter/aboutcrashes.ftl"/>
+ <script>
+ "use strict";
+ SimpleTest.waitForExplicitFinish();
+
+ is(document.l10n.ready && document.l10n.ready.then !== undefined, true,
+ "document.l10n.ready exists and is a Promise");
+
+ (async function() {
+ await document.l10n.ready;
+
+ const desc = document.getElementById("main-desc");
+ is(!!desc.textContent.length, true, "initial localization is applied");
+
+ const msg = await document.l10n.formatValue("id-heading");
+ is(!!msg.length, true, "value is formatted manually");
+
+ const label = document.getElementById("label1");
+ let l10nArgs = document.l10n.getAttributes(label);
+ is(l10nArgs.id, null, "id is null if not set");
+
+ SimpleTest.doesThrow(
+ () => {
+ const bad = {};
+ bad.bad = bad;
+ document.l10n.setAttributes(label, "date-crashed-heading", bad);
+ },
+ "an error is thrown for invalid args",
+ );
+
+ l10nArgs = document.l10n.getAttributes(label);
+ is(l10nArgs.id, null, "id is not set if args are invalid");
+
+ document.l10n.setAttributes(
+ label,
+ "date-crashed-heading",
+ {
+ name: "John",
+ }
+ );
+ ok(document.hasPendingL10nMutations, "Should have pending mutations");
+ l10nArgs = document.l10n.getAttributes(label);
+ is(l10nArgs.id, "date-crashed-heading", "id is set by setAttributes");
+ is(l10nArgs.args.name, "John", "args are set by setAttributes");
+ // Test for mutations applied.
+ document.addEventListener("L10nMutationsFinished", function() {
+ ok(!!label.textContent.length, "Should've applied translation");
+ ok(!document.hasPendingL10nMutations, "Should have no more pending mutations");
+ SimpleTest.finish();
+ }, { once: true });
+ })();
+ </script>
+</head>
+<body>
+ <h1 id="main-desc" data-l10n-id="crash-reports-title"></h1>
+
+ <p id="label1"></p>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/document_l10n/test_docl10n.xhtml b/dom/l10n/tests/mochitest/document_l10n/test_docl10n.xhtml
new file mode 100644
index 0000000000..2d51d8689e
--- /dev/null
+++ b/dom/l10n/tests/mochitest/document_l10n/test_docl10n.xhtml
@@ -0,0 +1,60 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <meta charset="utf-8"></meta>
+ <title>Test DocumentL10n in HTML environment</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"></link>
+ <link rel="localization" href="crashreporter/aboutcrashes.ftl"/>
+ <script type="application/javascript">
+ "use strict";
+ SimpleTest.waitForExplicitFinish();
+
+ document.addEventListener("DOMContentLoaded", async function() {
+ await document.l10n.ready;
+
+ // Test for initial localization applied.
+ let desc = document.getElementById("main-desc");
+ is(!!desc.textContent.length, true);
+
+ // Test for manual value formatting
+ let msg = await document.l10n.formatValue("id-heading");
+ is(!!msg.length, true);
+
+ // Test for mutations applied.
+ let verifyL10n = () => {
+ if (label.textContent.length) {
+ window.removeEventListener("MozAfterPaint", verifyL10n);
+ SimpleTest.finish();
+ }
+ };
+ window.addEventListener("MozAfterPaint", verifyL10n);
+
+ let label = document.getElementById("label1");
+ document.l10n.setAttributes(
+ label,
+ "date-crashed-heading",
+ {
+ name: "John",
+ }
+ );
+
+ // Test for l10n.getAttributes
+ let l10nArgs = document.l10n.getAttributes(label);
+ is(l10nArgs.id, "date-crashed-heading");
+ is(l10nArgs.args.name, "John");
+ }, { once: true});
+ </script>
+</head>
+<body>
+ <h1 id="main-desc" data-l10n-id="crash-reports-title"></h1>
+
+ <p id="label1" />
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/document_l10n/test_docl10n_lazy.html b/dom/l10n/tests/mochitest/document_l10n/test_docl10n_lazy.html
new file mode 100644
index 0000000000..6c3ddb73ed
--- /dev/null
+++ b/dom/l10n/tests/mochitest/document_l10n/test_docl10n_lazy.html
@@ -0,0 +1,44 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test Lazy DocumentL10n in HTML environment</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript">
+ "use strict";
+ SimpleTest.waitForExplicitFinish();
+
+ is(document.l10n, null, "document.l10n is null.");
+
+ window.addEventListener("load", async () => {
+ is(document.l10n, null, "document.l10n is null after load.");
+
+ let desc = document.getElementById("main-desc");
+ is(desc.textContent.length, 0, "main-desc is not translated");
+
+ let link = document.createElement("link");
+ link.setAttribute("rel", "localization");
+ link.setAttribute("href", "crashreporter/aboutcrashes.ftl");
+ document.head.appendChild(link);
+
+ // Verify now that `l10n.ready` exists and is fulfilled.
+ await document.l10n.ready;
+
+ // Lazy initialized localization should translate the document.
+ is(!!desc.textContent.length, true, "main-desc is translated");
+
+ document.head.removeChild(link);
+
+ is(document.l10n, null, "document.l10n is null");
+
+ SimpleTest.finish();
+ }, { once: true});
+ </script>
+</head>
+<body>
+ <h1 id="main-desc" data-l10n-id="crash-reports-title"></h1>
+
+ <p id="label1"></p>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/document_l10n/test_docl10n_ready_rejected.html b/dom/l10n/tests/mochitest/document_l10n/test_docl10n_ready_rejected.html
new file mode 100644
index 0000000000..63e18f802c
--- /dev/null
+++ b/dom/l10n/tests/mochitest/document_l10n/test_docl10n_ready_rejected.html
@@ -0,0 +1,29 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test mozIDOMLocalization.ready rejected state</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <link rel="localization" href="path/to_non_existing.ftl"/>
+ <script type="application/javascript">
+ "use strict";
+ SimpleTest.waitForExplicitFinish();
+
+ document.addEventListener("DOMContentLoaded", async function() {
+ /**
+ * Even when we fail to localize all elements, we will
+ * still resolve the `ready` promise to communicate that
+ * the initial translation phase is now completed.
+ */
+ document.l10n.ready.then(() => {
+ is(1, 1, "the ready should resolve");
+ SimpleTest.finish();
+ });
+ });
+ </script>
+</head>
+<body>
+ <h1 data-l10n-id="non-existing-id"></h1>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/document_l10n/test_docl10n_removeResourceIds.html b/dom/l10n/tests/mochitest/document_l10n/test_docl10n_removeResourceIds.html
new file mode 100644
index 0000000000..8ccaa04614
--- /dev/null
+++ b/dom/l10n/tests/mochitest/document_l10n/test_docl10n_removeResourceIds.html
@@ -0,0 +1,59 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test DocumentL10n::RemoveResourceIds</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <link rel="localization" href="toolkit/about/aboutAddons.ftl"/>
+ <link rel="localization" href="toolkit/about/aboutSupport.ftl"/>
+ <script type="application/javascript">
+ "use strict";
+ /* eslint-disable mozilla/prefer-formatValues */
+
+ SimpleTest.waitForExplicitFinish();
+
+ window.onload = async function() {
+ {
+ // 1. An example message from aboutAddons should be available.
+ let value = await document.l10n.formatValue("shortcuts-browserAction2");
+ is(!!value.length, true, "localized value retrieved");
+ }
+
+ {
+ // 2. Remove aboutAddons.ftl
+ let link = document.head.querySelector("link[href*=aboutAddons]");
+ document.head.removeChild(link);
+ }
+
+ {
+ // 3. An example message from aboutSupport should still be available.
+ let value = await document.l10n.formatValue("features-version");
+ is(!!value.length, true, "localized value retrieved");
+
+ // 4. An example message from aboutAddons should not be available.
+ await document.l10n.formatValue("shortcuts-browserAction").then(
+ () => {
+ ok(false, "localization should not be available");
+ },
+ () => {
+ ok(true, "localization should not be available");
+ });
+ }
+
+ {
+ // 5. Remove aboutSupport.ftl
+ let link = document.head.querySelector("link[href*=aboutSupport]");
+ document.head.removeChild(link);
+
+ // 6. document.l10n should be null.
+ is(document.l10n, null, "document.l10n should be null");
+
+ SimpleTest.finish();
+ }
+ };
+ </script>
+</head>
+<body>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/document_l10n/test_docl10n_sync.html b/dom/l10n/tests/mochitest/document_l10n/test_docl10n_sync.html
new file mode 100644
index 0000000000..ea44d1afe1
--- /dev/null
+++ b/dom/l10n/tests/mochitest/document_l10n/test_docl10n_sync.html
@@ -0,0 +1,54 @@
+<!DOCTYPE HTML>
+<html data-l10n-sync>
+<head>
+ <meta charset="utf-8">
+ <title>Test DocumentL10n in HTML environment</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <link rel="localization" href="crashreporter/aboutcrashes.ftl"/>
+ <script type="application/javascript">
+ "use strict";
+ SimpleTest.waitForExplicitFinish();
+
+ document.addEventListener("DOMContentLoaded", async function() {
+ await document.l10n.ready;
+
+ // Test for initial localization applied.
+ let desc = document.getElementById("main-desc");
+ is(!!desc.textContent.length, true);
+
+ // Test for manual value formatting.
+ let msg = await document.l10n.formatValue("id-heading");
+ is(!!msg.length, true);
+
+ // Test for mutations applied.
+ let verifyL10n = () => {
+ if (label.textContent.length) {
+ window.removeEventListener("MozAfterPaint", verifyL10n);
+ SimpleTest.finish();
+ }
+ };
+ window.addEventListener("MozAfterPaint", verifyL10n);
+
+ let label = document.getElementById("label1");
+ document.l10n.setAttributes(
+ label,
+ "date-crashed-heading",
+ {
+ name: "John",
+ }
+ );
+
+ // Test for l10n.getAttributes
+ let l10nArgs = document.l10n.getAttributes(label);
+ is(l10nArgs.id, "date-crashed-heading");
+ is(l10nArgs.args.name, "John");
+ }, { once: true});
+ </script>
+</head>
+<body>
+ <h1 id="main-desc" data-l10n-id="crash-reports-title"></h1>
+
+ <p id="label1"></p>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/document_l10n/test_telemetry.html b/dom/l10n/tests/mochitest/document_l10n/test_telemetry.html
new file mode 100644
index 0000000000..b528ebc7ea
--- /dev/null
+++ b/dom/l10n/tests/mochitest/document_l10n/test_telemetry.html
@@ -0,0 +1,83 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test DocumentL10n Telemetry</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript">
+ "use strict";
+ const { BrowserTestUtils } = ChromeUtils.import(
+ "resource://testing-common/BrowserTestUtils.jsm"
+ );
+
+ SimpleTest.waitForExplicitFinish();
+
+ function countValues(snapshot, key) {
+ if (!snapshot.hasOwnProperty(key)) {
+ return 0;
+ }
+ let values = snapshot[key].values;
+ return Object.values(values).reduce((sum, n) => sum + n, 0);
+ }
+
+ (async function() {
+ let histogram = Services.telemetry
+ .getKeyedHistogramById("L10N_DOCUMENT_INITIAL_TRANSLATION_TIME_US");
+ let snapshot = histogram.snapshot();
+
+ // In some cases the test runs before first window is localized.
+ // We just want to ensure that we didn't register more than 1
+ // first window telemetry.
+ let firstWindowCount = countValues(snapshot, "browser_first_window");
+ is(firstWindowCount < 2, true);
+
+ histogram.clear();
+
+ // Open a new window
+ let win = await BrowserTestUtils.openNewBrowserWindow({
+ waitForTabURL: "about:blank",
+ });
+
+ // Telemetry in testing is flaky and when landing this test
+ // we saw cases where the snapshot did not contain the new
+ // window telemetry at this moment.
+ //
+ // We're going to use `waitForCondition` to test for
+ // the telemetry to be eventually recorded.
+ BrowserTestUtils.waitForCondition(() => {
+ snapshot = histogram.snapshot();
+
+ // We want to make sure that since we cleared histogram
+ // just one new window of either type has been opened.
+ let browserWindowsCount =
+ countValues(snapshot, "browser_new_window") +
+ countValues(snapshot, "browser_first_window");
+ return browserWindowsCount == 1;
+ });
+
+ // Open preferences in a new tab
+ let tab = BrowserTestUtils.addTab(
+ win.gBrowser,
+ "about:preferences"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+ // Similarly to the above, we've seen cases where the telemetry
+ // was not present right after the tab was opened, so
+ // we'll use `waitForCondition` here.
+ BrowserTestUtils.waitForCondition(() => {
+ snapshot = histogram.snapshot();
+
+ return countValues(snapshot, "about:preferences") == 1;
+ });
+
+ await BrowserTestUtils.closeWindow(win);
+
+ SimpleTest.finish();
+ })();
+ </script>
+</head>
+<body>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/document_l10n/test_unpriv_iframe.html b/dom/l10n/tests/mochitest/document_l10n/test_unpriv_iframe.html
new file mode 100644
index 0000000000..4f4b29c500
--- /dev/null
+++ b/dom/l10n/tests/mochitest/document_l10n/test_unpriv_iframe.html
@@ -0,0 +1,26 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Ensure unprivilaged document cannot access document.l10n in an iframe</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <script type="application/javascript">
+ "use strict";
+ SimpleTest.waitForExplicitFinish();
+
+ addLoadEvent(function() {
+ let frame = document.getElementById("frame");
+ let frame2 = document.getElementById("frame2");
+
+ is("l10n" in frame.contentDocument, false);
+ is("l10n" in frame2.contentDocument, false);
+ });
+ addLoadEvent(SimpleTest.finish);
+ </script>
+</head>
+<body>
+ <iframe id="frame" src="about:blank"></iframe>
+ <iframe id="frame2" src="about:crashes"></iframe>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/dom_localization/test_attr_sanitized.html b/dom/l10n/tests/mochitest/dom_localization/test_attr_sanitized.html
new file mode 100644
index 0000000000..a9244c9891
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_attr_sanitized.html
@@ -0,0 +1,49 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test DOMLocalization's attr sanitization functionality</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript">
+ "use strict";
+ const l10nReg = new L10nRegistry();
+ const fs = [
+ { path: "/localization/en-US/mock.ftl", source: `
+key1 = Value for Key 1
+
+key2 = Value for <a>Key 2<a/>.
+` },
+ ];
+ const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs);
+ l10nReg.registerSources([source]);
+
+ SimpleTest.waitForExplicitFinish();
+ addLoadEvent(async () => {
+ const domLoc = new DOMLocalization(
+ ["/mock.ftl"],
+ false,
+ l10nReg,
+ ["en-US"],
+ );
+
+ await domLoc.translateFragment(document.body);
+
+ const elem1 = document.querySelector("#elem1");
+ const elem2 = document.querySelector("#elem2");
+
+ ok(elem1.textContent.includes("Value for"));
+ ok(!elem1.hasAttribute("title"));
+
+ ok(elem2.textContent.includes("Value for"));
+ ok(!elem2.hasAttribute("title"));
+
+ SimpleTest.finish();
+ });
+ </script>
+</head>
+<body>
+ <p id="elem1" title="Old Translation" data-l10n-id="key1"></p>
+ <p id="elem2" title="Old Translation" data-l10n-id="key2"></p>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/dom_localization/test_connectRoot.html b/dom/l10n/tests/mochitest/dom_localization/test_connectRoot.html
new file mode 100644
index 0000000000..83b83757f1
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_connectRoot.html
@@ -0,0 +1,45 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test DOMLocalization.prototype.connectRoot</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript">
+ "use strict";
+ const l10nReg = new L10nRegistry();
+ const fs = [
+ { path: "/localization/en-US/mock.ftl", source: `
+key1 = Value for Key 1
+` },
+ ];
+ const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs);
+ l10nReg.registerSources([source]);
+
+ window.onload = async function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const p1 = document.getElementById("p1");
+
+ const domLoc = new DOMLocalization(
+ ["/mock.ftl"],
+ false,
+ l10nReg,
+ ["en-US"],
+ );
+
+ await domLoc.translateRoots();
+ is(!p1.textContent.length, true);
+ const body = document.body;
+ domLoc.connectRoot(body);
+ await domLoc.translateRoots();
+ is(!!p1.textContent.length, true);
+
+ SimpleTest.finish();
+ };
+ </script>
+</head>
+<body>
+ <p id="p1" data-l10n-id="key1"></p>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/dom_localization/test_connectRoot_webcomponent.html b/dom/l10n/tests/mochitest/dom_localization/test_connectRoot_webcomponent.html
new file mode 100644
index 0000000000..1254bd814f
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_connectRoot_webcomponent.html
@@ -0,0 +1,72 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test DOMLocalization.prototype.connectRoot with Web Components</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript">
+ SimpleTest.waitForExplicitFinish();
+
+ class FluentWidget extends HTMLElement {
+ constructor() {
+ super();
+ const shadowRoot = this.attachShadow({mode: "open"});
+ const t = document.querySelector("#fluent-widget-template");
+ const instance = t.content.cloneNode(true);
+ shadowRoot.appendChild(instance);
+ }
+ connectedCallback() {
+ document.domLoc.connectRoot(this.shadowRoot);
+ ok(true);
+
+ let label = this.shadowRoot.getElementById("label");
+
+ // Test for mutations applied.
+ let verifyL10n = () => {
+ if (label.textContent.length) {
+ window.removeEventListener("MozAfterPaint", verifyL10n);
+ // Notice: In normal tests we do not want to test against any particular
+ // value as per https://firefox-source-docs.mozilla.org/l10n/fluent/tutorial.html#testing
+ // But in this particular test, since we do not rely on actual real
+ // localization, but instead we mock it in the test, we can test
+ // against the actual value safely.
+ is(label.textContent, "Value for Key 1", "localization content applied to element");
+ SimpleTest.finish();
+ }
+ };
+ window.addEventListener("MozAfterPaint", verifyL10n);
+
+ document.domLoc.setAttributes(label, "key1");
+ }
+ }
+ customElements.define("fluent-widget", FluentWidget);
+ </script>
+ <script type="application/javascript">
+ "use strict";
+ const l10nReg = new L10nRegistry();
+ const fs = [
+ { path: "/localization/en-US/mock.ftl", source: `
+key1 = Value for Key 1
+` },
+ ];
+ const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs);
+ l10nReg.registerSources([source]);
+
+ document.domLoc = new DOMLocalization(
+ ["/mock.ftl"],
+ false,
+ l10nReg,
+ ["en-US"],
+ );
+ </script>
+</head>
+<body>
+ <template id="fluent-widget-template">
+ <div>
+ <p id="label"></p>
+ </div>
+ </template>
+ <fluent-widget id="widget1"></fluent-widget>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/dom_localization/test_disconnectRoot.html b/dom/l10n/tests/mochitest/dom_localization/test_disconnectRoot.html
new file mode 100644
index 0000000000..fd7344c88e
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_disconnectRoot.html
@@ -0,0 +1,60 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test DOMLocalization.prototype.disconnectRoot</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript">
+ "use strict";
+ const l10nReg = new L10nRegistry();
+ const fs = [
+ { path: "/localization/en-US/mock.ftl", source: `
+key1 = Value for Key 1
+key2 = Value for Key 2
+` },
+ ];
+ const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs);
+ l10nReg.registerSources([source]);
+
+ window.onload = async function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const p1 = document.getElementById("p1");
+
+ const domLoc = new DOMLocalization(
+ ["/mock.ftl"],
+ false,
+ l10nReg,
+ ["en-US"],
+ );
+
+ await domLoc.translateRoots();
+ is(!p1.textContent.length, true);
+
+ const body = document.body;
+
+ domLoc.connectRoot(body);
+ await domLoc.translateRoots();
+ is(p1.textContent.includes("Key 1"), true);
+ is(p1.textContent.includes("Key 2"), false);
+
+ domLoc.disconnectRoot(body);
+ domLoc.setAttributes(p1, "key2");
+ await domLoc.translateRoots();
+ is(p1.textContent.includes("Key 1"), true);
+ is(p1.textContent.includes("Key 2"), false);
+
+ domLoc.connectRoot(body);
+ await domLoc.translateRoots();
+ is(p1.textContent.includes("Key 1"), false);
+ is(p1.textContent.includes("Key 2"), true);
+
+ SimpleTest.finish();
+ };
+ </script>
+</head>
+<body>
+ <p id="p1" data-l10n-id="key1"></p>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/dom_localization/test_domloc.xhtml b/dom/l10n/tests/mochitest/dom_localization/test_domloc.xhtml
new file mode 100644
index 0000000000..a4d4fcc506
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_domloc.xhtml
@@ -0,0 +1,68 @@
+<?xml version="1.0"?>
+<?xml-stylesheet href="chrome://global/skin" type="text/css"?>
+<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css"
+ type="text/css"?>
+
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ title="Testing DOMLocalization in XUL environment">
+
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" />
+ <script type="application/javascript">
+ <![CDATA[
+ const l10nReg = new L10nRegistry();
+ const fs = [
+ { path: "/localization/en-US/mock.ftl", source: `
+file-menu =
+ .label = File
+ .accesskey = F
+new-tab =
+ .label = New Tab
+ .accesskey = N
+container = Some text with an <image data-l10n-name="foo"> inside it.
+` },
+ ];
+ const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs);
+ l10nReg.registerSources([source]);
+
+ SimpleTest.waitForExplicitFinish();
+
+
+ const domLoc = new DOMLocalization(
+ [],
+ false,
+ l10nReg,
+ ["en-US"],
+ );
+
+ async function foo() {
+ domLoc.addResourceIds(["/mock.ftl"]);
+ domLoc.connectRoot(document.documentElement);
+ await domLoc.translateRoots();
+
+ is(document.getElementById('file-menu').getAttribute('label'), 'File');
+ is(document.getElementById('file-menu').getAttribute('accesskey'), 'F');
+
+ is(document.getElementById('new-tab').getAttribute('label'), 'New Tab');
+ is(document.getElementById('new-tab').getAttribute('accesskey'), 'N');
+
+ ok(document.querySelector("image"),
+ "Image should still be present after localization.");
+ SimpleTest.finish();
+ }
+
+ window.onload = foo;
+
+ ]]>
+ </script>
+ <description data-l10n-id="container"><image data-l10n-name="foo"/></description>
+
+ <menubar id="main-menubar">
+ <menu id="file-menu" data-l10n-id="file-menu">
+ <menupopup id="menu_FilePopup">
+ <menuitem id="new-tab" data-l10n-id="new-tab">
+ </menuitem>
+ </menupopup>
+ </menu>
+ </menubar>
+</window>
diff --git a/dom/l10n/tests/mochitest/dom_localization/test_getAttributes.html b/dom/l10n/tests/mochitest/dom_localization/test_getAttributes.html
new file mode 100644
index 0000000000..11996d132a
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_getAttributes.html
@@ -0,0 +1,49 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test DOMLocalization.prototype.getAttributes</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript">
+ "use strict";
+
+ window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const domLoc = new DOMLocalization(
+ [],
+ false,
+ );
+
+ const p1 = document.querySelectorAll("p")[0];
+ const p2 = document.querySelectorAll("p")[1];
+ const p3 = document.querySelectorAll("p")[2];
+ const attrs1 = domLoc.getAttributes(p1);
+ const attrs2 = domLoc.getAttributes(p2);
+ const attrs3 = domLoc.getAttributes(p3);
+ isDeeply(attrs1, {
+ id: null,
+ args: null,
+ });
+ isDeeply(attrs2, {
+ id: "id1",
+ args: null,
+ });
+ isDeeply(attrs3, {
+ id: "id2",
+ args: {
+ userName: "John",
+ },
+ });
+
+ SimpleTest.finish();
+ };
+ </script>
+</head>
+<body>
+ <p />
+ <p data-l10n-id="id1" />
+ <p data-l10n-id="id2" data-l10n-args='{"userName": "John"}' />
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/dom_localization/test_l10n_mutations.html b/dom/l10n/tests/mochitest/dom_localization/test_l10n_mutations.html
new file mode 100644
index 0000000000..278baa15dd
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_l10n_mutations.html
@@ -0,0 +1,57 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test DOMLocalization's MutationObserver</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript">
+ "use strict";
+ const l10nReg = new L10nRegistry();
+ const fs = [
+ { path: "/localization/en-US/mock.ftl", source: `
+title = Hello World
+title2 = Hello Another World
+` },
+ ];
+ const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs);
+ l10nReg.registerSources([source]);
+
+ window.onload = async function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const domLoc = new DOMLocalization(
+ [],
+ false,
+ l10nReg,
+ ["en-US"],
+ );
+
+ const h1 = document.querySelectorAll("h1")[0];
+
+ domLoc.addResourceIds(["/mock.ftl"]);
+ domLoc.connectRoot(document.body);
+
+ await domLoc.translateRoots();
+
+ is(h1.textContent, "Hello World");
+
+
+ const mo = new MutationObserver(function onMutations(mutations) {
+ is(h1.textContent, "Hello Another World");
+ mo.disconnect();
+ SimpleTest.finish();
+ });
+
+ mo.observe(h1, { childList: true, characterData: true });
+
+ domLoc.setAttributes(h1, "title2");
+ };
+ </script>
+</head>
+<body>
+ <div>
+ <h1 data-l10n-id="title"></h1>
+ </div>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/dom_localization/test_overlay.html b/dom/l10n/tests/mochitest/dom_localization/test_overlay.html
new file mode 100644
index 0000000000..2c8c219bb2
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_overlay.html
@@ -0,0 +1,60 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test DOMLocalization's DOMOverlay functionality</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript">
+ "use strict";
+ const l10nReg = new L10nRegistry();
+ const fs = [
+ { path: "/localization/en-US/mock.ftl", source: `
+title = <strong>Hello</strong> World
+title2 = This is <a data-l10n-name="link">a link</a>!
+` },
+ ];
+ const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs);
+ l10nReg.registerSources([source]);
+
+ window.onload = async function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const domLoc = new DOMLocalization(
+ ["/mock.ftl"],
+ false,
+ l10nReg,
+ ["en-US"],
+ );
+
+ const p1 = document.querySelectorAll("p")[0];
+ const p2 = document.querySelectorAll("p")[1];
+ const a = p2.querySelector("a");
+ // We want to test that the event listener persists after
+ // translateFragment().
+ a.addEventListener("click", function(e) {
+ SimpleTest.finish();
+ // We cannot connect to non-local connections on automation, so prevent
+ // the navigation.
+ e.preventDefault();
+ });
+
+ await domLoc.translateFragment(document.body);
+
+
+ is(p1.querySelector("strong").textContent, "Hello");
+
+ is(p2.querySelector("a").getAttribute("href"), "http://www.mozilla.org");
+ is(p2.querySelector("a").textContent, "a link");
+
+ a.click();
+ };
+ </script>
+</head>
+<body>
+ <p data-l10n-id="title" />
+ <p data-l10n-id="title2">
+ <a href="http://www.mozilla.org" data-l10n-name="link"></a>
+ </p>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/dom_localization/test_overlay_missing_all.html b/dom/l10n/tests/mochitest/dom_localization/test_overlay_missing_all.html
new file mode 100644
index 0000000000..c6f285aa38
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_overlay_missing_all.html
@@ -0,0 +1,37 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test DOMLocalization's DOMOverlay functionality</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript">
+ "use strict";
+ const l10nReg = new L10nRegistry();
+
+ SimpleTest.waitForExplicitFinish();
+ addLoadEvent(async () => {
+ const domLoc = new DOMLocalization(
+ [],
+ false,
+ l10nReg,
+ ["en-US"],
+ );
+
+ await domLoc.translateFragment(document.body).then(() => {
+ ok(false, "Expected translateFragment to throw on missing l10n-id");
+ }, () => {
+ ok(true, "Expected translateFragment to throw on missing l10n-id");
+ });
+ SimpleTest.finish();
+ });
+ </script>
+</head>
+<body>
+ <p data-l10n-id="title">
+ <a href="http://www.mozilla.org"></a>
+ <a href="http://www.firefox.com"></a>
+ <a href="http://www.w3.org"></a>
+ </p>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/dom_localization/test_overlay_missing_children.html b/dom/l10n/tests/mochitest/dom_localization/test_overlay_missing_children.html
new file mode 100644
index 0000000000..9e4fec1ce2
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_overlay_missing_children.html
@@ -0,0 +1,53 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test DOMLocalization's DOMOverlay functionality</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript">
+ "use strict";
+ const l10nReg = new L10nRegistry();
+ const fs = [
+ { path: "/localization/en-US/mock.ftl", source: `
+title = Visit <a data-l10n-name="mozilla-link">Mozilla</a> or <a data-l10n-name="firefox-link">Firefox</a> website!
+` },
+ ];
+ const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs);
+ l10nReg.registerSources([source]);
+
+ window.onload = async function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const domLoc = new DOMLocalization(
+ ["/mock.ftl"],
+ false,
+ l10nReg,
+ ["en-US"],
+ );
+
+ await domLoc.translateFragment(document.body);
+
+ const p1 = document.querySelectorAll("p")[0];
+ const linkList = p1.querySelectorAll("a");
+
+
+ is(linkList[0].getAttribute("href"), "http://www.mozilla.org");
+ is(linkList[0].textContent, "Mozilla");
+ is(linkList[1].getAttribute("href"), "http://www.firefox.com");
+ is(linkList[1].textContent, "Firefox");
+
+ is(linkList.length, 2, "There should be exactly two links in the result.");
+
+ SimpleTest.finish();
+ };
+ </script>
+</head>
+<body>
+ <p data-l10n-id="title">
+ <a href="http://www.mozilla.org" data-l10n-name="mozilla-link"></a>
+ <a href="http://www.firefox.com" data-l10n-name="firefox-link"></a>
+ <a href="http://www.w3.org" data-l10n-name="w3-link"></a>
+ </p>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/dom_localization/test_overlay_repeated.html b/dom/l10n/tests/mochitest/dom_localization/test_overlay_repeated.html
new file mode 100644
index 0000000000..a169c27fa0
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_overlay_repeated.html
@@ -0,0 +1,50 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test DOMLocalization's DOMOverlay functionality</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript">
+ "use strict";
+ const l10nReg = new L10nRegistry();
+ const fs = [
+ { path: "/localization/en-US/mock.ftl", source: `
+title = Visit <a data-l10n-name="mozilla-link">Mozilla</a> or <a data-l10n-name="firefox-link">Firefox</a> website!
+` },
+ ];
+ const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs);
+ l10nReg.registerSources([source]);
+
+ window.onload = async function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const domLoc = new DOMLocalization(
+ ["/mock.ftl"],
+ false,
+ l10nReg,
+ ["en-US"],
+ );
+
+ await domLoc.translateFragment(document.body);
+
+ const p1 = document.querySelectorAll("p")[0];
+ const linkList = p1.querySelectorAll("a");
+
+
+ is(linkList[0].getAttribute("href"), "http://www.mozilla.org");
+ is(linkList[0].textContent, "Mozilla");
+ is(linkList[1].getAttribute("href"), "http://www.firefox.com");
+ is(linkList[1].textContent, "Firefox");
+
+ SimpleTest.finish();
+ };
+ </script>
+</head>
+<body>
+ <p data-l10n-id="title">
+ <a href="http://www.mozilla.org" data-l10n-name="mozilla-link"></a>
+ <a href="http://www.firefox.com" data-l10n-name="firefox-link"></a>
+ </p>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/dom_localization/test_overlay_sanitized.html b/dom/l10n/tests/mochitest/dom_localization/test_overlay_sanitized.html
new file mode 100644
index 0000000000..fcc201edb6
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_overlay_sanitized.html
@@ -0,0 +1,52 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test DOMLocalization's DOMOverlay functionality</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript">
+ "use strict";
+ const l10nReg = new L10nRegistry();
+ const fs = [
+ { path: "/localization/en-US/mock.ftl", source: `
+key1 =
+ .href = https://www.hacked.com
+
+key2 =
+ .href = https://pl.wikipedia.org
+` },
+ ];
+ const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs);
+ l10nReg.registerSources([source]);
+
+ async function test() {
+ const domLoc = new DOMLocalization(
+ ["/mock.ftl"],
+ false,
+ l10nReg,
+ ["en-US"],
+ );
+
+ await domLoc.translateFragment(document.body);
+
+ const key1Elem = document.querySelector("[data-l10n-id=key1]");
+ const key2Elem = document.querySelector("[data-l10n-id=key2]");
+
+
+ is(key1Elem.hasAttribute("href"), false, "href translation should not be allowed");
+ is(key2Elem.getAttribute("href"), "https://pl.wikipedia.org",
+ "href translation should be allowed");
+
+ SimpleTest.finish();
+ }
+
+ SimpleTest.waitForExplicitFinish();
+ addLoadEvent(test);
+ </script>
+</head>
+<body>
+ <a data-l10n-id="key1"></a>
+ <a data-l10n-id="key2" data-l10n-attrs="href"></a>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/dom_localization/test_repeated_l10nid.html b/dom/l10n/tests/mochitest/dom_localization/test_repeated_l10nid.html
new file mode 100644
index 0000000000..64d585f0a0
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_repeated_l10nid.html
@@ -0,0 +1,60 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test DOMLocalization's matching l10nIds functionality</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript">
+ "use strict";
+ const l10nReg = new L10nRegistry();
+ const fs = [
+ { path: "/localization/en-US/mock.ftl", source: `
+key1 = Translation For Key 1
+
+key2 = Visit <a data-l10n-name="link">this link<a/>.
+` },
+ ];
+ const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs);
+ l10nReg.registerSources([source]);
+
+ SimpleTest.waitForExplicitFinish();
+ addLoadEvent(async () => {
+ const domLoc = new DOMLocalization(
+ ["/mock.ftl"],
+ false,
+ l10nReg,
+ ["en-US"],
+ );
+
+ await domLoc.translateFragment(document.body);
+
+ ok(document.querySelector("#elem1").textContent.includes("Key 1"));
+ ok(document.querySelector("#elem2").textContent.includes("Key 1"));
+
+ const elem3 = document.querySelector("#elem3");
+ const elem4 = document.querySelector("#elem4");
+
+ ok(elem3.textContent.includes("Visit"));
+ is(elem3.querySelector("a").getAttribute("href"), "http://www.mozilla.org");
+
+ ok(elem4.textContent.includes("Visit"));
+ is(elem4.querySelector("a").getAttribute("href"), "http://www.firefox.com");
+
+ SimpleTest.finish();
+ });
+ </script>
+</head>
+<body>
+ <h1 id="elem1" data-l10n-id="key1"></h1>
+ <h2 id="elem2" data-l10n-id="key1"></h2>
+
+ <p id="elem3" data-l10n-id="key2">
+ <a href="http://www.mozilla.org" data-l10n-name="link"></a>
+ </p>
+
+ <p id="elem4" data-l10n-id="key2">
+ <a href="http://www.firefox.com" data-l10n-name="link"></a>
+ </p>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/dom_localization/test_setAttributes.html b/dom/l10n/tests/mochitest/dom_localization/test_setAttributes.html
new file mode 100644
index 0000000000..508a15dcd3
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_setAttributes.html
@@ -0,0 +1,79 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test DOMLocalization.prototype.setAttributes and DOMLocalization.prototype.setArgs</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript">
+ "use strict";
+ const l10nReg = new L10nRegistry();
+
+ window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const domLoc = new DOMLocalization(
+ [],
+ false,
+ l10nReg,
+ );
+
+ const p1 = document.querySelectorAll("p")[0];
+
+ domLoc.setAttributes(p1, "title");
+ is(
+ p1.getAttribute("data-l10n-id"),
+ "title",
+ "The data-l10n-id can be set by setAttributes."
+ );
+ is(
+ p1.getAttribute("data-l10n-args"),
+ null,
+ "The data-l10n-args is unset."
+ );
+
+
+ domLoc.setAttributes(p1, "title2", {userName: "John"});
+ is(
+ p1.getAttribute("data-l10n-id"),
+ "title2",
+ "The data-l10n-id can be set by setAttributes."
+ );
+ is(
+ p1.getAttribute("data-l10n-args"),
+ JSON.stringify({userName: "John"}),
+ "The data-l10n-args can be set by setAttributes."
+ );
+
+ domLoc.setArgs(p1, {userName: "Jane"});
+ is(
+ p1.getAttribute("data-l10n-id"),
+ "title2",
+ "The data-l10n-id is unchanged by setArgs."
+ );
+ is(
+ p1.getAttribute("data-l10n-args"),
+ JSON.stringify({userName: "Jane"}),
+ "The data-l10n-args can by set by setArgs."
+ );
+
+ domLoc.setArgs(p1);
+ is(
+ p1.getAttribute("data-l10n-id"),
+ "title2",
+ "The data-l10n-id is unchanged by setArgs."
+ );
+ is(
+ p1.getAttribute("data-l10n-args"),
+ null,
+ "The data-l10n-args be unset by setArgs."
+ );
+
+ SimpleTest.finish();
+ };
+ </script>
+</head>
+<body>
+ <p />
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/dom_localization/test_translateElements.html b/dom/l10n/tests/mochitest/dom_localization/test_translateElements.html
new file mode 100644
index 0000000000..7893309ce4
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_translateElements.html
@@ -0,0 +1,47 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test DOMLocalization.prototype.translateElements</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript">
+ "use strict";
+ const l10nReg = new L10nRegistry();
+ const fs = [
+ { path: "/localization/en-US/mock.ftl", source: `
+title = Hello World
+link =
+ .title = Click me
+` },
+ ];
+ const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs);
+ l10nReg.registerSources([source]);
+
+ window.onload = async function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const domLoc = new DOMLocalization(
+ ["/mock.ftl"],
+ false,
+ l10nReg,
+ ["en-US"],
+ );
+
+ const p1 = document.querySelectorAll("p")[0];
+ const link1 = document.querySelectorAll("a")[0];
+
+ await domLoc.translateElements([p1, link1]);
+
+ is(p1.textContent, "Hello World");
+ is(link1.getAttribute("title"), "Click me");
+
+ SimpleTest.finish();
+ };
+ </script>
+</head>
+<body>
+ <p data-l10n-id="title" />
+ <a data-l10n-id="link" />
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/dom_localization/test_translateFragment.html b/dom/l10n/tests/mochitest/dom_localization/test_translateFragment.html
new file mode 100644
index 0000000000..c5ccfd996a
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_translateFragment.html
@@ -0,0 +1,48 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test DOMLocalization.prototype.translateFragment</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript">
+ "use strict";
+ const l10nReg = new L10nRegistry();
+ const fs = [
+ { path: "/localization/en-US/mock.ftl", source: `
+title = Hello World
+subtitle = Welcome to FluentBundle
+` },
+ ];
+ const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs);
+ l10nReg.registerSources([source]);
+
+ window.onload = async function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const domLoc = new DOMLocalization(
+ ["/mock.ftl"],
+ false,
+ l10nReg,
+ ["en-US"],
+ );
+
+ const frag = document.querySelectorAll("div")[0];
+ const h1 = document.querySelectorAll("h1")[0];
+ const p1 = document.querySelectorAll("p")[0];
+
+ await domLoc.translateFragment(frag);
+ is(h1.textContent, "Hello World");
+ is(p1.textContent, "Welcome to FluentBundle");
+
+ SimpleTest.finish();
+ };
+ </script>
+</head>
+<body>
+ <div>
+ <h1 data-l10n-id="title" />
+ <p data-l10n-id="subtitle" />
+ </div>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/dom_localization/test_translateRoots.html b/dom/l10n/tests/mochitest/dom_localization/test_translateRoots.html
new file mode 100644
index 0000000000..58bfb044b0
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_translateRoots.html
@@ -0,0 +1,56 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test DOMLocalization.prototype.translateRoots</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript">
+ "use strict";
+ const l10nReg = new L10nRegistry();
+ const fs = [
+ { path: "/localization/en-US/mock.ftl", source: `
+title = Hello World
+title2 = Hello Another World
+` },
+ ];
+ const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs);
+ l10nReg.registerSources([source]);
+
+ window.onload = async function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const domLoc = new DOMLocalization(
+ [],
+ false,
+ l10nReg,
+ ["en-US"],
+ );
+
+ const frag1 = document.querySelectorAll("div")[0];
+ const frag2 = document.querySelectorAll("div")[1];
+ const h1 = document.querySelectorAll("h1")[0];
+ const h2 = document.querySelectorAll("h2")[0];
+
+ domLoc.addResourceIds(["/mock.ftl"]);
+ domLoc.connectRoot(frag1);
+ domLoc.connectRoot(frag2);
+
+ await domLoc.translateRoots();
+
+ is(h1.textContent, "Hello World");
+ is(h2.textContent, "Hello Another World");
+
+ SimpleTest.finish();
+ };
+ </script>
+</head>
+<body>
+ <div>
+ <h1 data-l10n-id="title"></h1>
+ </div>
+ <div>
+ <h2 data-l10n-id="title2"></h2>
+ </div>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/l10n_mutations/test_append_content_post_dcl.html b/dom/l10n/tests/mochitest/l10n_mutations/test_append_content_post_dcl.html
new file mode 100644
index 0000000000..e3a9819728
--- /dev/null
+++ b/dom/l10n/tests/mochitest/l10n_mutations/test_append_content_post_dcl.html
@@ -0,0 +1,30 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test L10n Mutations for ContentAppended after DOMContentLoaded</title>
+ <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <link rel="localization" href="crashreporter/aboutcrashes.ftl"/>
+ <script type="application/javascript">
+ "use strict";
+ SimpleTest.waitForExplicitFinish();
+
+ document.addEventListener("DOMContentLoaded", function() {
+ let elem = document.createElement("div");
+ document.l10n.setAttributes(elem, "crash-reports-title");
+ is(elem.textContent.length, 0);
+ let verifyL10n = () => {
+ if (elem.textContent.length) {
+ window.removeEventListener("MozAfterPaint", verifyL10n);
+ SimpleTest.finish();
+ }
+ };
+ window.addEventListener("MozAfterPaint", verifyL10n);
+ document.body.appendChild(elem);
+ });
+ </script>
+</head>
+<body>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/l10n_mutations/test_append_content_pre_dcl.html b/dom/l10n/tests/mochitest/l10n_mutations/test_append_content_pre_dcl.html
new file mode 100644
index 0000000000..825464e7b8
--- /dev/null
+++ b/dom/l10n/tests/mochitest/l10n_mutations/test_append_content_pre_dcl.html
@@ -0,0 +1,28 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test L10n Mutations for ContentAppended before DOMContentLoaded</title>
+ <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <link rel="localization" href="crashreporter/aboutcrashes.ftl"/>
+</head>
+<body>
+ <script type="application/javascript">
+ "use strict";
+ SimpleTest.waitForExplicitFinish();
+
+ let elem = document.createElement("div");
+ document.l10n.setAttributes(elem, "crash-reports-title");
+ is(elem.textContent.length, 0);
+ let verifyL10n = () => {
+ if (elem.textContent.length) {
+ window.removeEventListener("MozAfterPaint", verifyL10n);
+ SimpleTest.finish();
+ }
+ };
+ window.addEventListener("MozAfterPaint", verifyL10n);
+ document.body.appendChild(elem);
+ </script>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/l10n_mutations/test_append_fragment_post_dcl.html b/dom/l10n/tests/mochitest/l10n_mutations/test_append_fragment_post_dcl.html
new file mode 100644
index 0000000000..8efb3203c6
--- /dev/null
+++ b/dom/l10n/tests/mochitest/l10n_mutations/test_append_fragment_post_dcl.html
@@ -0,0 +1,39 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test L10n Mutations for appending a fragment after DOMContentLoaded</title>
+ <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <link rel="localization" href="crashreporter/aboutcrashes.ftl"/>
+ <script type="application/javascript">
+ "use strict";
+ SimpleTest.waitForExplicitFinish();
+
+ document.addEventListener("DOMContentLoaded", function() {
+ let frag = document.createDocumentFragment();
+ let elem = document.createElement("div");
+ document.l10n.setAttributes(elem, "crash-reports-title");
+ frag.appendChild(elem);
+
+ let elem2 = document.createElement("div");
+ document.l10n.setAttributes(elem2, "crash-reports-title");
+ frag.appendChild(elem2);
+
+ is(elem.textContent.length, 0);
+ is(elem2.textContent.length, 0);
+
+ let verifyL10n = () => {
+ if (elem.textContent.length && elem2.textContent.length) {
+ window.removeEventListener("MozAfterPaint", verifyL10n);
+ SimpleTest.finish();
+ }
+ };
+ window.addEventListener("MozAfterPaint", verifyL10n);
+ document.body.appendChild(frag);
+ });
+ </script>
+</head>
+<body>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/l10n_mutations/test_disconnectedRoot_webcomponent.html b/dom/l10n/tests/mochitest/l10n_mutations/test_disconnectedRoot_webcomponent.html
new file mode 100644
index 0000000000..bb9d9fc24d
--- /dev/null
+++ b/dom/l10n/tests/mochitest/l10n_mutations/test_disconnectedRoot_webcomponent.html
@@ -0,0 +1,148 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test DOMLocalization.prototype.connectRoot with Web Components</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript">
+ "use strict";
+ const l10nReg = new L10nRegistry();
+ const fs = [
+ { path: "/localization/en-US/mock.ftl", source: `
+key1 = Key 1
+key2 = Key 2
+key3 = Key 3
+key4 = Key 4
+` },
+ ];
+ const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs);
+ l10nReg.registerSources([source]);
+
+ document.domLoc = new DOMLocalization(
+ ["/mock.ftl"],
+ false,
+ l10nReg,
+ ["en-US"],
+ );
+ document.domLoc.connectRoot(document.documentElement);
+ </script>
+ <script type="application/javascript">
+ // In this test we're going to use two elements - `shadowLabel` and `lightLabel`.
+ // We create a new `DOMLocalization` and connect it to the document's root first.
+ //
+ // Then, we connect and disconnect it on root and element within the shadow DOM and
+ // apply new `data-l10n-id` onto both labels.
+ // Once the `lightLabel` get a new translation, we check what happened to the `shadowLabel`
+ // to ensure that depending on the status of connection between the shadow DOM and the `DOMLocalization`
+ // the `shadowLabel` either gets translated or not.
+
+ SimpleTest.waitForExplicitFinish();
+
+ class FluentWidget extends HTMLElement {
+ constructor() {
+ super();
+ const shadowRoot = this.attachShadow({mode: "open"});
+ const t = document.querySelector("#fluent-widget-template");
+ const instance = t.content.cloneNode(true);
+ shadowRoot.appendChild(instance);
+ }
+ connectedCallback() {
+ this.runTests();
+ }
+ runTests() {
+ // First, let's verify that the mutation will not be applied since
+ // the shadow DOM is not connected to the `DOMLocalization`.
+ let shadowLabel = this.shadowRoot.getElementById("shadowLabel");
+ let lightLabel = document.getElementById("lightLabel");
+
+ let verifyL10n = () => {
+ if (lightLabel.textContent == "Key 1") {
+ is(shadowLabel.textContent, "", "document.l10n content not applied to an element in the shadow DOM");
+ window.removeEventListener("MozAfterPaint", verifyL10n);
+ this.testPart2();
+ }
+ };
+ window.addEventListener("MozAfterPaint", verifyL10n);
+
+ document.domLoc.setAttributes(shadowLabel, "key1");
+ document.domLoc.setAttributes(lightLabel, "key1");
+ }
+ testPart2() {
+ // Next, we connect the shadow root to DOMLocalization and the next attribute
+ // change should result in a translation being applied.
+ document.domLoc.connectRoot(this.shadowRoot);
+
+ let shadowLabel = this.shadowRoot.getElementById("shadowLabel");
+ let lightLabel = document.getElementById("lightLabel");
+
+ // Test that mutation was applied.
+ let verifyL10n = () => {
+ if (lightLabel.textContent == "Key 2") {
+ is(shadowLabel.textContent, "Key 2", "document.l10n content applied to an element in the shadow DOM");
+ window.removeEventListener("MozAfterPaint", verifyL10n);
+ this.testPart3();
+ }
+ };
+ window.addEventListener("MozAfterPaint", verifyL10n);
+
+ document.domLoc.setAttributes(shadowLabel, "key2");
+ document.domLoc.setAttributes(lightLabel, "key2");
+ }
+ testPart3() {
+ // After we disconnect the shadow root, the mutations should
+ // not be applied onto the `shadowLabel`.
+ document.domLoc.disconnectRoot(this.shadowRoot);
+
+ let shadowLabel = this.shadowRoot.getElementById("shadowLabel");
+ let lightLabel = document.getElementById("lightLabel");
+
+ let verifyL10n = () => {
+ if (lightLabel.textContent == "Key 3") {
+ is(shadowLabel.textContent, "Key 2", "document.l10n content not applied to an element in the shadow DOM");
+ window.removeEventListener("MozAfterPaint", verifyL10n);
+ this.testPart4();
+ }
+ };
+ window.addEventListener("MozAfterPaint", verifyL10n);
+
+ document.domLoc.setAttributes(shadowLabel, "key3");
+ document.domLoc.setAttributes(lightLabel, "key3");
+ }
+ testPart4() {
+ // Finally, we'll connect it back, but this time, we'll connect
+ // not the shadow root, but an element within it.
+ // This should still result in the `shadowLabel` receiving a new translation.
+ document.domLoc.connectRoot(this.shadowRoot.getElementById("shadowDiv"));
+
+ let shadowLabel = this.shadowRoot.getElementById("shadowLabel");
+ let lightLabel = document.getElementById("lightLabel");
+
+ // Test that mutation was applied.
+ let verifyL10n = () => {
+ if (lightLabel.textContent == "Key 4") {
+ is(shadowLabel.textContent, "Key 4", "document.l10n content applied to an element in the shadow DOM");
+ window.removeEventListener("MozAfterPaint", verifyL10n);
+ SimpleTest.finish();
+ }
+ };
+ window.addEventListener("MozAfterPaint", verifyL10n);
+
+ document.domLoc.setAttributes(shadowLabel, "key4");
+ document.domLoc.setAttributes(lightLabel, "key4");
+ }
+ }
+ customElements.define("fluent-widget", FluentWidget);
+ </script>
+</head>
+<body>
+ <p id="lightLabel"></p>
+
+ <template id="fluent-widget-template">
+ <div id="shadowDiv">
+ <p id="shadowLabel"></p>
+ </div>
+ </template>
+ <fluent-widget id="widget1"></fluent-widget>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/l10n_mutations/test_pause_observing.html b/dom/l10n/tests/mochitest/l10n_mutations/test_pause_observing.html
new file mode 100644
index 0000000000..d225153418
--- /dev/null
+++ b/dom/l10n/tests/mochitest/l10n_mutations/test_pause_observing.html
@@ -0,0 +1,44 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test L10n Mutations for Pause/Resume Observing</title>
+ <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <link rel="localization" href="crashreporter/aboutcrashes.ftl"/>
+ <script type="application/javascript">
+ "use strict";
+ SimpleTest.waitForExplicitFinish();
+
+ document.addEventListener("DOMContentLoaded", function() {
+ let elem1 = document.createElement("div");
+ let elem2 = document.createElement("div");
+ let elem3 = document.createElement("div");
+ is(elem1.textContent.length, 0);
+ is(elem2.textContent.length, 0);
+ is(elem3.textContent.length, 0);
+
+ document.l10n.setAttributes(elem1, "crash-reports-title");
+ document.l10n.setAttributes(elem2, "crash-reports-title");
+ document.l10n.setAttributes(elem3, "crash-reports-title");
+
+ let verifyL10n = () => {
+ if (elem1.textContent.length &&
+ !elem2.textContent.length &&
+ elem3.textContent.length) {
+ window.removeEventListener("MozAfterPaint", verifyL10n);
+ SimpleTest.finish();
+ }
+ };
+ window.addEventListener("MozAfterPaint", verifyL10n);
+ document.body.appendChild(elem1);
+ document.l10n.pauseObserving();
+ document.body.appendChild(elem2);
+ document.l10n.resumeObserving();
+ document.body.appendChild(elem3);
+ });
+ </script>
+</head>
+<body>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/l10n_mutations/test_remove_element.html b/dom/l10n/tests/mochitest/l10n_mutations/test_remove_element.html
new file mode 100644
index 0000000000..347e858d52
--- /dev/null
+++ b/dom/l10n/tests/mochitest/l10n_mutations/test_remove_element.html
@@ -0,0 +1,68 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test L10n Mutations for removing element</title>
+ <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <link rel="localization" href="crashreporter/aboutcrashes.ftl"/>
+ <script type="application/javascript">
+ "use strict";
+ SimpleTest.waitForExplicitFinish();
+
+ document.addEventListener("DOMContentLoaded", async function() {
+ // This element will be added to DOM and expected to be localized.
+ let elem = document.createElement("div");
+
+ // This element will be added to DOM, then immediatelly removed and
+ // expected *not* to be localized.
+ let elem2 = document.createElement("div");
+
+ // This element will be added to DOM, then immediatelly removed and
+ // and then immediatelly re-added, and expected to be localized.
+ let elem3 = document.createElement("div");
+
+ // This element will be added to DOM, then immediatelly removed and
+ // and then re-added later, and expected to be localized.
+ let elem4 = document.createElement("div");
+
+ document.l10n.setAttributes(elem, "crash-reports-title");
+ document.l10n.setAttributes(elem2, "crash-reports-title");
+ document.l10n.setAttributes(elem3, "crash-reports-title");
+ document.l10n.setAttributes(elem4, "crash-reports-title");
+
+ document.body.appendChild(elem);
+ document.body.appendChild(elem2);
+ document.body.appendChild(elem3);
+ document.body.appendChild(elem4);
+
+ is(elem.textContent.length, 0);
+ is(elem2.textContent.length, 0);
+ is(elem3.textContent.length, 0);
+ is(elem4.textContent.length, 0);
+
+ document.body.removeChild(elem2);
+ document.body.removeChild(elem3);
+ document.body.removeChild(elem4);
+
+ document.body.appendChild(elem3);
+
+ // 1. `elem` should be localized since it is in DOM.
+ await SimpleTest.waitForCondition(() => elem.textContent.length);
+ // 2. `elem2` was removed before l10n frame, so it should remain not localized.
+ is(elem2.textContent.length, 0);
+ // 3. `elem3` was added/removed/re-added so it should become localized.
+ await SimpleTest.waitForCondition(() => elem3.textContent.length);
+ // 4. `elem4` was not re-added, so it shouldn't be localized.
+ is(elem4.textContent.length, 0);
+
+ document.body.appendChild(elem4);
+ // 5. Now we re-added `elem4` to DOM so it should get localized.
+ await SimpleTest.waitForCondition(() => elem4.textContent.length);
+ SimpleTest.finish();
+ });
+ </script>
+</head>
+<body>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/l10n_mutations/test_remove_fragment.html b/dom/l10n/tests/mochitest/l10n_mutations/test_remove_fragment.html
new file mode 100644
index 0000000000..4f4d0fe1d8
--- /dev/null
+++ b/dom/l10n/tests/mochitest/l10n_mutations/test_remove_fragment.html
@@ -0,0 +1,67 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test L10n Mutations for removing fragment</title>
+ <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <link rel="localization" href="crashreporter/aboutcrashes.ftl"/>
+ <script type="application/javascript">
+ "use strict";
+ SimpleTest.waitForExplicitFinish();
+
+ document.addEventListener("DOMContentLoaded", async function() {
+ let div = document.createElement("div");
+ let div2 = document.createElement("div");
+
+ let elem = document.createElement("p");
+ let elem2 = document.createElement("p");
+ let elem3 = document.createElement("p");
+ let elem4 = document.createElement("p");
+
+ document.l10n.setAttributes(elem, "crash-reports-title");
+ document.l10n.setAttributes(elem2, "crash-reports-title");
+ document.l10n.setAttributes(elem3, "crash-reports-title");
+ document.l10n.setAttributes(elem4, "crash-reports-title");
+
+ div.appendChild(elem);
+ div.appendChild(elem2);
+ div.appendChild(elem3);
+ div.appendChild(elem4);
+
+ document.body.appendChild(div);
+
+ is(elem.textContent.length, 0);
+ is(elem2.textContent.length, 0);
+ is(elem3.textContent.length, 0);
+ is(elem4.textContent.length, 0);
+
+ document.body.removeChild(div);
+
+ div2.appendChild(elem);
+ div2.appendChild(elem3);
+
+ document.body.appendChild(div2);
+
+ // 1. `elem` should be localized since it is in DOM.
+ await SimpleTest.waitForCondition(() => !!elem.textContent.length);
+
+ // 2. `elem2` was removed before l10n frame, so it should remain not localized.
+ is(elem2.textContent.length, 0);
+
+ // 3. `elem3` was added/removed/re-added so it should become localized.
+ await SimpleTest.waitForCondition(() => !!elem3.textContent.length);
+
+ // 4. `elem4` was not re-added, so it shouldn't be localized.
+ is(elem4.textContent.length, 0);
+
+ document.body.appendChild(div);
+ // 5. Now we re-added `elem4` to DOM so it should get localized.
+ await SimpleTest.waitForCondition(() => !!elem4.textContent.length);
+ SimpleTest.finish();
+ });
+ </script>
+</head>
+<body>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/l10n_mutations/test_set_attributes.html b/dom/l10n/tests/mochitest/l10n_mutations/test_set_attributes.html
new file mode 100644
index 0000000000..762353a06c
--- /dev/null
+++ b/dom/l10n/tests/mochitest/l10n_mutations/test_set_attributes.html
@@ -0,0 +1,37 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test L10n Mutations for AttributeChange after DOMContentLoaded</title>
+ <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <link rel="localization" href="crashreporter/aboutcrashes.ftl"/>
+ <link rel="localization" href="toolkit/about/aboutCompat.ftl"/>
+ <script type="application/javascript">
+ "use strict";
+ SimpleTest.waitForExplicitFinish();
+
+ document.addEventListener("DOMContentLoaded", async function() {
+ await document.l10n.ready;
+ let elem = document.getElementById("elem1");
+ let elem2 = document.getElementById("elem2");
+ is(elem.textContent.length, 0);
+ is(elem2.textContent.includes("Initial string"), true);
+ document.l10n.setAttributes(elem, "crash-reports-title");
+ elem2.setAttribute("data-l10n-args", JSON.stringify({bug: "New string"}));
+
+ let verifyL10n = () => {
+ if (elem.textContent.length && elem2.textContent.includes("New string")) {
+ window.removeEventListener("MozAfterPaint", verifyL10n);
+ SimpleTest.finish();
+ }
+ };
+ window.addEventListener("MozAfterPaint", verifyL10n);
+ });
+ </script>
+</head>
+<body>
+ <div id="elem1"></div>
+ <div id="elem2" data-l10n-id="label-more-information" data-l10n-args='{"bug":"Initial string"}'></div>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/l10n_mutations/test_template.html b/dom/l10n/tests/mochitest/l10n_mutations/test_template.html
new file mode 100644
index 0000000000..d02ecfcd4d
--- /dev/null
+++ b/dom/l10n/tests/mochitest/l10n_mutations/test_template.html
@@ -0,0 +1,37 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test L10n Mutations in Template elements</title>
+ <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <link rel="localization" href="crashreporter/aboutcrashes.ftl"/>
+ <script type="application/javascript">
+ "use strict";
+ SimpleTest.waitForExplicitFinish();
+
+ document.addEventListener("DOMContentLoaded", async () => {
+ await document.l10n.ready;
+ let template = document.getElementById("template");
+ let clone = document.importNode(template.content, true);
+ let span = clone.querySelector("span");
+ is(span.textContent.length, 0,
+ "Element has not been translated while in template");
+ document.body.appendChild(clone);
+
+ let verifyL10n = () => {
+ if (span.textContent.length) {
+ window.removeEventListener("MozAfterPaint", verifyL10n);
+ SimpleTest.finish();
+ }
+ };
+ window.addEventListener("MozAfterPaint", verifyL10n);
+ });
+ </script>
+</head>
+<body>
+ <template id="template">
+ <span data-l10n-id="crash-reports-title"></span>
+ </template>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/l10n_overlays/test_attributes.html b/dom/l10n/tests/mochitest/l10n_overlays/test_attributes.html
new file mode 100644
index 0000000000..3d1f6048b2
--- /dev/null
+++ b/dom/l10n/tests/mochitest/l10n_overlays/test_attributes.html
@@ -0,0 +1,86 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test L10nOverlays Top-level attributes</title>
+ <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript">
+ /* global L10nOverlays */
+ "use strict";
+
+ function elem(name) {
+ return function(str) {
+ const element = document.createElement(name);
+ // eslint-disable-next-line no-unsanitized/property
+ element.innerHTML = str;
+ return element;
+ };
+ }
+
+ const { translateElement } = L10nOverlays;
+
+ {
+ // Allowed attribute
+ const element = elem("div")``;
+ const translation = {
+ value: null,
+ attributes: [
+ {name: "title", value: "FOO"},
+ ],
+ };
+ translateElement(element, translation);
+ is(element.outerHTML, '<div title="FOO"></div>');
+ }
+
+ {
+ // Forbidden attribute
+ const element = elem("input")``;
+ const translation = {
+ value: null,
+ attributes: [
+ {name: "disabled", value: "DISABLED"},
+ ],
+ };
+ translateElement(element, translation);
+ is(element.outerHTML, "<input>");
+ }
+
+ {
+ // Attributes do not leak on first translation
+ const element = elem("div")`Foo`;
+ element.setAttribute("title", "Title");
+
+ const translation = {
+ value: "FOO",
+ attributes: null,
+ };
+ translateElement(element, translation);
+ is(element.outerHTML, "<div>FOO</div>");
+ }
+
+ {
+ // Attributes do not leak on retranslation
+ const element = elem("div")`Foo`;
+
+ const translationA = {
+ value: "FOO A",
+ attributes: [
+ {name: "title", value: "TITLE A"},
+ ],
+ };
+
+ const translationB = {
+ value: "FOO B",
+ attributes: null,
+ };
+ translateElement(element, translationA);
+ is(element.outerHTML, '<div title="TITLE A">FOO A</div>');
+ translateElement(element, translationB);
+ is(element.outerHTML, "<div>FOO B</div>");
+ }
+ </script>
+</head>
+<body>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/l10n_overlays/test_extra_text_markup.html b/dom/l10n/tests/mochitest/l10n_overlays/test_extra_text_markup.html
new file mode 100644
index 0000000000..5f60599dfc
--- /dev/null
+++ b/dom/l10n/tests/mochitest/l10n_overlays/test_extra_text_markup.html
@@ -0,0 +1,136 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test L10nOverlays Localized text markup</title>
+ <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript">
+ /* global L10nOverlays */
+ "use strict";
+
+ function elem(name) {
+ return function(str) {
+ const element = document.createElement(name);
+ // eslint-disable-next-line no-unsanitized/property
+ element.innerHTML = str;
+ return element;
+ };
+ }
+
+ const { translateElement } = L10nOverlays;
+
+ // Localized text markup
+ {
+ // allowed element
+ const element = elem("div")`Foo`;
+ const translation = {
+ value: "FOO <em>BAR</em> BAZ",
+ attributes: null,
+ };
+
+ translateElement(element, translation);
+ is(element.innerHTML, "FOO <em>BAR</em> BAZ");
+ }
+
+ {
+ // forbidden element
+ const element = elem("div")`Foo`;
+ const translation = {
+ value: 'FOO <img src="img.png" />',
+ attributes: null,
+ };
+
+ translateElement(element, translation);
+ is(element.innerHTML, "FOO ");
+ }
+
+ {
+ // forbiden element with text
+ const element = elem("div")`Foo`;
+ const translation = {
+ value: "FOO <a>a</a>",
+ attributes: null,
+ };
+
+ translateElement(element, translation);
+ is(element.innerHTML, "FOO a");
+ }
+
+ {
+ // nested HTML is forbidden
+ const element = elem("div")`Foo`;
+ const translation = {
+ value: "FOO <em><strong>BAR</strong></em> BAZ",
+ attributes: null,
+ };
+
+ translateElement(element, translation);
+ is(element.innerHTML, "FOO <em>BAR</em> BAZ");
+ }
+
+ // Attributes of localized text markup
+ {
+ // allowed attribute
+ const element = elem("div")`Foo Bar`;
+ const translation = {
+ value: 'FOO <em title="BAR">BAR</em>',
+ attributes: null,
+ };
+
+ translateElement(element, translation);
+ is(element.innerHTML,
+ 'FOO <em title="BAR">BAR</em>');
+ }
+
+ {
+ // forbidden attribute
+ const element = elem("div")`Foo Bar`;
+ const translation = {
+ value: 'FOO <em class="BAR" title="BAR">BAR</em>',
+ attributes: null,
+ };
+
+ translateElement(element, translation);
+ is(element.innerHTML,
+ 'FOO <em title="BAR">BAR</em>');
+ }
+
+ {
+ // attributes do not leak on first translation
+ const element = elem("div")`
+ <em title="Foo">Foo</a>`;
+ const translation = {
+ value: "<em>FOO</em>",
+ attributes: null,
+ };
+
+ translateElement(element, translation);
+ is(element.innerHTML,
+ "<em>FOO</em>");
+ }
+
+ {
+ // attributes do not leak on retranslation
+ const element = elem("div")``;
+ const translationA = {
+ value: '<em title="FOO A">FOO A</em>',
+ attributes: null,
+ };
+ const translationB = {
+ value: "<em>FOO B</em>",
+ attributes: null,
+ };
+
+ translateElement(element, translationA);
+ is(element.innerHTML,
+ '<em title="FOO A">FOO A</em>');
+ translateElement(element, translationB);
+ is(element.innerHTML,
+ "<em>FOO B</em>");
+ }
+ </script>
+</head>
+<body>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/l10n_overlays/test_functional_children.html b/dom/l10n/tests/mochitest/l10n_overlays/test_functional_children.html
new file mode 100644
index 0000000000..dba5b6e633
--- /dev/null
+++ b/dom/l10n/tests/mochitest/l10n_overlays/test_functional_children.html
@@ -0,0 +1,344 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test L10nOverlays functional children test</title>
+ <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript">
+ /* global L10nOverlays */
+ "use strict";
+
+ function elem(name) {
+ return function(str) {
+ const element = document.createElement(name);
+ // eslint-disable-next-line no-unsanitized/property
+ element.innerHTML = str;
+ return element;
+ };
+ }
+
+ const { translateElement } = L10nOverlays;
+
+ // Child without name
+ {
+ // in source
+ const element = elem("div")`
+ <a>Foo</a>`;
+ const translation = {
+ value: "FOO",
+ attributes: null,
+ };
+ translateElement(element, translation);
+ is(element.innerHTML, "FOO");
+ }
+
+ {
+ // in translation
+ const element = elem("div")`Foo`;
+ const translation = {
+ value: "<a>FOO</a>",
+ attributes: null,
+ };
+ translateElement(element, translation);
+ is(element.innerHTML, "FOO");
+ }
+
+ {
+ // in both
+ const element = elem("div")`
+ <a>Foo</a>`;
+ const translation = {
+ value: "<a>FOO</a>",
+ attributes: null,
+ };
+ translateElement(element, translation);
+ is(element.innerHTML, "FOO");
+ }
+
+ // Child with name
+ {
+ // in source
+ const element = elem("div")`
+ <a data-l10n-name="foo">Foo</a>`;
+ const translation = {
+ value: "<a>FOO</a>",
+ attributes: null,
+ };
+ translateElement(element, translation);
+ is(element.innerHTML, "FOO");
+ }
+
+ {
+ // in translation
+ const element = elem("div")`
+ <a>Foo</a>`;
+ const translation = {
+ value: '<a data-l10n-name="foo">FOO</a>',
+ attributes: null,
+ };
+ translateElement(element, translation);
+ is(element.innerHTML, "FOO");
+ }
+
+ {
+ // in both
+ const element = elem("div")`
+ <a data-l10n-name="foo">Foo</a>`;
+ const translation = {
+ value: '<a data-l10n-name="foo">FOO</a>',
+ attributes: null,
+ };
+ translateElement(element, translation);
+ is(element.innerHTML, '<a data-l10n-name="foo">FOO</a>');
+ }
+
+ {
+ // translation without text content
+ const element = elem("div")`
+ <a data-l10n-name="foo">Foo</a>`;
+ const translation = {
+ value: '<a data-l10n-name="foo"></a>',
+ attributes: null,
+ };
+ translateElement(element, translation);
+ is(element.innerHTML, '<a data-l10n-name="foo"></a>');
+ }
+
+ {
+ // different names
+ const element = elem("div")`
+ <a data-l10n-name="foo">Foo</a>`;
+ const translation = {
+ value: '<a data-l10n-name="bar">BAR</a>',
+ attributes: null,
+ };
+ translateElement(element, translation);
+ is(element.innerHTML, "BAR");
+ }
+
+ {
+ // of different types
+ const element = elem("div")`
+ <a data-l10n-name="foo">Foo</a>`;
+ const translation = {
+ value: '<div data-l10n-name="foo">FOO</div>',
+ attributes: null,
+ };
+ translateElement(element, translation);
+ is(element.innerHTML, "FOO");
+ }
+
+ {
+ // used twice
+ const element = elem("div")`
+ <a data-l10n-name="foo">Foo</a>`;
+ const translation = {
+ value: '<a data-l10n-name="foo">FOO 1</a> <a data-l10n-name="foo">FOO 2</a>',
+ attributes: null,
+ };
+ translateElement(element, translation);
+ is(element.innerHTML, '<a data-l10n-name="foo">FOO 1</a> FOO 2');
+ }
+
+ // Two named children
+ {
+ // in order
+ const element = elem("div")`
+ <a data-l10n-name="foo">Foo</a>
+ <a data-l10n-name="bar">Bar</a>`;
+ const translation = {
+ value: '<a data-l10n-name="foo">FOO</a><a data-l10n-name="bar">BAR</a>',
+ attributes: null,
+ };
+ translateElement(element, translation);
+ is(element.innerHTML, '<a data-l10n-name="foo">FOO</a><a data-l10n-name="bar">BAR</a>');
+ }
+
+ {
+ // out of order
+ const element = elem("div")`
+ <a data-l10n-name="foo">Foo</a>
+ <a data-l10n-name="bar">Bar</a>`;
+ const translation = {
+ value: '<a data-l10n-name="bar">BAR</a><a data-l10n-name="foo">FOO</a>',
+ attributes: null,
+ };
+ translateElement(element, translation);
+ is(element.innerHTML, '<a data-l10n-name="bar">BAR</a><a data-l10n-name="foo">FOO</a>');
+ }
+
+ {
+ // nested in source
+ const element = elem("div")`
+ <a data-l10n-name="foo">
+ Foo 1
+ <a data-l10n-name="bar">Bar</a>
+ Foo 2
+ </a>`;
+ const translation = {
+ value: '<a data-l10n-name="foo">FOO</a><a data-l10n-name="bar">BAR</a>',
+ attributes: null,
+ };
+ translateElement(element, translation);
+ is(
+ element.innerHTML,
+ '<a data-l10n-name="foo">FOO</a><a data-l10n-name="bar">BAR</a>'
+ );
+ }
+
+ {
+ // nested in translation
+ const element = elem("div")`
+ <div data-l10n-name="foo">Foo</div>
+ <div data-l10n-name="bar">Bar</div>`;
+ const translation = {
+ value: '<div data-l10n-name="foo">FOO 1 <div data-l10n-name="bar">BAR</div> FOO 2</div>',
+ attributes: null,
+ };
+ translateElement(element, translation);
+ is(
+ element.innerHTML,
+ '<div data-l10n-name="foo">FOO 1 BAR FOO 2</div>'
+ );
+ }
+
+ // Child attributes
+ {
+ // functional attribute in source
+ const element = elem("div")`
+ <a data-l10n-name="foo" class="foo">Foo</a>`;
+ const translation = {
+ value: '<a data-l10n-name="foo">FOO</a>',
+ attributes: null,
+ };
+
+ translateElement(element, translation);
+ is(element.innerHTML,
+ '<a data-l10n-name="foo" class="foo">FOO</a>');
+ }
+
+ {
+ // functional attribute in translation
+ const element = elem("div")`
+ <a data-l10n-name="foo">Foo</a>`;
+ const translation = {
+ value: '<a data-l10n-name="foo" class="bar">FOO</a>',
+ attributes: null,
+ };
+
+ translateElement(element, translation);
+ is(element.innerHTML,
+ '<a data-l10n-name="foo">FOO</a>');
+ }
+
+ {
+ // functional attribute in both
+ const element = elem("div")`
+ <a data-l10n-name="foo" class="foo">Foo</a>`;
+ const translation = {
+ value: '<a data-l10n-name="foo" class="bar">FOO</a>',
+ attributes: null,
+ };
+
+ translateElement(element, translation);
+ is(element.innerHTML,
+ '<a data-l10n-name="foo" class="foo">FOO</a>');
+ }
+
+ {
+ // localizable attribute in source
+ const element = elem("div")`
+ <a data-l10n-name="foo" title="Foo">Foo</a>`;
+ const translation = {
+ value: '<a data-l10n-name="foo">FOO</a>',
+ attributes: null,
+ };
+
+ translateElement(element, translation);
+ is(element.innerHTML,
+ '<a data-l10n-name="foo">FOO</a>');
+ }
+
+ {
+ // localizable attribute in translation
+ const element = elem("div")`
+ <a data-l10n-name="foo">Foo</a>`;
+ const translation = {
+ value: '<a data-l10n-name="foo" title="FOO">FOO</a>',
+ attributes: null,
+ };
+
+ translateElement(element, translation);
+ is(element.innerHTML,
+ '<a data-l10n-name="foo" title="FOO">FOO</a>');
+ }
+
+ {
+ // localizable attribute in both
+ const element = elem("div")`
+ <a data-l10n-name="foo" title="Foo">Foo</a>`;
+ const translation = {
+ value: '<a data-l10n-name="foo" title="BAR">FOO</a>',
+ attributes: null,
+ };
+
+ translateElement(element, translation);
+ is(element.innerHTML,
+ '<a data-l10n-name="foo" title="BAR">FOO</a>');
+ }
+
+ {
+ // localizable attribute does not leak on retranslation
+ const element = elem("div")`
+ <a data-l10n-name="foo">Foo</a>`;
+ const translationA = {
+ value: '<a data-l10n-name="foo" title="FOO A">FOO A</a>',
+ attributes: null,
+ };
+ const translationB = {
+ value: '<a data-l10n-name="foo">FOO B</a>',
+ attributes: null,
+ };
+
+ translateElement(element, translationA);
+ is(element.innerHTML,
+ '<a data-l10n-name="foo" title="FOO A">FOO A</a>');
+ translateElement(element, translationB);
+ is(element.innerHTML,
+ '<a data-l10n-name="foo">FOO B</a>');
+ }
+
+ // Child attributes overrides
+ {
+ // the source can override child's attributes
+ const element = elem("div")`
+ <a data-l10n-name="foo" data-l10n-attrs="class" class="foo">Foo</a>`;
+ const translation = {
+ value: '<a data-l10n-name="foo" class="FOO">FOO</a>',
+ attributes: null,
+ };
+
+ translateElement(element, translation);
+ is(element.innerHTML,
+ '<a data-l10n-name="foo" data-l10n-attrs="class" class="FOO">FOO</a>');
+ }
+
+ {
+ // the translation cannot override child's attributes
+ const element = elem("div")`
+ <a data-l10n-name="foo" class="foo">Foo</a>`;
+ const translation = {
+ value: '<a data-l10n-name="foo" data-l10n-attrs="class" class="FOO">FOO</a>',
+ attributes: null,
+ };
+
+ translateElement(element, translation);
+ is(element.innerHTML,
+ '<a data-l10n-name="foo" class="foo">FOO</a>');
+ }
+ </script>
+</head>
+<body>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/l10n_overlays/test_l10n_overlays.xhtml b/dom/l10n/tests/mochitest/l10n_overlays/test_l10n_overlays.xhtml
new file mode 100644
index 0000000000..494958c573
--- /dev/null
+++ b/dom/l10n/tests/mochitest/l10n_overlays/test_l10n_overlays.xhtml
@@ -0,0 +1,87 @@
+<?xml version="1.0"?>
+<?xml-stylesheet href="chrome://global/skin" type="text/css"?>
+<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css"
+ type="text/css"?>
+
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ title="Testing DocumentL10n in XUL environment">
+
+ <linkset>
+ <html:link rel="localization" href="toolkit/about/aboutAddons.ftl"/>
+ </linkset>
+
+ <script type="application/javascript"
+ src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" />
+ <script type="application/javascript">
+ <![CDATA[
+ /* global L10nOverlays */
+
+ function elem(name) {
+ return function(str) {
+ const element = document.createXULElement(name);
+ // eslint-disable-next-line no-unsanitized/property
+ element.innerHTML = str;
+ return element;
+ };
+ }
+
+ const { translateElement } = L10nOverlays;
+
+ SimpleTest.waitForExplicitFinish();
+
+ {
+ // Allowed attribute
+ const element = elem("description")``;
+ const translation = {
+ value: null,
+ attributes: [
+ {name: "title", value: "FOO"},
+ ],
+ };
+ translateElement(element, translation);
+ is(element.outerHTML, '<description xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" title="FOO"/>');
+ }
+
+ document.addEventListener("DOMContentLoaded", () => {
+ {
+ // Handle HTML translation
+ const element = document.getElementById("test2");
+ const translation = {
+ value: "This is <a data-l10n-name=\"link\">a link</a>.",
+ attributes: null,
+ };
+ translateElement(element, translation);
+ is(element.innerHTML, 'This is <html:a xmlns:html="http://www.w3.org/1999/xhtml" data-l10n-name=\"link\" href="https://www.mozilla.org\">a link</html:a>.');
+ }
+
+ {
+ // Don't handle XUL translation
+ //
+ // Current iteration of L10nOverlays will replace
+ // XUL elements from translation with text.
+ //
+ // See bug 1545704 for details.
+ const element = document.getElementById("test3");
+ const translation = {
+ value: "This is <description data-l10n-name=\"desc\">a desc</description>.",
+ attributes: null,
+ };
+ translateElement(element, translation);
+ is(element.innerHTML, 'This is a desc.');
+ }
+ SimpleTest.finish();
+ }, {once: true});
+
+ ]]>
+ </script>
+
+ <description id="test2">
+ <html:a data-l10n-name="link" href="https://www.mozilla.org"/>
+ </description>
+
+ <box id="test3">
+ <description data-l10n-name="desc"/>
+ </box>
+</window>
diff --git a/dom/l10n/tests/mochitest/l10n_overlays/test_same_id.html b/dom/l10n/tests/mochitest/l10n_overlays/test_same_id.html
new file mode 100644
index 0000000000..ecaefbd68d
--- /dev/null
+++ b/dom/l10n/tests/mochitest/l10n_overlays/test_same_id.html
@@ -0,0 +1,57 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test Amount of mutations generated from DOM Overlays</title>
+ <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <link rel="localization" href="toolkit/about/aboutTelemetry.ftl"/>
+ <script type="application/javascript">
+ "use strict";
+ SimpleTest.waitForExplicitFinish();
+
+ let config = {
+ attributes: true,
+ attributeOldValue: true,
+ characterData: true,
+ characterDataOldValue: true,
+ childList: true,
+ subtree: true,
+ };
+ let allMutations = [];
+
+ document.addEventListener("DOMContentLoaded", async function() {
+ await document.l10n.ready;
+
+ let inputElem = document.getElementById("search-input");
+
+ // Test for initial localization applied.
+ is(!!inputElem.getAttribute("placeholder").length, true);
+
+ let observer = new MutationObserver((mutations) => {
+ for (let mutation of mutations) {
+ allMutations.push(mutation);
+ }
+ });
+ observer.observe(inputElem, config);
+
+ document.l10n.setAttributes(inputElem, "about-telemetry-filter-all-placeholder");
+
+ // Due to the async iteractions between nsINode.localize
+ // and DOMLocalization, we'll need to wait two frames
+ // to verify that no mutations happened.
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ // Since the l10n-id is the same as the previous one
+ // no mutation should happen in result.
+ is(allMutations.length, 0);
+ SimpleTest.finish();
+ });
+ });
+ }, { once: true});
+ </script>
+</head>
+<body>
+ <input id="search-input" data-l10n-id="about-telemetry-filter-all-placeholder"></input>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/l10n_overlays/test_same_id_args.html b/dom/l10n/tests/mochitest/l10n_overlays/test_same_id_args.html
new file mode 100644
index 0000000000..e43c394970
--- /dev/null
+++ b/dom/l10n/tests/mochitest/l10n_overlays/test_same_id_args.html
@@ -0,0 +1,57 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test Amount of mutations generated from DOM Overlays</title>
+ <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <link rel="localization" href="toolkit/about/aboutTelemetry.ftl"/>
+ <script type="application/javascript">
+ "use strict";
+ SimpleTest.waitForExplicitFinish();
+
+ let config = {
+ attributes: true,
+ attributeOldValue: true,
+ characterData: true,
+ characterDataOldValue: true,
+ childList: true,
+ subtree: true,
+ };
+ let allMutations = [];
+
+ document.addEventListener("DOMContentLoaded", async function() {
+ await document.l10n.ready;
+
+ let inputElem = document.getElementById("search-input");
+
+ // Test for initial localization applied.
+ is(!!inputElem.getAttribute("placeholder").length, true);
+
+ let observer = new MutationObserver((mutations) => {
+ for (let mutation of mutations) {
+ allMutations.push(mutation);
+ }
+ });
+ observer.observe(inputElem, config);
+
+ document.l10n.setAttributes(inputElem, "about-telemetry-filter-placeholder", {selectedTitle: "Test"});
+
+ // Due to the async iteractions between nsINode.localize
+ // and DOMLocalization, we'll need to wait two frames
+ // to verify that no mutations happened.
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ // Since the l10n-id is the same as the previous one
+ // no mutation should happen in result.
+ is(allMutations.length, 0);
+ SimpleTest.finish();
+ });
+ });
+ }, { once: true});
+ </script>
+</head>
+<body>
+ <input id="search-input" data-l10n-id="about-telemetry-filter-placeholder" data-l10n-args='{"selectedTitle":"Test"}'></input>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/l10n_overlays/test_text_children.html b/dom/l10n/tests/mochitest/l10n_overlays/test_text_children.html
new file mode 100644
index 0000000000..1c2fab7ade
--- /dev/null
+++ b/dom/l10n/tests/mochitest/l10n_overlays/test_text_children.html
@@ -0,0 +1,74 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test L10nOverlays Text-semantic argument elements</title>
+ <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript">
+ /* global L10nOverlays */
+ "use strict";
+
+ function elem(name) {
+ return function(str) {
+ const element = document.createElement(name);
+ // eslint-disable-next-line no-unsanitized/property
+ element.innerHTML = str;
+ return element;
+ };
+ }
+
+ const { translateElement } = L10nOverlays;
+
+ {
+ // without data-l10n-name
+ const element = elem("div")`
+ <em class="bar"></em>`;
+ const translation = {
+ value: '<em title="FOO">FOO</em>',
+ attributes: null,
+ };
+
+ translateElement(element, translation);
+ is(
+ element.innerHTML,
+ '<em title="FOO">FOO</em>'
+ );
+ }
+
+ {
+ // mismatched types
+ const element = elem("div")`
+ <button data-l10n-name="foo"></button>`;
+ const translation = {
+ value: '<em data-l10n-name="foo" title="FOO">FOO</em>',
+ attributes: null,
+ };
+
+ translateElement(element, translation);
+ is(
+ element.innerHTML,
+ "FOO"
+ );
+ }
+
+ {
+ // types and names mismatch
+ const element = elem("div")`
+ <em data-l10n-name="foo" class="foo"></em>`;
+ const translation = {
+ value: '<em data-l10n-name="foo" title="FOO">FOO</em>',
+ attributes: null,
+ };
+
+ translateElement(element, translation);
+ is(
+ element.innerHTML,
+ '<em data-l10n-name="foo" class="foo" title="FOO">FOO</em>'
+ );
+ }
+ </script>
+</head>
+<body>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/l10n_overlays/test_title.html b/dom/l10n/tests/mochitest/l10n_overlays/test_title.html
new file mode 100644
index 0000000000..4571589b8e
--- /dev/null
+++ b/dom/l10n/tests/mochitest/l10n_overlays/test_title.html
@@ -0,0 +1,60 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test L10nOverlays Special treatment of the title element</title>
+ <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript">
+ /* global L10nOverlays */
+ "use strict";
+
+ function elem(name) {
+ return function(str) {
+ const element = document.createElement(name);
+ // eslint-disable-next-line no-unsanitized/property
+ element.innerHTML = str;
+ return element;
+ };
+ }
+
+ const { translateElement } = L10nOverlays;
+
+ {
+ // Text is fine.
+ const element = elem("title")``;
+ const translation = {
+ value: 'Text',
+ attributes: null,
+ };
+
+ translateElement(element, translation);
+ is(
+ element.innerHTML,
+ 'Text'
+ );
+ }
+
+ {
+ // Markup is ignored.
+ const element = elem("title")``;
+ const translation = {
+ value: '<em>Markup</em>',
+ attributes: null,
+ };
+
+ translateElement(element, translation);
+ is(
+ element.textContent,
+ '<em>Markup</em>'
+ );
+ is(
+ element.innerHTML,
+ '&lt;em&gt;Markup&lt;/em&gt;'
+ );
+ }
+ </script>
+</head>
+<body>
+</body>
+</html>
diff --git a/dom/l10n/tests/mochitest/mochitest.ini b/dom/l10n/tests/mochitest/mochitest.ini
new file mode 100644
index 0000000000..82af57c47c
--- /dev/null
+++ b/dom/l10n/tests/mochitest/mochitest.ini
@@ -0,0 +1 @@
+[document_l10n/test_unpriv_iframe.html]