summaryrefslogtreecommitdiffstats
path: root/dom/l10n
diff options
context:
space:
mode:
Diffstat (limited to 'dom/l10n')
-rw-r--r--dom/l10n/DOMLocalization.cpp633
-rw-r--r--dom/l10n/DOMLocalization.h131
-rw-r--r--dom/l10n/DocumentL10n.cpp280
-rw-r--r--dom/l10n/DocumentL10n.h87
-rw-r--r--dom/l10n/L10nMutations.cpp218
-rw-r--r--dom/l10n/L10nMutations.h88
-rw-r--r--dom/l10n/L10nOverlays.cpp537
-rw-r--r--dom/l10n/L10nOverlays.h126
-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/.eslintrc.js5
-rw-r--r--dom/l10n/tests/mochitest/browser.ini4
-rw-r--r--dom/l10n/tests/mochitest/chrome.ini44
-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.js84
-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.html36
-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.html57
-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.html27
-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_unpriv_iframe.html26
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_attr_sanitized.html47
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_connectRoot.html43
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_connectRoot_webcomponent.html70
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_disconnectRoot.html58
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_domloc.xhtml66
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_getAttributes.html52
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_l10n_mutations.html53
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_overlay.html56
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_overlay_missing_all.html40
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_overlay_missing_children.html49
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_overlay_repeated.html46
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_overlay_sanitized.html50
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_repeated_l10nid.html58
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_setAttributes.html38
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_translateElements.html42
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_translateFragment.html44
-rw-r--r--dom/l10n/tests/mochitest/dom_localization/test_translateRoots.html52
-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.html146
-rw-r--r--dom/l10n/tests/mochitest/l10n_mutations/test_pause_observing.html44
-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
62 files changed, 5062 insertions, 0 deletions
diff --git a/dom/l10n/DOMLocalization.cpp b/dom/l10n/DOMLocalization.cpp
new file mode 100644
index 0000000000..2d3f6c767b
--- /dev/null
+++ b/dom/l10n/DOMLocalization.cpp
@@ -0,0 +1,633 @@
+/* -*- 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/LocaleService.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)
+
+/* static */
+already_AddRefed<DOMLocalization> DOMLocalization::Create(
+ nsIGlobalObject* aGlobal, const bool aSync,
+ const BundleGenerator& aBundleGenerator) {
+ RefPtr<DOMLocalization> domLoc =
+ new DOMLocalization(aGlobal, aSync, aBundleGenerator);
+
+ domLoc->Init();
+
+ return domLoc.forget();
+}
+
+DOMLocalization::DOMLocalization(nsIGlobalObject* aGlobal, const bool aSync,
+ const BundleGenerator& aBundleGenerator)
+ : Localization(aGlobal, aSync, aBundleGenerator) {
+ mMutations = new L10nMutations(this);
+}
+
+already_AddRefed<DOMLocalization> DOMLocalization::Constructor(
+ const GlobalObject& aGlobal, const Sequence<nsString>& aResourceIds,
+ const bool aSync, const BundleGenerator& aBundleGenerator,
+ ErrorResult& aRv) {
+ nsCOMPtr<nsIGlobalObject> global = do_QueryInterface(aGlobal.GetAsSupports());
+ if (!global) {
+ aRv.Throw(NS_ERROR_FAILURE);
+ return nullptr;
+ }
+
+ RefPtr<DOMLocalization> domLoc =
+ DOMLocalization::Create(global, aSync, aBundleGenerator);
+
+ if (aResourceIds.Length()) {
+ domLoc->AddResourceIds(aResourceIds);
+ }
+
+ domLoc->Activate(true);
+
+ return domLoc.forget();
+}
+
+JSObject* DOMLocalization::WrapObject(JSContext* aCx,
+ JS::Handle<JSObject*> aGivenProto) {
+ return DOMLocalization_Binding::Wrap(aCx, this, aGivenProto);
+}
+
+void DOMLocalization::Destroy() { DisconnectMutations(); }
+
+DOMLocalization::~DOMLocalization() { Destroy(); }
+
+/**
+ * DOMLocalization API
+ */
+
+void DOMLocalization::ConnectRoot(nsINode& aNode, ErrorResult& aRv) {
+ nsCOMPtr<nsIGlobalObject> global = aNode.GetOwnerGlobal();
+ if (!global) {
+ return;
+ }
+ MOZ_ASSERT(global == mGlobal,
+ "Cannot add a root that overlaps with existing root.");
+
+#ifdef DEBUG
+ for (auto iter = mRoots.ConstIter(); !iter.Done(); iter.Next()) {
+ nsINode* root = iter.Get()->GetKey();
+
+ MOZ_ASSERT(
+ root != &aNode && !root->Contains(&aNode) && !aNode.Contains(root),
+ "Cannot add a root that overlaps with existing root.");
+ }
+#endif
+
+ mRoots.PutEntry(&aNode);
+
+ aNode.AddMutationObserverUnlessExists(mMutations);
+}
+
+void DOMLocalization::DisconnectRoot(nsINode& aNode, ErrorResult& aRv) {
+ if (mRoots.Contains(&aNode)) {
+ aNode.RemoveMutationObserver(mMutations);
+ mRoots.RemoveEntry(&aNode);
+ }
+}
+
+void DOMLocalization::PauseObserving(ErrorResult& aRv) {
+ mMutations->PauseObserving();
+}
+
+void DOMLocalization::ResumeObserving(ErrorResult& aRv) {
+ mMutations->ResumeObserving();
+}
+
+void DOMLocalization::SetAttributes(
+ JSContext* aCx, Element& aElement, const nsAString& aId,
+ const Optional<JS::Handle<JSObject*>>& aArgs, ErrorResult& aRv) {
+ if (!aElement.AttrValueIs(kNameSpaceID_None, nsGkAtoms::datal10nid, aId,
+ eCaseMatters)) {
+ aElement.SetAttr(kNameSpaceID_None, nsGkAtoms::datal10nid, aId, true);
+ }
+
+ if (aArgs.WasPassed() && aArgs.Value()) {
+ nsAutoString data;
+ JS::Rooted<JS::Value> val(aCx, JS::ObjectValue(*aArgs.Value()));
+ if (!nsContentUtils::StringifyJSON(aCx, &val, data)) {
+ 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);
+ }
+}
+
+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);
+ }
+}
+
+already_AddRefed<Promise> DOMLocalization::TranslateFragment(nsINode& aNode,
+ ErrorResult& aRv) {
+ Sequence<OwningNonNull<Element>> elements;
+
+ GetTranslatables(aNode, elements, aRv);
+
+ 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) 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) 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 Sequence<OwningNonNull<Element>>& aElements, ErrorResult& aRv) {
+ return TranslateElements(aElements, nullptr, aRv);
+}
+
+already_AddRefed<Promise> DOMLocalization::TranslateElements(
+ const Sequence<OwningNonNull<Element>>& aElements,
+ nsXULPrototypeDocument* aProto, ErrorResult& aRv) {
+ JS::RootingContext* rcx = RootingCx();
+ Sequence<OwningUTF8StringOrL10nIdArgs> l10nKeys;
+ SequenceRooter<OwningUTF8StringOrL10nIdArgs> rooter(rcx, &l10nKeys);
+ RefPtr<ElementTranslationHandler> nativeHandler =
+ new ElementTranslationHandler(this, aProto);
+ nsTArray<nsCOMPtr<Element>>& domElements = nativeHandler->Elements();
+ domElements.SetCapacity(aElements.Length());
+
+ if (!mGlobal) {
+ return nullptr;
+ }
+
+ AutoEntryScript aes(mGlobal, "DOMLocalization TranslateElements");
+ JSContext* cx = aes.cx();
+
+ for (auto& domElement : aElements) {
+ if (!domElement->HasAttr(kNameSpaceID_None, 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)) {
+ 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 (mIsSync) {
+ nsTArray<Nullable<L10nMessage>> l10nMessages;
+
+ FormatMessagesSync(cx, l10nKeys, l10nMessages, aRv);
+
+ bool allTranslated =
+ ApplyTranslations(domElements, l10nMessages, aProto, aRv);
+ if (NS_WARN_IF(aRv.Failed()) || !allTranslated) {
+ promise->MaybeRejectWithUndefined();
+ return MaybeWrapPromise(promise);
+ }
+
+ promise->MaybeResolveWithUndefined();
+ } else {
+ RefPtr<Promise> callbackResult = FormatMessages(cx, 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) override {
+ DOMLocalization::SetRootInfo(mRoot);
+ }
+
+ void RejectedCallback(JSContext* aCx, JS::Handle<JS::Value> aValue) 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 (auto iter = mRoots.ConstIter(); !iter.Done(); iter.Next()) {
+ nsINode* root = iter.Get()->GetKey();
+
+ RefPtr<Promise> promise = TranslateFragment(*root, aRv);
+
+ // 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(aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ aRv.Throw(NS_ERROR_FAILURE);
+ return false;
+ }
+
+ bool hasMissingTranslation = false;
+
+ nsTArray<L10nOverlaysError> errors;
+ for (size_t i = 0; i < aTranslations.Length(); ++i) {
+ Element* 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;
+ }
+ 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(aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ aRv.Throw(NS_ERROR_FAILURE);
+ return false;
+ }
+
+ return !hasMissingTranslation;
+}
+
+/* Protected */
+
+void DOMLocalization::OnChange() {
+ Localization::OnChange();
+ if (mLocalization && !mResourceIds.IsEmpty()) {
+ ErrorResult rv;
+ RefPtr<Promise> promise = TranslateRoots(rv);
+ }
+}
+
+void DOMLocalization::DisconnectMutations() {
+ if (mMutations) {
+ mMutations->Disconnect();
+ DisconnectRoots();
+ }
+}
+
+void DOMLocalization::DisconnectRoots() {
+ for (auto iter = mRoots.ConstIter(); !iter.Done(); iter.Next()) {
+ nsINode* node = iter.Get()->GetKey();
+
+ 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.");
+ }
+ }
+ }
+}
+
+void DOMLocalization::ConvertStringToL10nArgs(const nsString& aInput,
+ intl::L10nArgs& aRetVal,
+ ErrorResult& aRv) {
+ // 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)) {
+ aRv.Throw(NS_ERROR_UNEXPECTED);
+ 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..f6ea7bd9b3
--- /dev/null
+++ b/dom/l10n/DOMLocalization.h
@@ -0,0 +1,131 @@
+/* -*- 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 "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"
+
+// XXX Avoid including this here by moving function bodies to the cpp file
+#include "nsINode.h"
+
+namespace mozilla {
+namespace dom {
+
+class Element;
+class L10nMutations;
+
+class DOMLocalization : public intl::Localization {
+ public:
+ NS_DECL_ISUPPORTS_INHERITED
+ NS_DECL_CYCLE_COLLECTION_CLASS_INHERITED(DOMLocalization, Localization)
+
+ static already_AddRefed<DOMLocalization> Create(
+ nsIGlobalObject* aGlobal, const bool aSync,
+ const BundleGenerator& aBundleGenerator);
+
+ void Destroy();
+
+ static already_AddRefed<DOMLocalization> Constructor(
+ const GlobalObject& aGlobal, const Sequence<nsString>& aResourceIds,
+ const bool aSync, const BundleGenerator& aBundleGenerator,
+ ErrorResult& aRv);
+
+ virtual JSObject* WrapObject(JSContext* aCx,
+ JS::Handle<JSObject*> aGivenProto) override;
+
+ /**
+ * DOMLocalization API
+ *
+ * Methods documentation in DOMLocalization.webidl
+ */
+
+ void ConnectRoot(nsINode& aNode, ErrorResult& aRv);
+ void DisconnectRoot(nsINode& aNode, ErrorResult& aRv);
+
+ void PauseObserving(ErrorResult& aRv);
+ void ResumeObserving(ErrorResult& aRv);
+
+ 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);
+
+ already_AddRefed<Promise> TranslateFragment(nsINode& aNode, ErrorResult& aRv);
+
+ already_AddRefed<Promise> TranslateElements(
+ const Sequence<OwningNonNull<Element>>& aElements, ErrorResult& aRv);
+ already_AddRefed<Promise> TranslateElements(
+ const Sequence<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 (auto iter = mRoots.Iter(); !iter.Done(); iter.Next()) {
+ nsINode* subtreeRoot = iter.Get()->GetKey()->SubtreeRoot();
+ if (subtreeRoot == aSubtreeRoot) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ protected:
+ explicit DOMLocalization(nsIGlobalObject* aGlobal, const bool aSync,
+ const BundleGenerator& aBundleGenerator);
+ 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;
+ nsTHashtable<nsRefPtrHashKey<nsINode>> mRoots;
+};
+
+} // namespace dom
+} // namespace mozilla
+
+#endif
diff --git a/dom/l10n/DocumentL10n.cpp b/dom/l10n/DocumentL10n.cpp
new file mode 100644
index 0000000000..2508b06f95
--- /dev/null
+++ b/dom/l10n/DocumentL10n.cpp
@@ -0,0 +1,280 @@
+/* -*- 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 "mozilla/dom/Document.h"
+#include "mozilla/dom/DocumentL10nBinding.h"
+
+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)
+
+/* static */
+RefPtr<DocumentL10n> DocumentL10n::Create(Document* aDocument,
+ const bool aSync) {
+ RefPtr<DocumentL10n> l10n = new DocumentL10n(aDocument, aSync);
+
+ if (!l10n->Init()) {
+ return nullptr;
+ }
+ return l10n.forget();
+}
+
+DocumentL10n::DocumentL10n(Document* aDocument, const bool aSync)
+ : DOMLocalization(aDocument->GetScopeObject(), aSync, {}),
+ mDocument(aDocument),
+ mState(DocumentL10nState::Constructed) {
+ mContentSink = do_QueryInterface(aDocument->GetCurrentContentSink());
+}
+
+bool DocumentL10n::Init() {
+ DOMLocalization::Init();
+ ErrorResult rv;
+ mReady = Promise::Create(mGlobal, rv);
+ if (NS_WARN_IF(rv.Failed())) {
+ return false;
+ }
+ return true;
+}
+
+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) override {
+ mDocumentL10n->InitialTranslationCompleted(true);
+ mPromise->MaybeResolveWithUndefined();
+ }
+
+ void RejectedCallback(JSContext* aCx, JS::Handle<JS::Value> aValue) override {
+ mDocumentL10n->InitialTranslationCompleted(false);
+ mPromise->MaybeRejectWithUndefined();
+ }
+
+ 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() {
+ if (mState >= DocumentL10nState::InitialTranslationTriggered) {
+ return;
+ }
+
+ AutoAllowLegacyScriptExecution exemption;
+
+ nsTArray<RefPtr<Promise>> promises;
+
+ ErrorResult rv;
+ promises.AppendElement(TranslateDocument(rv));
+ if (NS_WARN_IF(rv.Failed())) {
+ InitialTranslationCompleted(false);
+ mReady->MaybeRejectWithUndefined();
+ return;
+ }
+ promises.AppendElement(TranslateRoots(rv));
+ Element* documentElement = mDocument->GetDocumentElement();
+ if (!documentElement) {
+ InitialTranslationCompleted(false);
+ mReady->MaybeRejectWithUndefined();
+ return;
+ }
+
+ DOMLocalization::ConnectRoot(*documentElement, rv);
+ if (NS_WARN_IF(rv.Failed())) {
+ InitialTranslationCompleted(false);
+ mReady->MaybeRejectWithUndefined();
+ return;
+ }
+
+ 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);
+
+ 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);
+ } 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(!promise || aRv.Failed())) {
+ promise->MaybeRejectWithUndefined();
+ return promise.forget();
+ }
+
+ return promise.forget();
+}
+
+void DocumentL10n::InitialTranslationCompleted(bool aL10nCached) {
+ if (mState >= DocumentL10nState::Ready) {
+ return;
+ }
+
+ Element* documentElement = mDocument->GetDocumentElement();
+ if (documentElement) {
+ SetRootInfo(documentElement);
+ }
+
+ mState = DocumentL10nState::Ready;
+
+ mDocument->InitialTranslationCompleted(aL10nCached);
+
+ // In XUL scenario contentSink is nullptr.
+ if (mContentSink) {
+ mContentSink->InitialTranslationCompleted();
+ }
+
+ // From now on, the state of Localization is unconditionally
+ // async.
+ SetIsSync(false);
+}
+
+void DocumentL10n::ConnectRoot(nsINode& aNode, bool aTranslate,
+ ErrorResult& aRv) {
+ if (aTranslate) {
+ if (mState >= DocumentL10nState::InitialTranslationTriggered) {
+ RefPtr<Promise> promise = TranslateFragment(aNode, aRv);
+ }
+ }
+ DOMLocalization::ConnectRoot(aNode, aRv);
+}
+
+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..fc40f590f3
--- /dev/null
+++ b/dom/l10n/DocumentL10n.h
@@ -0,0 +1,87 @@
+/* -*- 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 {
+namespace 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, const bool aSync);
+
+ protected:
+ explicit DocumentL10n(Document* aDocument, const bool aSync);
+ bool Init() override;
+
+ 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() { return mDocument; };
+ void OnCreatePresShell();
+
+ void ConnectRoot(nsINode& aNode, bool aTranslate, ErrorResult& aRv);
+
+ DocumentL10nState GetState() { return mState; };
+
+ bool mBlockingLayout = false;
+};
+
+} // namespace dom
+} // namespace mozilla
+
+#endif // mozilla_dom_l10n_DocumentL10n_h
diff --git a/dom/l10n/L10nMutations.cpp b/dom/l10n/L10nMutations.cpp
new file mode 100644
index 0000000000..ed58722e49
--- /dev/null
+++ b/dom/l10n/L10nMutations.cpp
@@ -0,0 +1,218 @@
+/* -*- 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"
+
+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;
+ }
+
+ nsINode* node = aChild;
+ if (!IsInRoots(node)) {
+ return;
+ }
+
+ ErrorResult rv;
+ Sequence<OwningNonNull<Element>> elements;
+ while (node) {
+ if (node->IsElement()) {
+ DOMLocalization::GetTranslatables(*node, elements, rv);
+ }
+
+ node = node->GetNextSibling();
+ }
+
+ for (auto& elem : elements) {
+ L10nElementChanged(elem);
+ }
+}
+
+void L10nMutations::ContentInserted(nsIContent* aChild) {
+ if (!mObserving) {
+ return;
+ }
+ ErrorResult rv;
+ Sequence<OwningNonNull<Element>> elements;
+
+ if (!aChild->IsElement()) {
+ return;
+ }
+ Element* elem = aChild->AsElement();
+
+ if (!IsInRoots(elem)) {
+ return;
+ }
+ DOMLocalization::GetTranslatables(*aChild, elements, rv);
+
+ for (auto& elem : elements) {
+ L10nElementChanged(elem);
+ }
+}
+
+void L10nMutations::L10nElementChanged(Element* aElement) {
+ if (!mPendingElementsHash.Contains(aElement)) {
+ mPendingElements.AppendElement(aElement);
+ mPendingElementsHash.PutEntry(aElement);
+ }
+
+ if (!mRefreshObserver) {
+ StartRefreshObserver();
+ }
+}
+
+void L10nMutations::PauseObserving() { mObserving = false; }
+
+void L10nMutations::ResumeObserving() { mObserving = true; }
+
+void L10nMutations::WillRefresh(mozilla::TimeStamp aTime) {
+ StopRefreshObserver();
+ FlushPendingTranslations();
+}
+
+void L10nMutations::FlushPendingTranslations() {
+ if (!mDOMLocalization) {
+ return;
+ }
+
+ ErrorResult rv;
+
+ Sequence<OwningNonNull<Element>> elements;
+
+ for (auto& elem : mPendingElements) {
+ if (!elem->HasAttr(kNameSpaceID_None, nsGkAtoms::datal10nid)) {
+ continue;
+ }
+
+ if (!elements.AppendElement(*elem, fallible)) {
+ mozalloc_handle_oom(0);
+ }
+ }
+
+ mPendingElementsHash.Clear();
+ mPendingElements.Clear();
+
+ RefPtr<Promise> promise = mDOMLocalization->TranslateElements(elements, rv);
+}
+
+void L10nMutations::Disconnect() {
+ StopRefreshObserver();
+ mDOMLocalization = nullptr;
+}
+
+void L10nMutations::StartRefreshObserver() {
+ if (!mDOMLocalization || mRefreshObserver) {
+ return;
+ }
+
+ if (!mRefreshDriver) {
+ nsPIDOMWindowInner* innerWindow =
+ mDOMLocalization->GetParentObject()->AsInnerWindow();
+ Document* doc = innerWindow ? innerWindow->GetExtantDoc() : nullptr;
+ if (doc) {
+ nsPresContext* ctx = doc->GetPresContext();
+ if (ctx) {
+ 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");
+ mRefreshObserver = true;
+ } else {
+ NS_WARNING("[l10n][mutations] Failed to start a refresh observer.");
+ }
+}
+
+void L10nMutations::StopRefreshObserver() {
+ if (!mDOMLocalization) {
+ return;
+ }
+
+ if (mRefreshDriver) {
+ mRefreshDriver->RemoveRefreshObserver(this, FlushType::Style);
+ mRefreshObserver = false;
+ }
+}
+
+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..aae56c13ae
--- /dev/null
+++ b/dom/l10n/L10nMutations.h
@@ -0,0 +1,88 @@
+/* -*- 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 "nsRefreshObservers.h"
+#include "nsStubMutationObserver.h"
+#include "nsTHashtable.h"
+#include "mozilla/dom/DOMLocalization.h"
+
+class nsRefreshDriver;
+
+namespace mozilla {
+namespace dom {
+
+/**
+ * L10nMutations manage observing roots for localization
+ * changes and coalescing pending translations into
+ * batches - one per animation frame.
+ */
+class L10nMutations final : public nsStubMutationObserver,
+ 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_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();
+
+ protected:
+ bool mObserving = false;
+ bool mRefreshObserver = false;
+ RefPtr<nsRefreshDriver> mRefreshDriver;
+ DOMLocalization* mDOMLocalization;
+
+ // The hash is used to speed up lookups into mPendingElements.
+ nsTHashtable<nsRefPtrHashKey<Element>> mPendingElementsHash;
+ nsTArray<RefPtr<Element>> mPendingElements;
+
+ virtual void WillRefresh(mozilla::TimeStamp aTime) override;
+
+ void StartRefreshObserver();
+ void StopRefreshObserver();
+ void L10nElementChanged(Element* aElement);
+ void FlushPendingTranslations();
+
+ private:
+ ~L10nMutations();
+ bool IsInRoots(nsINode* aNode);
+};
+
+} // namespace dom
+} // namespace mozilla
+
+#endif // mozilla_dom_l10n_L10nMutations_h__
diff --git a/dom/l10n/L10nOverlays.cpp b/dom/l10n/L10nOverlays.cpp
new file mode 100644
index 0000000000..89fc8fa1d6
--- /dev/null
+++ b/dom/l10n/L10nOverlays.cpp
@@ -0,0 +1,537 @@
+/* -*- 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"
+
+using namespace mozilla::dom;
+using namespace mozilla;
+
+bool L10nOverlays::IsAttrNameLocalizable(
+ const nsAtom* nameAtom, Element* aElement,
+ nsTArray<nsString>* aExplicitlyAllowed) {
+ nsAutoString name;
+ nameAtom->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 (nameAtom == nsGkAtoms::title || nameAtom == nsGkAtoms::aria_label ||
+ nameAtom == nsGkAtoms::aria_valuetext) {
+ return true;
+ }
+
+ // Is it allowed on this element?
+ if (elemName == nsGkAtoms::a) {
+ return nameAtom == nsGkAtoms::download;
+ }
+ if (elemName == nsGkAtoms::area) {
+ return nameAtom == nsGkAtoms::download || nameAtom == nsGkAtoms::alt;
+ }
+ if (elemName == nsGkAtoms::input) {
+ // Special case for value on HTML inputs with type button, reset, submit
+ if (nameAtom == nsGkAtoms::value) {
+ HTMLInputElement* input = HTMLInputElement::FromNode(aElement);
+ if (input) {
+ uint32_t type = input->ControlType();
+ if (type == NS_FORM_INPUT_SUBMIT || type == NS_FORM_INPUT_BUTTON ||
+ type == NS_FORM_INPUT_RESET) {
+ return true;
+ }
+ }
+ }
+ return nameAtom == nsGkAtoms::alt || nameAtom == nsGkAtoms::placeholder;
+ }
+ if (elemName == nsGkAtoms::menuitem) {
+ return nameAtom == nsGkAtoms::label;
+ }
+ if (elemName == nsGkAtoms::menu) {
+ return nameAtom == nsGkAtoms::label;
+ }
+ if (elemName == nsGkAtoms::optgroup) {
+ return nameAtom == nsGkAtoms::label;
+ }
+ if (elemName == nsGkAtoms::option) {
+ return nameAtom == nsGkAtoms::label;
+ }
+ if (elemName == nsGkAtoms::track) {
+ return nameAtom == nsGkAtoms::label;
+ }
+ if (elemName == nsGkAtoms::img) {
+ return nameAtom == nsGkAtoms::alt;
+ }
+ if (elemName == nsGkAtoms::textarea) {
+ return nameAtom == nsGkAtoms::placeholder;
+ }
+ if (elemName == nsGkAtoms::th) {
+ return nameAtom == nsGkAtoms::abbr;
+ }
+
+ } else if (nameSpace == kNameSpaceID_XUL) {
+ // Is it a globally safe attribute?
+ if (nameAtom == nsGkAtoms::accesskey || nameAtom == nsGkAtoms::aria_label ||
+ nameAtom == nsGkAtoms::aria_valuetext || nameAtom == nsGkAtoms::label ||
+ nameAtom == nsGkAtoms::title || nameAtom == nsGkAtoms::tooltiptext) {
+ return true;
+ }
+
+ // Is it allowed on this element?
+ if (elemName == nsGkAtoms::description) {
+ return nameAtom == nsGkAtoms::value;
+ }
+ if (elemName == nsGkAtoms::key) {
+ return nameAtom == nsGkAtoms::key || nameAtom == nsGkAtoms::keycode;
+ }
+ if (elemName == nsGkAtoms::label) {
+ return nameAtom == 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;
+ 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()))) {
+ nsAutoString name;
+ attrName->LocalName()->ToString(name);
+ aToElement->RemoveAttribute(name, 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());
+ nsContentUtils::ParseFragmentHTML(
+ NS_ConvertUTF8toUTF16(aTranslation.mValue), fragment,
+ nsGkAtoms::_template, kNameSpaceID_XHTML, false, true);
+ 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..0450fb130d
--- /dev/null
+++ b/dom/l10n/L10nOverlays.h
@@ -0,0 +1,126 @@
+/* -*- 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 {
+namespace 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:
+ /**
+ * 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* nameAtom, Element* aElement,
+ nsTArray<nsString>* aExplicitlyAllowed);
+
+ /**
+ * 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 dom
+} // namespace mozilla
+
+#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/.eslintrc.js b/dom/l10n/tests/mochitest/.eslintrc.js
new file mode 100644
index 0000000000..1779fd7f1c
--- /dev/null
+++ b/dom/l10n/tests/mochitest/.eslintrc.js
@@ -0,0 +1,5 @@
+"use strict";
+
+module.exports = {
+ extends: ["plugin:mozilla/browser-test"],
+};
diff --git a/dom/l10n/tests/mochitest/browser.ini b/dom/l10n/tests/mochitest/browser.ini
new file mode 100644
index 0000000000..e1ef9ad541
--- /dev/null
+++ b/dom/l10n/tests/mochitest/browser.ini
@@ -0,0 +1,4 @@
+[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
diff --git a/dom/l10n/tests/mochitest/chrome.ini b/dom/l10n/tests/mochitest/chrome.ini
new file mode 100644
index 0000000000..d9e984ffcc
--- /dev/null
+++ b/dom/l10n/tests/mochitest/chrome.ini
@@ -0,0 +1,44 @@
+[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]
+
+[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]
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..00433c5314
--- /dev/null
+++ b/dom/l10n/tests/mochitest/document_l10n/non-system-principal/browser_resource_uri.js
@@ -0,0 +1,84 @@
+const { L10nRegistry, FileSource } = ChromeUtils.import(
+ "resource://gre/modules/L10nRegistry.jsm"
+);
+
+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;
+let mockSource = new FileSource("test", locales, `${uri}localization/`);
+L10nRegistry.registerSources([mockSource]);
+
+registerCleanupFunction(() => {
+ protocol.setSubstitution("l10n-test", null);
+ L10nRegistry.removeSources(["test"]);
+ SpecialPowers.pushPrefEnv({
+ set: [["dom.ipc.processPrelaunch.enabled", true]],
+ });
+});
+
+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]],
+ });
+ await BrowserTestUtils.withNewTab(
+ "resource://l10n-test/test.html",
+ async browser => {
+ await SpecialPowers.spawn(browser, [], async function() {
+ let document = content.document;
+ let window = document.defaultView;
+
+ let { customMsg, l10nArgs } = await document.testsReadyPromise;
+
+ 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");
+ is(l10nArgs.id, "subtitle");
+ is(l10nArgs.args.name, "Firefox");
+
+ // Test for manual value formatting
+ 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..3b6784ed6b
--- /dev/null
+++ b/dom/l10n/tests/mochitest/document_l10n/non-system-principal/test.html
@@ -0,0 +1,36 @@
+<!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");
+ const l10nArgs = document.l10n.getAttributes(label);
+ resolve({customMsg, l10nArgs});
+ }, {once: true});
+ });
+ </script>
+</head>
+<body>
+ <h1 id="main-desc" data-l10n-id="page-title"></h1>
+
+ <p id="label1"></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..fee14d6206
--- /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 > 0) {
+ 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..9f2c916f47
--- /dev/null
+++ b/dom/l10n/tests/mochitest/document_l10n/test_docl10n.html
@@ -0,0 +1,57 @@
+<!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 type="application/javascript">
+ "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;
+
+ // Test for initial localization applied.
+ let desc = document.getElementById("main-desc");
+ is(desc.textContent.length > 0, true);
+
+ // Test for manual value formatting.
+ let msg = await document.l10n.formatValue("id-heading");
+ is(msg.length > 0, true);
+
+ // Test for mutations applied.
+ let verifyL10n = () => {
+ if (label.textContent.length > 0) {
+ 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");
+ })();
+ </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..3b20c0620e
--- /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 > 0, true);
+
+ // Test for manual value formatting
+ let msg = await document.l10n.formatValue("id-heading");
+ is(msg.length > 0, true);
+
+ // Test for mutations applied.
+ let verifyL10n = () => {
+ if (label.textContent.length > 0) {
+ 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..6be2a6f45e
--- /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 > 0, 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..933305138b
--- /dev/null
+++ b/dom/l10n/tests/mochitest/document_l10n/test_docl10n_ready_rejected.html
@@ -0,0 +1,27 @@
+<!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() {
+ document.l10n.ready.then(() => {
+ is(1, 2, "the ready should not resolve");
+ SimpleTest.finish();
+ }, (err) => {
+ is(1, 1, "the ready should reject");
+ 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..19e4153e00
--- /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 > 0, 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 > 0, 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..f931ed3351
--- /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 > 0, true);
+
+ // Test for manual value formatting.
+ let msg = await document.l10n.formatValue("id-heading");
+ is(msg.length > 0, true);
+
+ // Test for mutations applied.
+ let verifyL10n = () => {
+ if (label.textContent.length > 0) {
+ 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_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..19ff6aee2f
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_attr_sanitized.html
@@ -0,0 +1,47 @@
+<!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";
+ async function* generateBundles(resourceIds) {
+ const bundle = new FluentBundle("en-US");
+ bundle.addResource(new FluentResource(`
+key1 = Value for Key 1
+
+key2 = Value for <a>Key 2<a/>.
+ `));
+ yield bundle;
+ }
+
+ SimpleTest.waitForExplicitFinish();
+ addLoadEvent(async () => {
+ const domLoc = new DOMLocalization(
+ [],
+ false,
+ { generateBundles },
+ );
+
+ 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..ae6c6a8e01
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_connectRoot.html
@@ -0,0 +1,43 @@
+<!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";
+ async function* generateBundles(resourceIds) {
+ const bundle = new FluentBundle("en-US");
+ bundle.addResource(new FluentResource(`
+key1 = Value for Key 1
+ `));
+ yield bundle;
+ }
+
+ window.onload = async function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const p1 = document.getElementById("p1");
+
+ const domLoc = new DOMLocalization(
+ [],
+ false,
+ { generateBundles },
+ );
+
+ await domLoc.translateRoots();
+ is(p1.textContent.length == 0, true);
+ const body = document.body;
+ domLoc.connectRoot(body);
+ await domLoc.translateRoots();
+ is(p1.textContent.length > 0, 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..49ead91d4b
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_connectRoot_webcomponent.html
@@ -0,0 +1,70 @@
+<!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 > 0) {
+ 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/intl/l10n/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";
+ async function* generateBundles(resourceIds) {
+ const bundle = new FluentBundle("en-US");
+ bundle.addResource(new FluentResource(`
+key1 = Value for Key 1
+ `));
+ yield bundle;
+ }
+
+ document.domLoc = new DOMLocalization(
+ [],
+ false,
+ { generateBundles }
+ );
+ </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..58279c4dec
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_disconnectRoot.html
@@ -0,0 +1,58 @@
+<!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";
+ async function* generateBundles(resourceIds) {
+ const bundle = new FluentBundle("en-US");
+ bundle.addResource(new FluentResource(`
+key1 = Value for Key 1
+key2 = Value for Key 2
+ `));
+ yield bundle;
+ }
+
+ window.onload = async function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const p1 = document.getElementById("p1");
+
+ const domLoc = new DOMLocalization(
+ [],
+ false,
+ { generateBundles },
+ );
+
+ await domLoc.translateRoots();
+ is(p1.textContent.length == 0, 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..3cf167cce5
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_domloc.xhtml
@@ -0,0 +1,66 @@
+<?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[
+ async function* generateBundles(resourceIds) {
+ const bundle = new FluentBundle("en-US");
+ bundle.addResource(new FluentResource(`
+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.
+`));
+ yield bundle;
+ }
+
+ SimpleTest.waitForExplicitFinish();
+
+
+ const domLoc = new DOMLocalization(
+ [],
+ false,
+ { generateBundles },
+ );
+
+ async function foo() {
+ domLoc.addResourceIds(["dummy.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..c0fdf96026
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_getAttributes.html
@@ -0,0 +1,52 @@
+<!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";
+
+ async function* generateBundles(resourceIds) {}
+
+ window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const domLoc = new DOMLocalization(
+ [],
+ false,
+ { generateBundles },
+ );
+
+ 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..e29e5f347a
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_l10n_mutations.html
@@ -0,0 +1,53 @@
+<!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";
+ async function* generateBundles(resourceIds) {
+ const bundle = new FluentBundle("en-US");
+ bundle.addResource(new FluentResource("title = Hello World"));
+ bundle.addResource(new FluentResource("title2 = Hello Another World"));
+ yield bundle;
+ }
+
+ window.onload = async function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const domLoc = new DOMLocalization(
+ [],
+ false,
+ { generateBundles },
+ );
+
+ const h1 = document.querySelectorAll("h1")[0];
+
+ domLoc.addResourceIds(["dummy.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..8127e1ba2e
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_overlay.html
@@ -0,0 +1,56 @@
+<!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";
+ async function* generateBundles(resourceIds) {
+ const bundle = new FluentBundle("en-US");
+ bundle.addResource(new FluentResource("title = <strong>Hello</strong> World"));
+ bundle.addResource(new FluentResource(`title2 = This is <a data-l10n-name="link">a link</a>!`));
+ yield bundle;
+ }
+
+ window.onload = async function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const domLoc = new DOMLocalization(
+ [],
+ false,
+ { generateBundles },
+ );
+
+ 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..2200fe7ea6
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_overlay_missing_all.html
@@ -0,0 +1,40 @@
+<!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";
+ async function* generateBundles(resourceIds) {
+ const bundle = new FluentBundle("en-US");
+ // No translations!
+ yield bundle;
+ }
+
+ SimpleTest.waitForExplicitFinish();
+ addLoadEvent(async () => {
+ const domLoc = new DOMLocalization(
+ [],
+ false,
+ { generateBundles },
+ );
+
+ 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..20d58ba630
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_overlay_missing_children.html
@@ -0,0 +1,49 @@
+<!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";
+ async function* generateBundles(resourceIds) {
+ const bundle = new FluentBundle("en-US");
+ bundle.addResource(new FluentResource(`title = Visit <a data-l10n-name="mozilla-link">Mozilla</a> or <a data-l10n-name="firefox-link">Firefox</a> website!`));
+ yield bundle;
+ }
+
+ window.onload = async function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const domLoc = new DOMLocalization(
+ [],
+ false,
+ { generateBundles },
+ );
+
+ 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..2d202b2e7f
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_overlay_repeated.html
@@ -0,0 +1,46 @@
+<!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";
+ async function* generateBundles(resourceIds) {
+ const bundle = new FluentBundle("en-US");
+ bundle.addResource(new FluentResource(`title = Visit <a data-l10n-name="mozilla-link">Mozilla</a> or <a data-l10n-name="firefox-link">Firefox</a> website!`));
+ yield bundle;
+ }
+
+ window.onload = async function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const domLoc = new DOMLocalization(
+ [],
+ false,
+ { generateBundles },
+ );
+
+ 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..74472e88b0
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_overlay_sanitized.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";
+ async function* generateBundles(resourceIds) {
+ const bundle = new FluentBundle("en-US");
+ bundle.addResource(new FluentResource(`
+key1 =
+ .href = https://www.hacked.com
+
+key2 =
+ .href = https://pl.wikipedia.org
+`));
+ yield bundle;
+ }
+
+ async function test() {
+ const domLoc = new DOMLocalization(
+ [],
+ false,
+ { generateBundles },
+ );
+
+ 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..5ac848c033
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_repeated_l10nid.html
@@ -0,0 +1,58 @@
+<!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";
+ async function* generateBundles(resourceIds) {
+ const bundle = new FluentBundle("en-US");
+ bundle.addResource(new FluentResource(`
+key1 = Translation For Key 1
+
+key2 = Visit <a data-l10n-name="link">this link<a/>.
+ `));
+ yield bundle;
+ }
+
+ SimpleTest.waitForExplicitFinish();
+ addLoadEvent(async () => {
+ const domLoc = new DOMLocalization(
+ [],
+ false,
+ { generateBundles },
+ );
+
+ 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..1c4d93c2e7
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_setAttributes.html
@@ -0,0 +1,38 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test DOMLocalization.prototype.setAttributes</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";
+
+ async function* generateBundles(resourceIds) {}
+
+ window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const domLoc = new DOMLocalization(
+ [],
+ false,
+ { generateBundles },
+ );
+
+ const p1 = document.querySelectorAll("p")[0];
+
+ domLoc.setAttributes(p1, "title");
+ is(p1.getAttribute("data-l10n-id"), "title");
+
+ domLoc.setAttributes(p1, "title2", {userName: "John"});
+ is(p1.getAttribute("data-l10n-id"), "title2");
+ is(p1.getAttribute("data-l10n-args"), JSON.stringify({userName: "John"}));
+
+ 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..20bc015a66
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_translateElements.html
@@ -0,0 +1,42 @@
+<!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";
+ async function* generateBundles(resourceIds) {
+ const bundle = new FluentBundle("en-US");
+ bundle.addResource(new FluentResource("title = Hello World"));
+ bundle.addResource(new FluentResource("link =\n .title = Click me"));
+ yield bundle;
+ }
+
+ window.onload = async function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const domLoc = new DOMLocalization(
+ [],
+ false,
+ { generateBundles },
+ );
+
+ 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..8f592bbfb7
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_translateFragment.html
@@ -0,0 +1,44 @@
+<!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";
+ async function* generateBundles(resourceIds) {
+ const bundle = new FluentBundle("en-US");
+ bundle.addResource(new FluentResource("title = Hello World"));
+ bundle.addResource(new FluentResource("subtitle = Welcome to FluentBundle"));
+ yield bundle;
+ }
+
+ window.onload = async function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const domLoc = new DOMLocalization(
+ [],
+ false,
+ { generateBundles },
+ );
+
+ 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..1daba8c5ae
--- /dev/null
+++ b/dom/l10n/tests/mochitest/dom_localization/test_translateRoots.html
@@ -0,0 +1,52 @@
+<!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";
+ async function* generateBundles(resourceIds) {
+ const bundle = new FluentBundle("en-US");
+ bundle.addResource(new FluentResource("title = Hello World"));
+ bundle.addResource(new FluentResource("title2 = Hello Another World"));
+ yield bundle;
+ }
+
+ window.onload = async function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const domLoc = new DOMLocalization(
+ [],
+ false,
+ { generateBundles },
+ );
+
+ 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(["dummy.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..2b2cf6d881
--- /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 > 0) {
+ 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..80255cf649
--- /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 > 0) {
+ 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..b87479868e
--- /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 > 0 && elem2.textContent.length > 0) {
+ 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..9d9f0cfb2a
--- /dev/null
+++ b/dom/l10n/tests/mochitest/l10n_mutations/test_disconnectedRoot_webcomponent.html
@@ -0,0 +1,146 @@
+<!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";
+ async function* generateBundles(resourceIds) {
+ const bundle = new FluentBundle("en-US");
+ bundle.addResource(new FluentResource(`
+key1 = Key 1
+key2 = Key 2
+key3 = Key 3
+key4 = Key 4
+ `));
+ yield bundle;
+ }
+
+ document.domLoc = new DOMLocalization(
+ [],
+ false,
+ { generateBundles }
+ );
+ 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..1ba6e897f3
--- /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 > 0 &&
+ elem2.textContent.length == 0 &&
+ elem3.textContent.length > 0) {
+ 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_set_attributes.html b/dom/l10n/tests/mochitest/l10n_mutations/test_set_attributes.html
new file mode 100644
index 0000000000..587c62d7b6
--- /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 > 0 && 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..3d8bc60c7b
--- /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 > 0) {
+ 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..a6567eda4e
--- /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 > 0, 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.jsm, 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..af2a108e46
--- /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 > 0, 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.jsm, 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]