diff --git a/extensions/amp-story/0.1/_locales/default.js b/extensions/amp-story/0.1/_locales/default.js new file mode 100644 index 000000000000..34a22660a757 --- /dev/null +++ b/extensions/amp-story/0.1/_locales/default.js @@ -0,0 +1,49 @@ +/** + * Copyright 2018 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import {LocalizedStringBundleDef, LocalizedStringId} from '../localization'; + + +/** + * Localized string bundle used for English strings. + * @const {!LocalizedStringBundleDef} + */ +export default { + [LocalizedStringId.AMP_STORY_EXPERIMENT_ENABLE_BUTTON_LABEL]: { + string: 'Enable', + }, + [LocalizedStringId.AMP_STORY_EXPERIMENT_ENABLED_TEXT]: { + string: 'Experiment enabled. Please reload.', + }, + [LocalizedStringId.AMP_STORY_SHARING_CLIPBOARD_FAILURE_TEXT]: { + string: ':(', + }, + [LocalizedStringId.AMP_STORY_SYSTEM_LAYER_SHARE_WIDGET_LABEL]: { + string: 'Share', + }, + [LocalizedStringId.AMP_STORY_WARNING_DESKTOP_SIZE_TEXT]: { + string: 'Expand your window to view this experience', + }, + [LocalizedStringId.AMP_STORY_WARNING_EXPERIMENT_DISABLED_TEXT]: { + string: 'You must enable the amp-story experiment to view this content.', + }, + [LocalizedStringId.AMP_STORY_WARNING_LANDSCAPE_ORIENTATION_TEXT]: { + string: 'The page is best viewed in portrait mode', + }, + [LocalizedStringId.AMP_STORY_WARNING_UNSUPPORTED_BROWSER_TEXT]: { + string: 'We\'re sorry, it looks like your browser doesn\'t support ' + + 'this experience', + }, +}; diff --git a/extensions/amp-story/0.1/_locales/en.js b/extensions/amp-story/0.1/_locales/en.js new file mode 100644 index 000000000000..9e39ce88c7aa --- /dev/null +++ b/extensions/amp-story/0.1/_locales/en.js @@ -0,0 +1,134 @@ +/** + * Copyright 2018 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import {LocalizedStringBundleDef, LocalizedStringId} from '../localization'; + + +/** + * Localized string bundle used for English strings. + * @const {!LocalizedStringBundleDef} + */ +export default { + [LocalizedStringId.AMP_STORY_EXPERIMENT_ENABLE_BUTTON_LABEL]: { + string: 'Enable', + description: 'Label for a button that enables the amp-story experiment.', + }, + [LocalizedStringId.AMP_STORY_EXPERIMENT_ENABLED_TEXT]: { + string: 'Experiment enabled. Please reload.', + description: 'Text that is shown once the amp-story experiment has ' + + 'been successfully enabled.', + }, + [LocalizedStringId.AMP_STORY_HINT_UI_NEXT_LABEL]: { + string: 'Next', + description: 'Label indicating that users can navigate to the next ' + + 'page, in the amp-story hint UI.', + }, + [LocalizedStringId.AMP_STORY_HINT_UI_PREVIOUS_LABEL]: { + string: 'Back', + description: 'Label indicating that users can navigate to the previous ' + + 'page, in the amp-story hint UI.', + }, + [LocalizedStringId.AMP_STORY_SHARING_CLIPBOARD_FAILURE_TEXT]: { + string: 'Could not copy link to clipboard :(', + description: 'String shown in a failure message to inform the user that ' + + 'a link could not be successfully copied to their clipboard.', + }, + [LocalizedStringId.AMP_STORY_SHARING_CLIPBOARD_SUCCESS_TEXT]: { + string: 'Link copied!', + description: 'String shown in a confirmation message to inform the user ' + + 'that a link was successfully copied to their clipboard.', + }, + [LocalizedStringId.AMP_STORY_SHARING_PROVIDER_NAME_EMAIL]: { + string: 'Email', + description: 'Button label for the share target that shares a link via ' + + 'email.', + }, + [LocalizedStringId.AMP_STORY_SHARING_PROVIDER_NAME_FACEBOOK]: { + string: 'Facebook', + description: 'Button label for the share target that shares a link via ' + + 'Facebook.', + }, + [LocalizedStringId.AMP_STORY_SHARING_PROVIDER_NAME_GOOGLE_PLUS]: { + string: 'Google+', + description: 'Button label for the share target that shares a link via ' + + 'Google+.', + }, + [LocalizedStringId.AMP_STORY_SHARING_PROVIDER_NAME_LINK]: { + string: 'Get Link', + description: 'Button label for the share target that shares a link via ' + + 'by copying it to the user\'s clipboard.', + }, + [LocalizedStringId.AMP_STORY_SHARING_PROVIDER_NAME_LINKEDIN]: { + string: 'LinkedIn', + description: 'Button label for the share target that shares a link via ' + + 'LinkedIn.', + }, + [LocalizedStringId.AMP_STORY_SHARING_PROVIDER_NAME_PINTEREST]: { + string: 'Pinterest', + description: 'Button label for the share target that shares a link via ' + + 'Pinterest.', + }, + [LocalizedStringId.AMP_STORY_SHARING_PROVIDER_NAME_SMS]: { + string: 'SMS', + description: 'Button label for the share target that shares a link via ' + + 'SMS.', + }, + [LocalizedStringId.AMP_STORY_SHARING_PROVIDER_NAME_SYSTEM]: { + string: 'More', + description: 'Button label for the share target that shares a link via ' + + 'deferral to the operating system\'s native sharing handler.', + }, + [LocalizedStringId.AMP_STORY_SHARING_PROVIDER_NAME_TUMBLR]: { + string: 'Tumblr', + description: 'Button label for the share target that shares a link via ' + + 'Tumblr.', + }, + [LocalizedStringId.AMP_STORY_SHARING_PROVIDER_NAME_TWITTER]: { + string: 'Twitter', + description: 'Button label for the share target that shares a link via ' + + 'Twitter.', + }, + [LocalizedStringId.AMP_STORY_SHARING_PROVIDER_NAME_WHATSAPP]: { + string: 'Whatsapp', + description: 'Button label for the share target that shares a link via ' + + 'Whatsapp.', + }, + [LocalizedStringId.AMP_STORY_SYSTEM_LAYER_SHARE_WIDGET_LABEL]: { + string: 'Share', + description: 'Label for the expandable share widget shown in the ' + + 'desktop UI.', + }, + [LocalizedStringId.AMP_STORY_WARNING_DESKTOP_SIZE_TEXT]: { + string: 'Expand your window to view this experience', + description: 'Text for a warning screen that informs the user that ' + + 'stories are only supported in larger browser windows.', + }, + [LocalizedStringId.AMP_STORY_WARNING_EXPERIMENT_DISABLED_TEXT]: { + string: 'You must enable the amp-story experiment to view this content.', + description: 'Text for a warning screen that informs the user that ' + + 'they must enable an experiment to use stories.', + }, + [LocalizedStringId.AMP_STORY_WARNING_LANDSCAPE_ORIENTATION_TEXT]: { + string: 'The page is best viewed in portrait mode', + description: 'Text for a warning screen that informs the user that ' + + 'stories are only supported in portrait orientation.', + }, + [LocalizedStringId.AMP_STORY_WARNING_UNSUPPORTED_BROWSER_TEXT]: { + string: 'We\'re sorry, it looks like your browser doesn\'t support ' + + 'this experience', + description: 'Text for a warning screen that informs the user that ' + + 'their browser does not support stories.', + }, +}; diff --git a/extensions/amp-story/0.1/amp-story-bookend.js b/extensions/amp-story/0.1/amp-story-bookend.js index 8ca54d1b600d..e7492571ddcb 100644 --- a/extensions/amp-story/0.1/amp-story-bookend.js +++ b/extensions/amp-story/0.1/amp-story-bookend.js @@ -112,12 +112,12 @@ function buildArticleTemplate(articleData) { { tag: 'h2', attrs: dict({'class': 'i-amphtml-story-bookend-article-heading'}), - text: articleData.title, + unlocalizedString: articleData.title, }, { tag: 'div', attrs: dict({'class': 'i-amphtml-story-bookend-article-meta'}), - text: articleData.domainName, + unlocalizedString: articleData.domainName, }, ], }); @@ -150,7 +150,7 @@ function buildArticlesContainerTemplate(articleSets) { template.push({ tag: 'h3', attrs: dict({'class': 'i-amphtml-story-bookend-heading'}), - text: articleSet.heading, + unlocalizedString: articleSet.heading, }); } template.push({ @@ -188,12 +188,12 @@ function buildReplayButtonTemplate(doc, title, domainName, opt_imageUrl) { { tag: 'h2', attrs: dict({'class': 'i-amphtml-story-bookend-article-heading'}), - text: title, + unlocalizedString: title, }, { tag: 'div', attrs: dict({'class': 'i-amphtml-story-bookend-article-meta'}), - text: domainName, + unlocalizedString: domainName, }, ], }); diff --git a/extensions/amp-story/0.1/amp-story-desktop.css b/extensions/amp-story/0.1/amp-story-desktop.css index 3ba28920fd80..9da40f35855f 100644 --- a/extensions/amp-story/0.1/amp-story-desktop.css +++ b/extensions/amp-story/0.1/amp-story-desktop.css @@ -322,9 +322,8 @@ div.i-amphtml-story-top { } /* Share text for share box */ -.i-amphtml-story-share-pill:after { +span.i-amphtml-story-share-pill-label { font-family: 'Roboto', sans-serif!important; - content: 'SHARE'!important; position: absolute!important; right: 15px!important; text-align: center!important; @@ -335,6 +334,7 @@ div.i-amphtml-story-top { margin: auto!important; color:#fff!important; box-sizing: initial !important; + text-transform: uppercase !important; } /* background for the share box */ diff --git a/extensions/amp-story/0.1/amp-story-hint.js b/extensions/amp-story/0.1/amp-story-hint.js index 7e97c6230730..2e799c416528 100644 --- a/extensions/amp-story/0.1/amp-story-hint.js +++ b/extensions/amp-story/0.1/amp-story-hint.js @@ -14,6 +14,7 @@ * limitations under the License. */ +import {LocalizedStringId} from './localization'; import {Services} from '../../../src/services'; import {dict} from '../../../src/utils/object'; import {renderAsElement} from './simple-template'; @@ -54,7 +55,8 @@ const TEMPLATE = { tag: 'div', attrs: dict({'class': 'i-amphtml-story-hint-tap-button-text'}), - text: 'Back', + localizedStringId: + LocalizedStringId.AMP_STORY_HINT_UI_PREVIOUS_LABEL, }, ], }, @@ -84,7 +86,8 @@ const TEMPLATE = { tag: 'div', attrs: dict({'class': 'i-amphtml-story-hint-tap-button-text'}), - text: 'Next', + localizedStringId: + LocalizedStringId.AMP_STORY_HINT_UI_NEXT_LABEL, }, ], }, diff --git a/extensions/amp-story/0.1/amp-story-share.js b/extensions/amp-story/0.1/amp-story-share.js index b620dee37e73..f2480434a5fe 100644 --- a/extensions/amp-story/0.1/amp-story-share.js +++ b/extensions/amp-story/0.1/amp-story-share.js @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import {LocalizedStringId} from './localization'; import {Services} from '../../../src/services'; import {Toast} from './toast'; import { @@ -20,7 +21,7 @@ import { isCopyingToClipboardSupported, } from '../../../src/clipboard'; import {dev, user} from '../../../src/log'; -import {dict} from './../../../src/utils/object'; +import {dict, map} from './../../../src/utils/object'; import {isObject} from '../../../src/types'; import {listen} from '../../../src/event-helper'; import {px, setImportantStyles} from '../../../src/style'; @@ -32,13 +33,19 @@ import {throttle} from '../../../src/utils/rate-limit'; * Maps share provider type to visible name. * If the name only needs to be capitalized (e.g. `facebook` to `Facebook`) it * does not need to be included here. - * @const {!JsonObject} + * @const {!Object} */ -const SHARE_PROVIDER_NAME = dict({ - 'gplus': 'Google+', - 'linkedin': 'LinkedIn', - 'system': 'More', - 'whatsapp': 'WhatsApp', +const SHARE_PROVIDER_LOCALIZED_STRING_ID = map({ + 'system': LocalizedStringId.AMP_STORY_SHARING_PROVIDER_NAME_SYSTEM, + 'email': LocalizedStringId.AMP_STORY_SHARING_PROVIDER_NAME_EMAIL, + 'facebook': LocalizedStringId.AMP_STORY_SHARING_PROVIDER_NAME_FACEBOOK, + 'linkedin': LocalizedStringId.AMP_STORY_SHARING_PROVIDER_NAME_LINKEDIN, + 'pinterest': LocalizedStringId.AMP_STORY_SHARING_PROVIDER_NAME_PINTEREST, + 'gplus': LocalizedStringId.AMP_STORY_SHARING_PROVIDER_NAME_GOOGLE_PLUS, + 'tumblr': LocalizedStringId.AMP_STORY_SHARING_PROVIDER_NAME_TUMBLR, + 'twitter': LocalizedStringId.AMP_STORY_SHARING_PROVIDER_NAME_TWITTER, + 'whatsapp': LocalizedStringId.AMP_STORY_SHARING_PROVIDER_NAME_WHATSAPP, + 'sms': LocalizedStringId.AMP_STORY_SHARING_PROVIDER_NAME_SMS, }); @@ -86,7 +93,7 @@ const LINK_SHARE_ITEM_TEMPLATE = { 'class': 'i-amphtml-story-share-icon i-amphtml-story-share-icon-link', }), - text: 'Get Link', // TODO(alanorozco): i18n + localizedStringId: LocalizedStringId.AMP_STORY_SHARING_PROVIDER_NAME_LINK, }; @@ -118,6 +125,10 @@ function buildProviderParams(opt_params) { * @return {!Node} */ function buildProvider(doc, shareType, opt_params) { + const shareProviderLocalizedStringId = dev().assert( + SHARE_PROVIDER_LOCALIZED_STRING_ID[shareType], + `No localized string to display name for share type ${shareType}.`); + return renderSimpleTemplate(doc, /** @type {!Array} */ ([ { @@ -130,7 +141,7 @@ function buildProvider(doc, shareType, opt_params) { 'type': shareType, }), buildProviderParams(opt_params))), - text: SHARE_PROVIDER_NAME[shareType] || shareType, + localizedStringId: shareProviderLocalizedStringId, }, ])); } @@ -148,12 +159,13 @@ function buildCopySuccessfulToast(doc, url) { children: [ { tag: 'div', - text: 'Link copied!', // TODO(alanorozco): i18n + localizedStringId: + LocalizedStringId.AMP_STORY_SHARING_CLIPBOARD_SUCCESS_TEXT, }, { tag: 'div', attrs: dict({'class': 'i-amphtml-story-copy-url'}), - text: url, + unlocalizedString: url, }, ], })); @@ -174,6 +186,9 @@ export class ShareWidget { /** @private {?Element} */ this.root_ = null; + + /** @private {?Promise} */ + this.localizationServicePromise_ = null; } /** @param {!Window} win */ @@ -189,6 +204,8 @@ export class ShareWidget { dev().assert(!this.root_, 'Already built.'); this.ampdoc_ = ampdoc; + this.localizationServicePromise_ = + Services.localizationServiceForOrNull(this.win_); this.root_ = renderAsElement(this.win_.document, TEMPLATE); @@ -224,7 +241,13 @@ export class ShareWidget { dev().assert(this.ampdoc_))).canonicalUrl; if (!copyTextToClipboard(this.win_, url)) { - Toast.show(this.win_, 'Could not copy link to clipboard :('); + this.localizationServicePromise_.then(localizationService => { + dev().assert(localizationService, + 'Could not retrieve LocalizationService.'); + const failureString = localizationService.getLocalizedString( + LocalizedStringId.AMP_STORY_SHARING_CLIPBOARD_FAILURE_TEXT); + Toast.show(this.win_, failureString); + }); return; } diff --git a/extensions/amp-story/0.1/amp-story.js b/extensions/amp-story/0.1/amp-story.js index 4885367db9fb..025c5b6dc40a 100644 --- a/extensions/amp-story/0.1/amp-story.js +++ b/extensions/amp-story/0.1/amp-story.js @@ -48,6 +48,11 @@ import {EventType, dispatch} from './events'; import {Gestures} from '../../../src/gesture'; import {KeyCodes} from '../../../src/utils/key-codes'; import {Layout} from '../../../src/layout'; +import { + LocalizationService, + LocalizedStringId, + createPseudoLocale, +} from './localization'; import {MediaPool, MediaType} from './media-pool'; import {NavigationState} from './navigation-state'; import {ORIGIN_WHITELIST} from './origin-whitelist'; @@ -81,6 +86,8 @@ import {registerServiceBuilder} from '../../../src/service'; import {renderSimpleTemplate} from './simple-template'; import {stringHash32} from '../../../src/string'; import {upgradeBackgroundAudio} from './audio'; +import LocalizedStringsDefault from './_locales/default'; +import LocalizedStringsEn from './_locales/en'; /** @private @const {string} */ const PRE_ACTIVE_PAGE_ATTRIBUTE_NAME = 'pre-active'; @@ -162,7 +169,8 @@ const LANDSCAPE_ORIENTATION_WARNING = [ { tag: 'div', attrs: dict({'class': 'i-amphtml-story-overlay-text'}), - text: 'The page is best viewed in portrait mode', + localizedStringId: + LocalizedStringId.AMP_STORY_WARNING_LANDSCAPE_ORIENTATION_TEXT, }, ], }, @@ -188,7 +196,8 @@ const DESKTOP_SIZE_WARNING = [ { tag: 'div', attrs: dict({'class': 'i-amphtml-story-overlay-text'}), - text: 'Expand your window to view this experience', + localizedStringId: + LocalizedStringId.AMP_STORY_WARNING_DESKTOP_SIZE_TEXT, }, ], }, @@ -212,8 +221,8 @@ const UNSUPPORTED_BROWSER_WARNING = [ { tag: 'div', attrs: dict({'class': 'i-amphtml-story-overlay-text'}), - text: 'We\'re sorry, it looks like your browser doesn\'t support ' + - 'this experience', + localizedStringId: + LocalizedStringId.AMP_STORY_WARNING_UNSUPPORTED_BROWSER_TEXT, }, ], }, @@ -229,6 +238,14 @@ const UNSUPPORTED_BROWSER_WARNING = [ const SHARE_WIDGET_PILL_CONTAINER = { tag: 'div', attrs: dict({'class': 'i-amphtml-story-share-pill'}), + children: [ + { + tag: 'span', + attrs: dict({'class': 'i-amphtml-story-share-pill-label'}), + localizedStringId: + LocalizedStringId.AMP_STORY_SYSTEM_LAYER_SHARE_WIDGET_LABEL, + }, + ], }; @@ -320,6 +337,20 @@ export class AmpStory extends AMP.BaseElement { /** @private @const {!../../../src/service/timer-impl.Timer} */ this.timer_ = Services.timerFor(this.win); + + /** @private @const {!LocalizationService} */ + this.localizationService_ = new LocalizationService(this.win); + this.localizationService_ + .registerLocalizedStringBundle('default', LocalizedStringsDefault) + .registerLocalizedStringBundle('en', LocalizedStringsEn); + + const enXaPseudoLocaleBundle = + createPseudoLocale(LocalizedStringsEn, s => `[${s} one two]`); + this.localizationService_ + .registerLocalizedStringBundle('en-xa', enXaPseudoLocaleBundle); + + registerServiceBuilder(this.win, 'localization', + () => this.localizationService_); } @@ -570,7 +601,13 @@ export class AmpStory extends AMP.BaseElement { this.shareWidget_ = new ShareWidget(this.win); - container.appendChild(this.shareWidget_.build(this.getAmpDoc())); + const shareLabelEl = dev().assertElement( + container.querySelector('.i-amphtml-story-share-pill-label'), + 'Expected share pill label to be present.'); + + container.insertBefore( + this.shareWidget_.build(this.getAmpDoc()), + shareLabelEl); this.bookend_.loadConfig(false /** applyConfig */).then(bookendConfig => { if (bookendConfig !== null) { @@ -724,16 +761,19 @@ export class AmpStory extends AMP.BaseElement { errorIconEl.classList.add('i-amphtml-story-experiment-icon-error'); const errorMsgEl = this.win.document.createElement('span'); - errorMsgEl.textContent = 'You must enable the amp-story experiment to ' + - 'view this content.'; + errorMsgEl.textContent = this.localizationService_.getLocalizedString( + LocalizedStringId.AMP_STORY_WARNING_EXPERIMENT_DISABLED_TEXT); const experimentsLinkEl = this.win.document.createElement('button'); - experimentsLinkEl.textContent = 'Enable'; + experimentsLinkEl.textContent = this.localizationService_ + .getLocalizedString( + LocalizedStringId.AMP_STORY_EXPERIMENT_ENABLE_BUTTON_LABEL); experimentsLinkEl.addEventListener('click', () => { toggleExperiment(this.win, 'amp-story', true); errorIconEl.classList.remove('i-amphtml-story-experiment-icon-error'); errorIconEl.classList.add('i-amphtml-story-experiment-icon-done'); - errorMsgEl.textContent = 'Experiment enabled. Please reload.'; + errorMsgEl.textContent = this.localizationService_.getLocalizedString( + LocalizedStringId.AMP_STORY_EXPERIMENT_ENABLED_TEXT); removeElement(experimentsLinkEl); }); diff --git a/extensions/amp-story/0.1/localization.js b/extensions/amp-story/0.1/localization.js new file mode 100644 index 000000000000..3f2542670974 --- /dev/null +++ b/extensions/amp-story/0.1/localization.js @@ -0,0 +1,231 @@ +/** + * Copyright 2018 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import {closest} from '../../../src/dom'; +import {parseJson} from '../../../src/json'; + + +/** + * A unique identifier for each localized string. Localized string IDs should: + * + * - Maintain alphabetical order + * - Be prefixed with the name of the extension that uses the string + * (e.g. "AMP_STORY_"), or with "AMP_" if they are general + * - NOT be reused; to deprecate an ID, comment it out and prefix its key with + * the string "DEPRECATED_" + * + * Next ID: 22 + * + * @const @enum {string} + */ +export const LocalizedStringId = { + // amp-story + AMP_STORY_EXPERIMENT_ENABLE_BUTTON_LABEL: '0', + AMP_STORY_EXPERIMENT_ENABLED_TEXT: '1', + AMP_STORY_HINT_UI_NEXT_LABEL: '2', + AMP_STORY_HINT_UI_PREVIOUS_LABEL: '3', + AMP_STORY_SHARING_CLIPBOARD_FAILURE_TEXT: '4', + AMP_STORY_SHARING_CLIPBOARD_SUCCESS_TEXT: '5', + AMP_STORY_SHARING_PROVIDER_NAME_EMAIL: '6', + AMP_STORY_SHARING_PROVIDER_NAME_FACEBOOK: '7', + AMP_STORY_SHARING_PROVIDER_NAME_GOOGLE_PLUS: '8', + AMP_STORY_SHARING_PROVIDER_NAME_LINK: '9', + AMP_STORY_SHARING_PROVIDER_NAME_LINKEDIN: '10', + AMP_STORY_SHARING_PROVIDER_NAME_PINTEREST: '11', + AMP_STORY_SHARING_PROVIDER_NAME_SMS: '12', + AMP_STORY_SHARING_PROVIDER_NAME_SYSTEM: '13', + AMP_STORY_SHARING_PROVIDER_NAME_TUMBLR: '14', + AMP_STORY_SHARING_PROVIDER_NAME_TWITTER: '15', + AMP_STORY_SHARING_PROVIDER_NAME_WHATSAPP: '16', + AMP_STORY_SYSTEM_LAYER_SHARE_WIDGET_LABEL: '17', + AMP_STORY_WARNING_DESKTOP_SIZE_TEXT: '18', + AMP_STORY_WARNING_EXPERIMENT_DISABLED_TEXT: '19', + AMP_STORY_WARNING_LANDSCAPE_ORIENTATION_TEXT: '20', + AMP_STORY_WARNING_UNSUPPORTED_BROWSER_TEXT: '21', +}; + + +/** + * @typedef {{ + * string: string, + * description: string, + * }} + */ +export let LocalizedStringDef; + + +/** + * @typedef {!Object} + */ +export let LocalizedStringBundleDef; + + +/** + * Language code used if there is no language code specified by the document. + * @const {string} + */ +const DEFAULT_LANGUAGE_CODE = 'default'; + + +/** + * @const {!RegExp} + */ +const LANGUAGE_CODE_CHUNK_REGEX = /\w+/gi; + + +/** + * @param {string} languageCode + * @return {!Array} A list of language codes. + */ +export function getLanguageCodesFromString(languageCode) { + const matches = languageCode.match(LANGUAGE_CODE_CHUNK_REGEX) || []; + return matches.reduce((fallbackLanguageCodeList, chunk, index) => { + const fallbackLanguageCode = matches.slice(0, index + 1) + .join('-') + .toLowerCase(); + fallbackLanguageCodeList.unshift(fallbackLanguageCode); + return fallbackLanguageCodeList; + }, [DEFAULT_LANGUAGE_CODE]); +} + + +/** + * Gets the string matching the specified localized string ID in the language + * specified. + * @param {!Object} localizedStringBundles + * @param {!Array} languageCodes + * @param {!LocalizedStringId} localizedStringId + */ +function findLocalizedString(localizedStringBundles, languageCodes, + localizedStringId) { + let localizedString = null; + + languageCodes.some(languageCode => { + const localizedStringBundle = localizedStringBundles[languageCode]; + if (localizedStringBundle && localizedStringBundle[localizedStringId] && + localizedStringBundle[localizedStringId].string) { + localizedString = localizedStringBundle[localizedStringId].string; + return true; + } + + return false; + }); + + return localizedString; +} + + +/** + * Creates a deep copy of the specified LocalizedStringBundle. + * @param {!LocalizedStringBundleDef} localizedStringBundle + * @return {!LocalizedStringBundleDef} + */ +function cloneLocalizedStringBundle(localizedStringBundle) { + return /** @type {!LocalizedStringBundleDef} */ (parseJson( + JSON.stringify(/** @type {!JsonObject} */ (localizedStringBundle)))); +} + + +/** + * Creates a pseudo locale by applying string transformations (specified by the + * localizationFn) to an existing string bundle, without modifying the original. + * @param {!LocalizedStringBundleDef} localizedStringBundle The localized + * string bundle to be transformed. + * @param {function(string): string} localizationFn The transformation to be + * applied to each string in the bundle. + * @return {!LocalizedStringBundleDef} The new strings. + */ +export function createPseudoLocale(localizedStringBundle, localizationFn) { + /** @type {!LocalizedStringBundleDef} */ + const pseudoLocaleStringBundle = + cloneLocalizedStringBundle(localizedStringBundle); + + Object.keys(pseudoLocaleStringBundle).forEach(localizedStringIdAsStr => { + const localizedStringId = + /** @type {!LocalizedStringId} */ (localizedStringIdAsStr); + pseudoLocaleStringBundle[localizedStringId].string = + localizationFn(localizedStringBundle[localizedStringId].string); + }); + + return pseudoLocaleStringBundle; +} + + +export class LocalizationService { + /** + * @param {!Window} win + */ + constructor(win) { + const rootEl = win.document.documentElement; + + /** + * @private @const {!Array} + */ + this.rootLanguageCodes_ = this.getLanguageCodesForElement_(rootEl); + + /** + * A mapping of language code to localized string bundle. + * @private @const {!Object} + */ + this.localizedStringBundles_ = {}; + } + + + /** + * @param {!Element} element + * @return {!Array} + * @private + */ + getLanguageCodesForElement_(element) { + const languageEl = closest(element, el => el.hasAttribute('lang')); + const languageCode = languageEl ? languageEl.getAttribute('lang') : null; + return getLanguageCodesFromString(languageCode || ''); + } + + + /** + * @param {string} languageCode The language code to associate with the + * specified localized string bundle. + * @param {!LocalizedStringBundleDef} localizedStringBundle The localized + * string bundle to register. + * @return {!LocalizationService} For chaining. + */ + registerLocalizedStringBundle(languageCode, localizedStringBundle) { + if (!this.localizedStringBundles_[languageCode]) { + this.localizedStringBundles_[languageCode] = {}; + } + + Object.assign(this.localizedStringBundles_[languageCode], + localizedStringBundle); + return this; + } + + + /** + * @param {!LocalizedStringId} LocalizedStringId + * @param {!Element=} elementToUse The element where the string will be + * used. The language is based on the language at that part of the + * document. If unspecified, will use the document-level language, if + * one exists, or the default otherwise. + */ + getLocalizedString(LocalizedStringId, elementToUse = undefined) { + const languageCodes = elementToUse ? + this.getLanguageCodesForElement_(elementToUse) : + this.rootLanguageCodes_; + + return findLocalizedString(this.localizedStringBundles_, languageCodes, + LocalizedStringId); + } +} diff --git a/extensions/amp-story/0.1/simple-template.js b/extensions/amp-story/0.1/simple-template.js index 0b2fd9f3fc67..05f863757294 100644 --- a/extensions/amp-story/0.1/simple-template.js +++ b/extensions/amp-story/0.1/simple-template.js @@ -13,15 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import {LocalizedStringId} from './localization'; // eslint-disable-line no-unused-vars +import {Services} from '../../../src/services'; import {createElementWithAttributes} from '../../../src/dom'; -import {isArray} from '../../../src/types'; +import {dev} from '../../../src/log'; +import {isArray, toWin} from '../../../src/types'; /** * @typedef {{ * tag: string, * attrs: (!JsonObject|undefined), - * text: (string|undefined), + * localizedStringId: (!LocalizedStringId|undefined), + * unlocalizedString: (string|undefined), * children: (!Array|undefined), * }} */ @@ -74,8 +78,19 @@ function renderSingle(doc, elementDef) { createElementWithAttributes(doc, elementDef.tag, elementDef.attrs) : doc.createElement(elementDef.tag); - if (elementDef.text) { - el.textContent = elementDef.text; + if (elementDef.localizedStringId) { + const win = toWin(doc.defaultView); + Services.localizationServiceForOrNull(win).then(localizationService => { + dev().assert(localizationService, + 'Could not retrieve LocalizationService.'); + el.textContent = localizationService + .getLocalizedString(/** @type {!LocalizedStringId} */ ( + elementDef.localizedStringId)); + }); + } + + if (elementDef.unlocalizedString) { + el.textContent = elementDef.unlocalizedString; } if (elementDef.children) { diff --git a/extensions/amp-story/0.1/test/test-localization.js b/extensions/amp-story/0.1/test/test-localization.js new file mode 100644 index 000000000000..02206cfa247c --- /dev/null +++ b/extensions/amp-story/0.1/test/test-localization.js @@ -0,0 +1,101 @@ +/** + * Copyright 2018 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + LocalizationService, + LocalizedStringId, + createPseudoLocale, + getLanguageCodesFromString, +} from '../localization'; + +describes.fakeWin('localization', {}, env => { + describe('localized string IDs', () => { + it('should have unique values', () => { + // Transform string IDs from a map of keys to values to a multimap of + // values to a list of keys that have that value. + const localizedStringIdKeys = Object.keys(LocalizedStringId); + const valuesToKeys = localizedStringIdKeys + .reduce((freq, LocalizedStringIdKey) => { + const LocalizedStringIdValue = + LocalizedStringId[LocalizedStringIdKey]; + if (!freq[LocalizedStringIdValue]) { + freq[LocalizedStringIdValue] = []; + } + + freq[LocalizedStringIdValue].push(LocalizedStringIdKey); + return freq; + }, {}); + + // Assert that each of the lists of keys from the created multimap has + // exactly one value. + const localizedStringIdValues = Object.keys(valuesToKeys); + localizedStringIdValues.forEach(value => { + const keys = valuesToKeys[value]; + expect(keys, `${value} is never used in a localized string ID`) + .to.not.be.empty; + expect(keys).to.have + .lengthOf(1, `${value} is used as a value for more than one ` + + `localized string ID: ${keys}`); + }); + }); + }); + + describe('localization service', () => { + it('should get string text', () => { + const localizationService = new LocalizationService(env.win); + localizationService.registerLocalizedStringBundle('default', { + 'test_string_id': { + string: 'test string content', + }, + }); + + expect(localizationService.getLocalizedString('test_string_id')) + .to.equal('test string content'); + }); + + it('should have language fallbacks', () => { + expect(getLanguageCodesFromString('en-US-123')).to + .deep.equal(['en-us-123', 'en-us', 'en', 'default']); + }); + }); + + describe('en-XA pseudolocale', () => { + it('should transform strings', () => { + const originalStringBundle = { + 'test_string_id': {string: 'foo'}, + }; + const pseudoLocaleBundle = createPseudoLocale(originalStringBundle, + s => `${s} ${s}`); + + expect(pseudoLocaleBundle['test_string_id'].string).to.equal('foo foo'); + }); + + it('should contain all string IDs from original locale', () => { + const originalStringBundle = { + 'msg_id_1': {string: 'msg1'}, + 'msg_id_2': {string: 'msg2'}, + 'msg_id_3': {string: 'msg3'}, + 'msg_id_4': {string: 'msg4'}, + 'msg_id_5': {string: 'msg5'}, + }; + const pseudoLocaleBundle = createPseudoLocale(originalStringBundle, + s => `${s} ${s}`); + + expect(Object.keys(originalStringBundle)).to + .deep.equal(Object.keys(pseudoLocaleBundle)); + }); + }); +}); diff --git a/src/services.js b/src/services.js index 835f86844ffa..639f2981bab2 100644 --- a/src/services.js +++ b/src/services.js @@ -311,6 +311,24 @@ export class Services { return getService(win, 'story-store'); } + /** + * @param {!Window} win + * @return {!Promise} + */ + static localizationServiceForOrNull(win) { + return ( + /** @type {!Promise} */ + (getElementServiceIfAvailable(win, 'localization', 'amp-story', true))); + } + + /** + * @param {!Window} win + * @return {!../extensions/amp-story/0.1/localization.LocalizationService} + */ + static localizationService(win) { + return getService(win, 'localization'); + } + /** * @param {!Window} win * @return {?Promise}