/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ /* vim: set ts=8 sts=2 et sw=2 tw=80: */ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ #include "js/ForOfIterator.h" // JS::ForOfIterator #include "js/JSON.h" // JS_ParseJSON #include "nsContentUtils.h" #include "nsIScriptError.h" #include "DOMLocalization.h" #include "mozilla/intl/L10nRegistry.h" #include "mozilla/intl/LocaleService.h" #include "mozilla/dom/AutoEntryScript.h" #include "mozilla/dom/Element.h" #include "mozilla/dom/L10nOverlays.h" using namespace mozilla; using namespace mozilla::dom; using namespace mozilla::intl; NS_IMPL_CYCLE_COLLECTION_CLASS(DOMLocalization) NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN_INHERITED(DOMLocalization, Localization) tmp->DisconnectMutations(); NS_IMPL_CYCLE_COLLECTION_UNLINK(mMutations) NS_IMPL_CYCLE_COLLECTION_UNLINK(mRoots) NS_IMPL_CYCLE_COLLECTION_UNLINK_PRESERVED_WRAPPER NS_IMPL_CYCLE_COLLECTION_UNLINK_END NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN_INHERITED(DOMLocalization, Localization) NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mMutations) NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mRoots) NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END NS_IMPL_ADDREF_INHERITED(DOMLocalization, Localization) NS_IMPL_RELEASE_INHERITED(DOMLocalization, Localization) NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(DOMLocalization) NS_INTERFACE_MAP_END_INHERITING(Localization) DOMLocalization::DOMLocalization(nsIGlobalObject* aGlobal, bool aSync) : Localization(aGlobal, aSync) { mMutations = new L10nMutations(this); } DOMLocalization::DOMLocalization(nsIGlobalObject* aGlobal, bool aIsSync, const ffi::LocalizationRc* aRaw) : Localization(aGlobal, aIsSync, aRaw) { mMutations = new L10nMutations(this); } already_AddRefed DOMLocalization::Constructor( const GlobalObject& aGlobal, const Sequence& aResourceIds, bool aIsSync, const Optional>& aRegistry, const Optional>& aLocales, ErrorResult& aRv) { auto ffiResourceIds{L10nRegistry::ResourceIdsToFFI(aResourceIds)}; Maybe> locales; if (aLocales.WasPassed()) { locales.emplace(); locales->SetCapacity(aLocales.Value().Length()); for (const auto& locale : aLocales.Value()) { locales->AppendElement(locale); } } RefPtr raw; bool result; if (aRegistry.WasPassed()) { result = ffi::localization_new_with_locales( &ffiResourceIds, aIsSync, aRegistry.Value().Raw(), locales.ptrOr(nullptr), getter_AddRefs(raw)); } else { result = ffi::localization_new_with_locales(&ffiResourceIds, aIsSync, nullptr, locales.ptrOr(nullptr), getter_AddRefs(raw)); } if (result) { nsCOMPtr global = do_QueryInterface(aGlobal.GetAsSupports()); return do_AddRef(new DOMLocalization(global, aIsSync, raw)); } aRv.ThrowInvalidStateError( "Failed to create the Localization. Check the locales arguments."); return nullptr; } JSObject* DOMLocalization::WrapObject(JSContext* aCx, JS::Handle aGivenProto) { return DOMLocalization_Binding::Wrap(aCx, this, aGivenProto); } void DOMLocalization::Destroy() { DisconnectMutations(); } DOMLocalization::~DOMLocalization() { Destroy(); } bool DOMLocalization::HasPendingMutations() const { return mMutations && mMutations->HasPendingMutations(); } /** * DOMLocalization API */ void DOMLocalization::ConnectRoot(nsINode& aNode) { nsCOMPtr global = aNode.GetOwnerGlobal(); if (!global) { return; } MOZ_ASSERT(global == mGlobal, "Cannot add a root that overlaps with existing root."); #ifdef DEBUG for (nsINode* root : mRoots) { MOZ_ASSERT( root != &aNode && !root->Contains(&aNode) && !aNode.Contains(root), "Cannot add a root that overlaps with existing root."); } #endif mRoots.Insert(&aNode); aNode.AddMutationObserverUnlessExists(mMutations); } void DOMLocalization::DisconnectRoot(nsINode& aNode) { if (mRoots.Contains(&aNode)) { aNode.RemoveMutationObserver(mMutations); mRoots.Remove(&aNode); } } void DOMLocalization::PauseObserving() { mMutations->PauseObserving(); } void DOMLocalization::ResumeObserving() { mMutations->ResumeObserving(); } void DOMLocalization::SetAttributes( JSContext* aCx, Element& aElement, const nsAString& aId, const Optional>& aArgs, ErrorResult& aRv) { if (aArgs.WasPassed() && aArgs.Value()) { nsAutoString data; JS::Rooted val(aCx, JS::ObjectValue(*aArgs.Value())); if (!nsContentUtils::StringifyJSON(aCx, val, data, UndefinedIsNullStringLiteral)) { aRv.NoteJSContextException(aCx); return; } if (!aElement.AttrValueIs(kNameSpaceID_None, nsGkAtoms::datal10nargs, data, eCaseMatters)) { aElement.SetAttr(kNameSpaceID_None, nsGkAtoms::datal10nargs, data, true); } } else { aElement.UnsetAttr(kNameSpaceID_None, nsGkAtoms::datal10nargs, true); } if (!aElement.AttrValueIs(kNameSpaceID_None, nsGkAtoms::datal10nid, aId, eCaseMatters)) { aElement.SetAttr(kNameSpaceID_None, nsGkAtoms::datal10nid, aId, true); } } void DOMLocalization::GetAttributes(Element& aElement, L10nIdArgs& aResult, ErrorResult& aRv) { nsAutoString l10nId; nsAutoString l10nArgs; if (aElement.GetAttr(kNameSpaceID_None, nsGkAtoms::datal10nid, l10nId)) { CopyUTF16toUTF8(l10nId, aResult.mId); } if (aElement.GetAttr(kNameSpaceID_None, nsGkAtoms::datal10nargs, l10nArgs)) { ConvertStringToL10nArgs(l10nArgs, aResult.mArgs.SetValue(), aRv); } } already_AddRefed DOMLocalization::TranslateFragment(nsINode& aNode, ErrorResult& aRv) { Sequence> elements; GetTranslatables(aNode, elements, aRv); if (NS_WARN_IF(aRv.Failed())) { return nullptr; } return TranslateElements(elements, aRv); } /** * A Promise Handler used to apply the result of * a call to Localization::FormatMessages onto the list * of translatable elements. */ class ElementTranslationHandler : public PromiseNativeHandler { public: explicit ElementTranslationHandler(DOMLocalization* aDOMLocalization, nsXULPrototypeDocument* aProto) : mDOMLocalization(aDOMLocalization), mProto(aProto){}; NS_DECL_CYCLE_COLLECTING_ISUPPORTS NS_DECL_CYCLE_COLLECTION_CLASS(ElementTranslationHandler) nsTArray>& Elements() { return mElements; } void SetReturnValuePromise(Promise* aReturnValuePromise) { mReturnValuePromise = aReturnValuePromise; } virtual void ResolvedCallback(JSContext* aCx, JS::Handle aValue, ErrorResult& aRv) override { ErrorResult rv; nsTArray> 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 temp(aCx); while (true) { bool done; if (!iter.next(&temp, &done)) { mReturnValuePromise->MaybeRejectWithUndefined(); return; } if (done) { break; } Nullable* 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 aValue, ErrorResult& aRv) override { mReturnValuePromise->MaybeRejectWithClone(aCx, aValue); } private: ~ElementTranslationHandler() = default; nsTArray> mElements; RefPtr mDOMLocalization; RefPtr mReturnValuePromise; RefPtr 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 DOMLocalization::TranslateElements( const nsTArray>& aElements, ErrorResult& aRv) { return TranslateElements(aElements, nullptr, aRv); } already_AddRefed DOMLocalization::TranslateElements( const nsTArray>& aElements, nsXULPrototypeDocument* aProto, ErrorResult& aRv) { Sequence l10nKeys; RefPtr nativeHandler = new ElementTranslationHandler(this, aProto); nsTArray>& domElements = nativeHandler->Elements(); domElements.SetCapacity(aElements.Length()); if (!mGlobal) { aRv.Throw(NS_ERROR_UNEXPECTED); return nullptr; } for (auto& domElement : aElements) { if (!domElement->HasAttr(nsGkAtoms::datal10nid)) { continue; } OwningUTF8StringOrL10nIdArgs* key = l10nKeys.AppendElement(fallible); if (!key) { aRv.Throw(NS_ERROR_OUT_OF_MEMORY); return nullptr; } GetAttributes(*domElement, key->SetAsL10nIdArgs(), aRv); if (NS_WARN_IF(aRv.Failed())) { return nullptr; } if (!domElements.AppendElement(domElement, fallible)) { // This can't really happen, we SetCapacity'd above... aRv.Throw(NS_ERROR_OUT_OF_MEMORY); return nullptr; } } RefPtr promise = Promise::Create(mGlobal, aRv); if (NS_WARN_IF(aRv.Failed())) { return nullptr; } if (IsSync()) { nsTArray> l10nMessages; FormatMessagesSync(l10nKeys, l10nMessages, aRv); if (NS_WARN_IF(aRv.Failed())) { promise->MaybeRejectWithUndefined(); return promise.forget(); } bool allTranslated = ApplyTranslations(domElements, l10nMessages, aProto, aRv); if (NS_WARN_IF(aRv.Failed()) || !allTranslated) { promise->MaybeRejectWithUndefined(); return promise.forget(); } promise->MaybeResolveWithUndefined(); return promise.forget(); } RefPtr callbackResult = FormatMessages(l10nKeys, aRv); if (NS_WARN_IF(aRv.Failed())) { return nullptr; } nativeHandler->SetReturnValuePromise(promise); callbackResult->AppendNativeHandler(nativeHandler); return MaybeWrapPromise(promise); } /** * Promise handler used to set localization data on * roots of elements that got successfully translated. */ class L10nRootTranslationHandler final : public PromiseNativeHandler { public: NS_DECL_CYCLE_COLLECTING_ISUPPORTS NS_DECL_CYCLE_COLLECTION_CLASS(L10nRootTranslationHandler) explicit L10nRootTranslationHandler(Element* aRoot) : mRoot(aRoot) {} void ResolvedCallback(JSContext* aCx, JS::Handle aValue, ErrorResult& aRv) override { DOMLocalization::SetRootInfo(mRoot); } void RejectedCallback(JSContext* aCx, JS::Handle aValue, ErrorResult& aRv) override {} private: ~L10nRootTranslationHandler() = default; RefPtr 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 DOMLocalization::TranslateRoots(ErrorResult& aRv) { nsTArray> promises; for (nsINode* root : mRoots) { RefPtr promise = TranslateFragment(*root, aRv); if (MOZ_UNLIKELY(aRv.Failed())) { return nullptr; } // If the root is an element, we'll add a native handler // to set root info (language, direction etc.) on it // once the localization finishes. if (root->IsElement()) { RefPtr 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>& 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>& aElements, nsTArray>& aTranslations, nsXULPrototypeDocument* aProto, ErrorResult& aRv) { if (aElements.Length() != aTranslations.Length()) { aRv.Throw(NS_ERROR_FAILURE); return false; } PauseObserving(); bool hasMissingTranslation = false; nsTArray errors; for (size_t i = 0; i < aTranslations.Length(); ++i) { nsCOMPtr elem = aElements[i]; if (aTranslations[i].IsNull()) { hasMissingTranslation = true; continue; } // If we have a proto, we expect all elements are connected up. // If they're not, they may have been removed by earlier translations. // We will have added an error in L10nOverlays in this case. // This is an error in fluent use, but shouldn't be crashing. There's // also no point translating the element - skip it: if (aProto && !elem->IsInComposedDoc()) { continue; } // It is possible that someone removed the `data-l10n-id` from the element // before the async translation completed. In that case, skip applying // the translation. if (!elem->HasAttr(kNameSpaceID_None, nsGkAtoms::datal10nid)) { continue; } L10nOverlays::TranslateElement(*elem, aTranslations[i].Value(), errors, aRv); if (NS_WARN_IF(aRv.Failed())) { hasMissingTranslation = true; continue; } if (aProto) { // We only need to rebuild deep if the translation has a value. // Otherwise we'll only rebuild the attributes. aProto->RebuildL10nPrototype(elem, !aTranslations[i].Value().mValue.IsVoid()); } } ReportL10nOverlaysErrors(errors); ResumeObserving(); return !hasMissingTranslation; } /* Protected */ void DOMLocalization::OnChange() { Localization::OnChange(); RefPtr promise = TranslateRoots(IgnoreErrors()); } void DOMLocalization::DisconnectMutations() { if (mMutations) { mMutations->Disconnect(); DisconnectRoots(); } } void DOMLocalization::DisconnectRoots() { for (nsINode* node : mRoots) { node->RemoveMutationObserver(mMutations); } mRoots.Clear(); } void DOMLocalization::ReportL10nOverlaysErrors( nsTArray& aErrors) { nsAutoString msg; for (auto& error : aErrors) { if (error.mCode.WasPassed()) { msg = u"[fluent-dom] "_ns; switch (error.mCode.Value()) { case L10nOverlays_Binding::ERROR_FORBIDDEN_TYPE: msg += u"An element of forbidden type \""_ns + error.mTranslatedElementName.Value() + nsLiteralString( u"\" was found in the translation. Only safe text-level " "elements and elements with data-l10n-name are allowed."); break; case L10nOverlays_Binding::ERROR_NAMED_ELEMENT_MISSING: msg += u"An element named \""_ns + error.mL10nName.Value() + u"\" wasn't found in the source."_ns; break; case L10nOverlays_Binding::ERROR_NAMED_ELEMENT_TYPE_MISMATCH: msg += u"An element named \""_ns + error.mL10nName.Value() + nsLiteralString( u"\" was found in the translation but its type ") + error.mTranslatedElementName.Value() + nsLiteralString( u" didn't match the element found in the source ") + error.mSourceElementName.Value() + u"."_ns; break; case L10nOverlays_Binding::ERROR_TRANSLATED_ELEMENT_DISCONNECTED: msg += u"The element using message \""_ns + error.mL10nName.Value() + nsLiteralString( u"\" was removed from the DOM when translating its \"") + error.mTranslatedElementName.Value() + u"\" parent."_ns; break; case L10nOverlays_Binding::ERROR_TRANSLATED_ELEMENT_DISALLOWED_DOM: msg += nsLiteralString( u"While translating an element with fluent ID \"") + error.mL10nName.Value() + u"\" a child element of type \""_ns + error.mTranslatedElementName.Value() + nsLiteralString( u"\" was removed. Either the fluent message " "does not contain markup, or it does not contain markup " "of this type."); break; case L10nOverlays_Binding::ERROR_UNKNOWN: default: msg += nsLiteralString( u"Unknown error happened while translating an element."); break; } nsPIDOMWindowInner* innerWindow = GetParentObject()->AsInnerWindow(); Document* doc = innerWindow ? innerWindow->GetExtantDoc() : nullptr; if (doc) { nsContentUtils::ReportToConsoleNonLocalized( msg, nsIScriptError::warningFlag, "DOM"_ns, doc); } else { NS_WARNING("Failed to report l10n DOM Overlay errors to console."); } printf_stderr("%s\n", NS_ConvertUTF16toUTF8(msg).get()); } } } void DOMLocalization::ConvertStringToL10nArgs(const nsString& aInput, intl::L10nArgs& aRetVal, ErrorResult& aRv) { if (aInput.IsEmpty()) { // There are no properties. return; } // This method uses a temporary dictionary to automate // converting a JSON string into an IDL Record via a dictionary. // // Once we get Record::Init(const nsAString& aJSON), we'll switch to // that. L10nArgsHelperDict helperDict; if (!helperDict.Init(u"{\"args\": "_ns + aInput + u"}"_ns)) { nsTArray errors{ "[dom/l10n] Failed to parse l10n-args JSON: "_ns + NS_ConvertUTF16toUTF8(aInput), }; MaybeReportErrorsToGecko(errors, aRv, GetParentObject()); return; } for (auto& entry : helperDict.mArgs.Entries()) { L10nArgs::EntryType* newEntry = aRetVal.Entries().AppendElement(fallible); if (!newEntry) { aRv.Throw(NS_ERROR_OUT_OF_MEMORY); return; } newEntry->mKey = entry.mKey; newEntry->mValue = entry.mValue; } }