| @@ -0,0 +1,61 @@ | ||
| addon_id: support@laserlike.com | ||
| bug_report_url: "https://github.com/mozilla/advance/issues" | ||
| completed: '2018-10-17T06:00:00.000000Z' | ||
| contributors: | ||
| - | ||
| avatar: /static/images/experiments/advance/avatars/advance-avatar.png | ||
| display_name: Laserlike | ||
| created: "2018-08-07T13:00:00.00Z" | ||
| description: "See where the web takes you. Advance notes the webpage you’re on and recommends the next thing you’ll want to read." | ||
| details: | ||
| - | ||
| copy: "Advance shows Read Next suggestions in your browser's sidebar." | ||
| image: /static/images/experiments/advance/details/detail1.jpg | ||
| copy_l10nsuffix: "launch" | ||
| - | ||
| copy: "The sidebar's For You section shows content recommended for you based on your recent browsing history." | ||
| image: /static/images/experiments/advance/details/detail2.jpg | ||
| copy_l10nsuffix: "launch" | ||
| - | ||
| copy: "Open a new tab, and the Advance sidebar updates with new For You recommendations." | ||
| image: /static/images/experiments/advance/details/detail3.jpg | ||
| copy_l10nsuffix: "launch" | ||
| discourse_url: "https://discourse.mozilla-community.org/c/test-pilot/advance" | ||
| gradient_start: "#007991" | ||
| gradient_stop: "#78ffd6" | ||
| id: 18 | ||
| image_facebook: /static/images/experiments/advance/social/advance-facebook.png | ||
| image_twitter: /static/images/experiments/advance/social/advance-twitter.png | ||
| introduction: "Advance delivers real-time recommendations to your Firefox sidebar while you browse. Advance uses your current browsing to suggest related news and similar pages to read next, and uses your browsing history to create a personalized feed of quality content." | ||
| launch_date: "2018-08-07T13:00:00.00Z" | ||
| legal_notice: By proceeding, you agree to the <terms-link>terms</terms-link> and <privacy-link>privacy</privacy-link> policies of Test Pilot and the <modal-link>Advance privacy policy</modal-link>. | ||
| legal_notice_l10nsuffix: withlinks | ||
| locale_grantlist: | ||
| - en | ||
| locales: | ||
| - en | ||
| measurements: | ||
| - "Sensitive Data: After installation, Laserlike will receive your web browsing history. No data is sent if you are in private browsing or pause mode, the experiment expires, or you disable it. Laserlike also receives your IP addresses, dates/timestamps, and time spent on webpages. This data is used to index URLs publicly visible on the web." | ||
| - "Controls: The settings allow you to request what data Laserlike receives about you from this experiment. You can also delete cookies, web browsing history, and related Laserlike account information." | ||
| - "Technical and Interaction Data: Both Mozilla and Laserlike will receive clickthrough rates and time spent on recommended content; data on how you interact with the sidebar and experiment; and technical data about your OS, browser, locale." | ||
| order: 1 | ||
| platforms: | ||
| - addon | ||
| privacy_notice_url: "https://github.com/mozilla/advance/blob/master/metrics.md" | ||
| privacy_preamble: "The Advance Test Pilot experiment is a collaboration between Laserlike and Mozilla." | ||
| slug: advance | ||
| subtitle: "Powered by Laserlike" | ||
| thumbnail: /static/images/experiments/advance/icon/thumbnail.png | ||
| title: Advance | ||
| tour_steps: | ||
| - | ||
| copy: "Keep recommended content close at hand in a sidebar." | ||
| image: /static/images/experiments/advance/tour/tour1.jpg | ||
| - | ||
| copy: "Or access suggestions when you want from the Advance toolbar icon." | ||
| copy_l10nsuffix: "launch" | ||
| image: /static/images/experiments/advance/tour/tour2.jpg | ||
| - | ||
| copy: "You can always give us feedback or disable Advance from Test Pilot." | ||
| image: /static/images/experiments/advance/tour/tour3.jpg | ||
| xpi_url: "https://testpilot.firefox.com/files/support@laserlike.com/latest" |
| @@ -0,0 +1,76 @@ | ||
| id: 20 | ||
| title: 'Email Tabs' | ||
| slug: email-tabs | ||
| is_featured: false | ||
| platforms: ['addon'] | ||
| min_release: 63 | ||
| thumbnail: /static/images/experiments/email-tabs/email-tabs.png | ||
| description: > | ||
| Easily create emails from all of your tabs and make saving and sharing easier than ever. | ||
| introduction: > | ||
| <p>Ever needed to save or share a whole bunch of tabs as you research, shop, or just browse the web? Email Tabs lets you create beautiful emails from your open tabs to save them for later or share them. You can use Email Tabs to automatically send along links, screenshots, or even the text from articles.<p> | ||
| <p>Email Tabs currently works with the Gmail webmail client, but we’re working to bring it to other popular webmail providers as well.</p> | ||
| image_twitter: /static/images/experiments/email-tabs/email-tabs-twitter.jpg | ||
| image_facebook: /static/images/experiments/email-tabs/email-tabs-fb.jpg | ||
| changelog_url: 'https://github.com/mozilla/email-tabs/releases' | ||
| contribute_url: 'https://github.com/mozilla/email-tabs' | ||
| bug_report_url: 'https://github.com/mozilla/email-tabs/issues' | ||
| discourse_url: 'https://discourse.mozilla-community.org/c/test-pilot/email-tabs' | ||
| privacy_notice_url: 'https://github.com/mozilla/email-tabs/blob/master/docs/metrics.md' | ||
| legal_notice: By proceeding, you agree to the <terms-link>terms</terms-link> and <privacy-link>privacy</privacy-link> policies of Test Pilot and the <modal-link>Email Tabs privacy policy</modal-link>. | ||
| measurements: | ||
| - > | ||
| Email Tabs collects information about your use of the feature such as which email provider you choose, how often you interact with Email Tabs UI, and whether you choose to send all tabs at once or just a selection. | ||
| - > | ||
| Email Tabs also collects information about the emails generated by the feature such as how many tabs you choose to send, the Email Tabs template you select, the count of recipients for Email Tabs emails, and whether the sender email address matches the recipient email address. | ||
| - > | ||
| Email Tabs does not collect any information about your email address, your email contacts or the URLS of any websites you visit or send while using Email Tabs. | ||
| xpi_url: 'https://testpilot.firefox.com/files/email-tabs@mozilla.org/latest' | ||
| addon_id: 'email-tabs@mozilla.org' | ||
| gradient_start: '#008EA4' | ||
| gradient_stop: '#31FFC3' | ||
| details: | ||
| - | ||
| image: /static/images/experiments/email-tabs/email-tabs-detail-1.jpg | ||
| copy: > | ||
| Create emails from your open tabs in just a few clicks. | ||
| - | ||
| image: /static/images/experiments/email-tabs/email-tabs-detail-2.jpg | ||
| copy: > | ||
| Email Tabs lets you send links, screenshots, and even full articles. | ||
| - | ||
| image: /static/images/experiments/email-tabs/email-tabs-detail-3.jpg | ||
| copy: > | ||
| Email Tabs automatically creates your email. Just add your recipients and send. | ||
| tour_steps: | ||
| - | ||
| image: /static/images/experiments/email-tabs/email-tabs-tour-0.jpg | ||
| copy: Access Email Tabs from your browser’s toolbar. | ||
| - | ||
| image: /static/images/experiments/email-tabs/email-tabs-tour-1.jpg | ||
| copy: You can choose to email all of your tabs, or just select a few. | ||
| - | ||
| image: /static/images/experiments/email-tabs/email-tabs-tour-2.jpg | ||
| copy: You can send links to your tabs, screenshots and links, or even full articles. | ||
| - | ||
| image: /static/images/experiments/email-tabs/email-tabs-tour-3.jpg | ||
| copy: Email Tabs will automatically format your email. Just add your recipients and send. | ||
| - | ||
| image: /static/images/experiments/email-tabs/email-tabs-tour-4.jpg | ||
| copy: You can always give us feedback or disable Email Tabs from Test Pilot. | ||
| contributors: | ||
| - | ||
| display_name: 'Ian Bicking' | ||
| title: 'Software Engineer' | ||
| avatar: /static/images/experiments/page-shot/avatars/ian-bicking.jpg | ||
| - | ||
| display_name: 'Dave Justice' | ||
| title: 'Engineer' | ||
| avatar: /static/images/experiments/min-vid/avatars/dave-justice.jpg | ||
| - | ||
| display_name: 'Mark Liang' | ||
| title: 'Firefox UX' | ||
| avatar: /static/images/experiments/avatars/mliang.png | ||
| created: '2018-11-12T14:00:00Z' | ||
| launch_date: '2018-11-12T14:00:00Z' | ||
| order: 0 |
| @@ -0,0 +1,57 @@ | ||
| addon_id: shopping-testpilot@mozilla.org | ||
| bug_report_url: "https://github.com/mozilla/price-wise/issues" | ||
| contributors: | ||
| - | ||
| avatar: /static/images/experiments/price-wise/avatars/bianca.jpg | ||
| display_name: "Bianca Danforth" | ||
| - | ||
| avatar: /static/images/experiments/price-wise/avatars/osmose.jpg | ||
| display_name: Osmose | ||
| - | ||
| avatar: /static/images/experiments/price-wise/avatars/carmen.jpg | ||
| display_name: Carmen Fat | ||
| - | ||
| avatar: /static/images/experiments/price-wise/avatars/pablo.jpg | ||
| display_name: Pablo Oubina | ||
| - | ||
| avatar: /static/images/experiments/price-wise/avatars/remus.jpg | ||
| display_name: Remus Dranca | ||
| created: "2018-11-12T14:00:00Z" | ||
| description: "Price Wise automatically looks for price drops on specific products at Amazon, Walmart and other top U.S. retailers." | ||
| description_l10nsuffix: "us_clarification" | ||
| details: | ||
| - | ||
| copy: "Tell Price Wise to keep an eye on a product and it’s added to your watch list." | ||
| image: /static/images/experiments/price-wise/details/detail1.png | ||
| - | ||
| copy: "When the price drops, Price Wise alerts you with a colorful heads-up." | ||
| image: /static/images/experiments/price-wise/details/detail2.png | ||
| discourse_url: "https://discourse.mozilla-community.org/c/test-pilot/price-wise" | ||
| is_featured: true | ||
| gradient_start: "#DE5BC5" | ||
| gradient_stop: "#86BBF9" | ||
| id: 19 | ||
| image_facebook: /static/images/experiments/price-wise/social/price-wise-facebook.jpg | ||
| image_twitter: /static/images/experiments/price-wise/social/price-wise-twitter.jpg | ||
| introduction: "Price Wise spots price drops on things you’re interested in at Amazon and AmazonSmile, Best Buy, eBay, Home Depot and Walmart (U.S. domains only for now). When Price Wise finds a price drop, the add-on gives you a heads-up about the lower price." | ||
| introduction_l10nsuffix: "us_clarification" | ||
| launch_date: "2018-11-12T14:00:00Z" | ||
| legal_notice: "By proceeding, you agree to the <terms-link>terms</terms-link> and <privacy-link>privacy</privacy-link> policies of Test Pilot and the <modal-link>Price Wise privacy policy</modal-link>." | ||
| locale_grantlist: | ||
| - en | ||
| locales: | ||
| - en | ||
| measurements: | ||
| - "Product Pages: Price Wise collects how often you visit saved product pages." | ||
| - "Product Data: Price Wise collects data about the products you choose to track such as prices change frequency, size, and the accuracy of that information." | ||
| - "Interaction Data: Price Wise collects information about how you use the feature such as how often you add, delete and interact with saved products, number of products you choose to track, and how you engage with messages and push notifications." | ||
| min_release: 63 | ||
| order: 1 | ||
| platforms: | ||
| - addon | ||
| privacy_notice_url: "https://github.com/mozilla/price-wise/blob/master/docs/METRICS.md" | ||
| slug: price-wise | ||
| thumbnail: /static/images/experiments/price-wise/icon/thumbnail.png | ||
| title: "Price Wise" | ||
| video_url: "https://www.youtube.com/embed/UpRLjTQmkW4" | ||
| xpi_url: "https://testpilot.firefox.com/files/shopping-testpilot@mozilla.org/latest" |
| @@ -1,4 +1,3 @@ | ||
|
|
||
| declare module 'fluent-react/compat' { | ||
| declare function Localized(): Object; | ||
| } |
| @@ -0,0 +1,3 @@ | ||
| declare module 'fluent/compat' { | ||
| declare module.exports: any; | ||
| } |
| @@ -0,0 +1,73 @@ | ||
| // flow-typed signature: e2997cda0412ba771b3b6d9f311e9650 | ||
| // flow-typed version: <<STUB>>/html-react-parser_v^0.4.1/flow_v0.64.0 | ||
|
|
||
| /** | ||
| * This is an autogenerated libdef stub for: | ||
| * | ||
| * 'html-react-parser' | ||
| * | ||
| * Fill this stub out by replacing all the `any` types. | ||
| * | ||
| * Once filled out, we encourage you to share your work with the | ||
| * community by sending a pull request to: | ||
| * https://github.com/flowtype/flow-typed | ||
| */ | ||
|
|
||
| declare module 'html-react-parser' { | ||
| declare module.exports: any; | ||
| } | ||
|
|
||
| /** | ||
| * We include stubs for each file inside this npm package in case you need to | ||
| * require those files directly. Feel free to delete any files that aren't | ||
| * needed. | ||
| */ | ||
| declare module 'html-react-parser/dist/html-react-parser' { | ||
| declare module.exports: any; | ||
| } | ||
|
|
||
| declare module 'html-react-parser/dist/html-react-parser.min' { | ||
| declare module.exports: any; | ||
| } | ||
|
|
||
| declare module 'html-react-parser/lib/attributes-to-props' { | ||
| declare module.exports: any; | ||
| } | ||
|
|
||
| declare module 'html-react-parser/lib/dom-to-react' { | ||
| declare module.exports: any; | ||
| } | ||
|
|
||
| declare module 'html-react-parser/lib/property-config' { | ||
| declare module.exports: any; | ||
| } | ||
|
|
||
| declare module 'html-react-parser/lib/utilities' { | ||
| declare module.exports: any; | ||
| } | ||
|
|
||
| // Filename aliases | ||
| declare module 'html-react-parser/dist/html-react-parser.js' { | ||
| declare module.exports: $Exports<'html-react-parser/dist/html-react-parser'>; | ||
| } | ||
| declare module 'html-react-parser/dist/html-react-parser.min.js' { | ||
| declare module.exports: $Exports<'html-react-parser/dist/html-react-parser.min'>; | ||
| } | ||
| declare module 'html-react-parser/index' { | ||
| declare module.exports: $Exports<'html-react-parser'>; | ||
| } | ||
| declare module 'html-react-parser/index.js' { | ||
| declare module.exports: $Exports<'html-react-parser'>; | ||
| } | ||
| declare module 'html-react-parser/lib/attributes-to-props.js' { | ||
| declare module.exports: $Exports<'html-react-parser/lib/attributes-to-props'>; | ||
| } | ||
| declare module 'html-react-parser/lib/dom-to-react.js' { | ||
| declare module.exports: $Exports<'html-react-parser/lib/dom-to-react'>; | ||
| } | ||
| declare module 'html-react-parser/lib/property-config.js' { | ||
| declare module.exports: $Exports<'html-react-parser/lib/property-config'>; | ||
| } | ||
| declare module 'html-react-parser/lib/utilities.js' { | ||
| declare module.exports: $Exports<'html-react-parser/lib/utilities'>; | ||
| } |
| @@ -0,0 +1,23 @@ | ||
| // flow-typed signature: cf86673cc32d185bdab1d2ea90578d37 | ||
| // flow-typed version: 614bf49aa8/classnames_v2.x.x/flow_>=v0.25.x | ||
|
|
||
| type $npm$classnames$Classes = | ||
| | string | ||
| | { [className: string]: * } | ||
| | false | ||
| | void | ||
| | null; | ||
|
|
||
| declare module "classnames" { | ||
| declare module.exports: ( | ||
| ...classes: Array<$npm$classnames$Classes | $npm$classnames$Classes[]> | ||
| ) => string; | ||
| } | ||
|
|
||
| declare module "classnames/bind" { | ||
| declare module.exports: $Exports<"classnames">; | ||
| } | ||
|
|
||
| declare module "classnames/dedupe" { | ||
| declare module.exports: $Exports<"classnames">; | ||
| } |
| @@ -0,0 +1,41 @@ | ||
| // flow-typed signature: e0a359e4d48c1106a3f8b77134811839 | ||
| // flow-typed version: <<STUB>>/seedrandom_v2.4.3/flow_v0.78.0 | ||
|
|
||
| /** | ||
| * This is an autogenerated libdef stub for: | ||
| * | ||
| * 'seedrandom' | ||
| * | ||
| * Fill this stub out by replacing all the `any` types. | ||
| * | ||
| * Once filled out, we encourage you to share your work with the | ||
| * community by sending a pull request to: | ||
| * https://github.com/flowtype/flow-typed | ||
| */ | ||
|
|
||
| declare module 'seedrandom' { | ||
| declare module.exports: any; | ||
| } | ||
|
|
||
| declare module 'seedrandom/seedrandom' { | ||
| declare module.exports: any; | ||
| } | ||
|
|
||
| declare module 'seedrandom/seedrandom.min' { | ||
| declare module.exports: any; | ||
| } | ||
|
|
||
| // Filename aliases | ||
| declare module 'seedrandom/index' { | ||
| declare module.exports: $Exports<'seedrandom'>; | ||
| } | ||
| declare module 'seedrandom/index.js' { | ||
| declare module.exports: $Exports<'seedrandom'>; | ||
| } | ||
| declare module 'seedrandom/seedrandom.js' { | ||
| declare module.exports: $Exports<'seedrandom/seedrandom'>; | ||
| } | ||
| declare module 'seedrandom/seedrandom.min.js' { | ||
| declare module.exports: $Exports<'seedrandom/seedrandom.min'>; | ||
| } | ||
|
|
| @@ -0,0 +1,26 @@ | ||
| // @flow | ||
|
|
||
| import type { | ||
| FetchCountryCodeAction | ||
| } from "../reducers/browser"; | ||
|
|
||
| // There may be a better place for this, but this seems | ||
| // the best for now. | ||
| export const acceptedSMSCountries = ["US", "DE", "FR"]; | ||
| export const COUNTRY_CODE_ENDPOINT = "https://location.services.mozilla.com/v1/country"; | ||
|
|
||
| export function fetchCountryCode(): Promise<FetchCountryCodeAction> { | ||
| return fetch(COUNTRY_CODE_ENDPOINT) | ||
| .then((resp) => resp.json()) | ||
| .then((data) => { | ||
| return { | ||
| type: "FETCH_COUNTRY_CODE", | ||
| payload: data.country_code | ||
| }; | ||
| }).catch((err) => { | ||
| return { | ||
| type: "FETCH_COUNTRY_CODE", | ||
| payload: "" | ||
| }; | ||
| }); | ||
| } |
| @@ -0,0 +1,59 @@ | ||
| // @flow | ||
| import React, { Component } from "react"; | ||
|
|
||
| type HeadProps = { | ||
| metaTitle: string, | ||
| metaDescription: string, | ||
| availableLocales: string, | ||
| canonicalPath: string, | ||
| imageFacebook: string, | ||
| imageTwitter: string | ||
| } | ||
|
|
||
| export default class Head extends Component<HeadProps> { | ||
|
|
||
| render() { | ||
| const { | ||
| metaTitle, metaDescription, | ||
| availableLocales, canonicalPath, | ||
| imageFacebook, imageTwitter | ||
| } = this.props; | ||
|
|
||
| const canonicalUrl = `https://testpilot.firefox.com/${canonicalPath}`; | ||
|
|
||
| return ( | ||
| <head> | ||
| <meta charSet="utf-8" /> | ||
| <link rel="shortcut icon" href="/static/images/favicon.ico" /> | ||
| <link rel="stylesheet" href="https://code.cdn.mozilla.net/fonts/fira.css" /> | ||
| <link rel="stylesheet" href="/static/styles/experiments.css" /> | ||
| <link rel="stylesheet" href="/static/app/app.css" /> | ||
|
|
||
| <meta name="defaultLanguage" content="en-US" /> | ||
| <meta name="availableLanguages" content={availableLocales} /> | ||
| <meta name="viewport" content="width=device-width" /> | ||
|
|
||
| <link rel="alternate" type="application/atom+xml" href="/feed.atom" title="Atom Feed" /> | ||
| <link rel="alternate" type="application/rss+xml" href="/feed.rss" title="RSS Feed" /> | ||
| <link rel="alternate" type="application/json" href="/feed.json" title="JSON Feed" /> | ||
|
|
||
| <link rel="canonical" href={canonicalUrl} /> | ||
|
|
||
| <title>{metaTitle}</title> | ||
|
|
||
| <meta property="og:type" content="website" /> | ||
| <meta property="og:title" content={metaTitle} /> | ||
| <meta name="twitter:title" content={metaTitle} /> | ||
| <meta name="description" content={metaDescription} /> | ||
| <meta property="og:description" content={metaDescription} /> | ||
| <meta name="twitter:description" content={metaDescription} /> | ||
| <meta name="twitter:card" content="summary" /> | ||
| <meta property="og:image" content={imageFacebook} /> | ||
| <meta name="twitter:image" content={imageTwitter} /> | ||
| <meta property="og:url" content={canonicalUrl} /> | ||
| </head> | ||
| ); | ||
| } | ||
|
|
||
| } | ||
|
|
| @@ -0,0 +1,53 @@ | ||
| /* global describe, beforeEach, it */ | ||
| import React from "react"; | ||
|
|
||
| import Head from "."; | ||
|
|
||
| import { expect } from "chai"; | ||
| import { shallow } from "enzyme"; | ||
|
|
||
| describe("app/components/Head", () => { | ||
| let subject, props; | ||
|
|
||
| beforeEach(() => { | ||
| props = { | ||
| metaTitle: "Firefox Test Pilot", | ||
| metaDescription: "Test new Features. Give us feedback. Help build Firefox.", | ||
|
|
||
| imageFacebook: "/static/images/thumbnail-facebook.png", | ||
| imageTwitter: "/static/images/thumbnail-twitter.png", | ||
|
|
||
| availableLocales: "en-US,fr-CA", | ||
| canonicalPath: "canonical/path" | ||
| }; | ||
|
|
||
| subject = shallow(<Head {...props} />); | ||
| }); | ||
|
|
||
| it("should contain the correct page title", () => { | ||
| expect(subject.find("title").text()).equals(props.metaTitle); | ||
| }); | ||
|
|
||
| it("should contain the correct available locales", () => { | ||
| expect(subject.find(`meta[name="availableLanguages"]`).props().content).equals(props.availableLocales); | ||
| }); | ||
|
|
||
| it("should contain the correct canonical URL", () => { | ||
| expect(subject.find(`link[rel="canonical"]`).props().href).match(new RegExp(`${props.canonicalPath}$`)); | ||
| }); | ||
|
|
||
| it("should contain correct Twitter metadata", () => { | ||
| expect(subject.find(`meta[name="twitter:title"]`).props().content).equals(props.metaTitle); | ||
| expect(subject.find(`meta[name="twitter:description"]`).props().content).equals(props.metaDescription); | ||
| expect(subject.find(`meta[name="twitter:image"]`).props().content).equals(props.imageTwitter); | ||
| expect(subject.find(`meta[name="twitter:card"]`).props().content).equals("summary"); | ||
| }); | ||
|
|
||
| it("should contain correct Facebook metadata", () => { | ||
| expect(subject.find(`meta[property="og:type"]`).props().content).equals("website"); | ||
| expect(subject.find(`meta[property="og:title"]`).props().content).equals(props.metaTitle); | ||
| expect(subject.find(`meta[property="og:description"]`).props().content).equals(props.metaDescription); | ||
| expect(subject.find(`meta[property="og:image"]`).props().content).equals(props.imageFacebook); | ||
| expect(subject.find(`meta[property="og:url"]`).props().content).to.match(new RegExp(`${props.canonicalPath}$`)); | ||
| }); | ||
| }); |
| @@ -1,136 +1,29 @@ | ||
|
|
||
| import { Localized } from "fluent-react/compat"; | ||
| import {Children, cloneElement} from "react"; | ||
| import { withLocalization } from "fluent-react/compat"; | ||
| import parser from "html-react-parser"; | ||
| import domToReact from "html-react-parser/lib/dom-to-react"; | ||
| import React from "react"; | ||
|
|
||
| function recurseChildrenAndFindAnchors(found, node) { | ||
| /* | ||
| When anchors are present in a LocalizedHtml instance, | ||
| all anchors in the ftl should have their attributes | ||
| overwritten by the attributes in the corresponding | ||
| anchors in the jsx. This prevents localizers from | ||
| hijacking anchors. | ||
| const MISSING_TRANSLATION = Symbol(); | ||
|
|
||
| // Retrieve the translation for the id, parse it with html-react-parser, and | ||
| // render the wrapped element using the parsed result as its children. | ||
| function LocalizedHtml({children, id, getString}) { | ||
| const wrappedElement = Children.only(children); | ||
|
|
||
| To support this, this function simply performs a | ||
| depth-first traversal of the children of the React | ||
| element `node` and pushes any `a` tags into the | ||
| `found` Array. | ||
| */ | ||
| if (typeof node === "string" || typeof node.props === "undefined") { | ||
| return; | ||
| // By default, when the translation is missing, getString returns the | ||
| // identifier as fallback. Instead, pass a custom symbol as the fallback to be | ||
| // used and return the wrapped element without any modifications. This is also | ||
| // used in tests. | ||
| const translation = getString(id, null, MISSING_TRANSLATION); | ||
| if (translation === MISSING_TRANSLATION) { | ||
| return wrappedElement; | ||
| } | ||
| React.Children.forEach(node.props.children, child => { | ||
| if (child.type === "a") { | ||
| found.push(child); | ||
| } else { | ||
| recurseChildrenAndFindAnchors(found, child); | ||
| } | ||
|
|
||
| return cloneElement(wrappedElement, { | ||
| // The parsed result may be a single React element or an array of elements. | ||
| // Pass it explicitly as the children prop (rather than as ...children) to | ||
| // handle this ambiguity. | ||
| children: parser(translation) | ||
| }); | ||
| } | ||
|
|
||
|
|
||
| export default class LocalizedHtml extends Localized { | ||
| render() { | ||
| const templates = { | ||
| anchors: [], | ||
| insertIndex: 0, | ||
| readIndex: 0 | ||
| }; | ||
| recurseChildrenAndFindAnchors(templates.anchors, this.props.children); | ||
|
|
||
| /* | ||
| Localized.render returns one of two things: if the | ||
| ftl string being localized contains ${template} | ||
| placeholders, it will return an element whose children | ||
| are strings and object instances representing the | ||
| placeholders. If the ftl string being localized does | ||
| not contain template placeholders, it will return an | ||
| element with a string children prop. | ||
| */ | ||
| const result = super.render(); | ||
| /* | ||
| If we are being used in the tests, just return the | ||
| placeholder content which Localized returns in this | ||
| case. | ||
| */ | ||
| if (typeof this.context.l10n === "undefined") { | ||
| return result; | ||
| } | ||
|
|
||
| let joined; | ||
| if (typeof result.props.children === "string") { | ||
| /* | ||
| If children is a string, there are | ||
| no template substitutions to make. Simply | ||
| prepare for parsing the ftl element as html. | ||
| */ | ||
| joined = result.props.children; | ||
| } else { | ||
| /* | ||
| Otherwise, join together result.props.children, | ||
| replacing template elements with the sentinel | ||
| html <span>///</span> and storing the original | ||
| template elements in `templates`. | ||
| */ | ||
| const mapped = React.Children.map(result.props.children, child => { | ||
| if (typeof child === "string") { | ||
| return child; | ||
| } | ||
| templates.insertIndex++; | ||
| templates[templates.insertIndex] = child; | ||
| return "<span>///</span>"; | ||
| }); | ||
|
|
||
| if (mapped === null) { | ||
| joined = ""; | ||
| } else { | ||
| joined = mapped.join(""); | ||
| } | ||
| } | ||
|
|
||
| /* | ||
| Now that we have replaced any template nodes with | ||
| <span>///</span> and produced one html string from | ||
| the ftl string, we can use html-react-parser to parse | ||
| the string into React elements. html-react-parser | ||
| takes an options object which has a replace method | ||
| which takes a node (in htmlparser2.parseDOM format) | ||
| and returns React elements. If `node` is | ||
| <span>///</span>, we return the template node | ||
| stored earlier. If `node` is an anchor, we copy | ||
| all the attributes from the jsx anchor to the one | ||
| parsed from the ftl. | ||
| */ | ||
| const options = { | ||
| replace: node => { | ||
| // node has the same structure as htmlparser2.parseDOM | ||
| // https://github.com/fb55/domhandler#example | ||
| if (node.type === "tag") { | ||
| if (node.name === "span" && | ||
| node.children && node.children.length === 1 && | ||
| node.children[0].data === "///") { | ||
| templates.readIndex++; | ||
| return templates[templates.readIndex]; | ||
| } else if (node.name === "a") { | ||
| if (templates.anchors.length) { | ||
| const anchor = templates.anchors.shift(); | ||
| return React.cloneElement( | ||
| anchor, | ||
| { | ||
| children: domToReact(node.children, options), | ||
| ...anchor.props | ||
| } | ||
| ); | ||
| } | ||
| throw new Error(`ftl string "${this.props.id}" did not have as many anchors as the jsx`); | ||
| } | ||
| } | ||
| return undefined; | ||
| } | ||
| }; | ||
|
|
||
| const parsed = parser(joined, options); | ||
| return React.cloneElement(result, { children: parsed }); | ||
| } | ||
| } | ||
| export default withLocalization(LocalizedHtml); |