diff --git a/.circleci/config.yml b/.circleci/config.yml index b9be963cc..c38b41685 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -5,10 +5,10 @@ references: # # Workspace # - container_config_node8: &container_config_node8 + container_config_node: &container_config_node working_directory: ~/project/build docker: - - image: circleci/node:8.15 + - image: circleci/node:12-browsers workspace_root: &workspace_root ~/project @@ -22,37 +22,17 @@ references: # cache_keys_root: &cache_keys_root keys: - - cache-root-v1-{{ .Branch }}-{{ checksum "./package.json" }} - - cache_keys_docs: &cache_keys_docs - keys: - - cache-docs-v1-{{ .Branch }}-{{ checksum "./tools/x-docs/package.json" }} - - cache_keys_storybook: &cache_keys_storybook - keys: - - cache-storybook-v1-{{ .Branch }}-{{ checksum "./tools/x-storybook/package.json" }} + - cache-root-v4-{{ .Branch }}-{{ checksum "./package.json" }} # # Cache creation # create_cache_root: &create_cache_root save_cache: - key: cache-root-v1-{{ .Branch }}-{{ checksum "./package.json" }} + key: cache-root-v4-{{ .Branch }}-{{ checksum "./package.json" }} paths: - ./node_modules/ - create_cache_docs: &create_cache_docs - save_cache: - key: cache-docs-v1-{{ .Branch }}-{{ checksum "./tools/x-docs/package.json" }} - paths: - - ./tools/x-docs/node_modules/ - - create_cache_storybook: &create_cache_storybook - save_cache: - key: cache-storybook-v1-{{ .Branch }}-{{ checksum "./tools/x-storybook/package.json" }} - paths: - - ./tools/x-storybook/node_modules/ - # # Cache restoration # @@ -60,23 +40,19 @@ references: restore_cache: <<: *cache_keys_root - restore_cache_docs: &restore_cache_docs - restore_cache: - <<: *cache_keys_docs - - restore_cache_storybook: &restore_cache_storybook - restore_cache: - <<: *cache_keys_storybook - # # Filters # - filters_branch_build: &filters_branch_build + + filters_only_renovate_nori: &filters_only_renovate_nori branches: - ignore: - - gh-pages + only: /(^renovate-.*|^nori\/.*)/ + + filters_ignore_tags_renovate_nori_build: &filters_ignore_tags_renovate_nori_build tags: ignore: /.*/ + branches: + ignore: /(^renovate-.*|^nori\/.*|^gh-pages)/ filters_release_build: &filters_release_build tags: @@ -92,23 +68,21 @@ references: branches: ignore: /.*/ - filters_master_branch: &filters_master_branch + filters_only_main: &filters_only_main branches: only: - - master + - main jobs: build: - <<: *container_config_node8 + <<: *container_config_node steps: - checkout - run: name: Checkout next-ci-shared-helpers command: git clone --depth 1 git@github.com:Financial-Times/next-ci-shared-helpers.git .circleci/shared-helpers - *restore_cache_root - - *restore_cache_docs - - *restore_cache_storybook - run: name: Install project dependencies command: make install @@ -116,23 +90,32 @@ jobs: name: Run the project build task command: make build - *create_cache_root - - *create_cache_docs - - *create_cache_storybook - persist_to_workspace: root: *workspace_root paths: - build test: - <<: *container_config_node8 + <<: *container_config_node steps: - *attach_workspace - run: name: Run tests command: make test + - run: + name: Run storybook + command: npm run start-storybook:ci + + e2e-test: + <<: *container_config_node + steps: + - *attach_workspace + - run: + name: Run end to end test + command: npm run e2e publish: - <<: *container_config_node8 + <<: *container_config_node steps: - *attach_workspace - run: @@ -146,35 +129,36 @@ jobs: command: npx athloi publish -- --access=public prerelease: - <<: *container_config_node8 + <<: *container_config_node steps: - *attach_workspace - run: name: shared-helper / npm-store-auth-token command: .circleci/shared-helpers/helper-npm-store-auth-token - run: - name: Bump version number + name: Extract tag name and version number command: | # https://circleci.com/docs/2.0/env-vars/#using-bash_env-to-set-environment-variables - export PRERELEASE_SEMVER='v0\.[0-9]{1,2}\.[0-9]{1,2}(-[a-z]+\.[0-9])?' + export PRERELEASE_SEMVER='v0\.[0-9]{1,2}\.[0-9]{1,2}(-[a-z]+\.[0-9]+)?' export TARGET_VERSION=$(echo $CIRCLE_TAG | grep -o -E $PRERELEASE_SEMVER); export TARGET_MODULE=$(echo $CIRCLE_TAG | sed -E "s/-${PRERELEASE_SEMVER}//g"); + echo "export TARGET_VERSION=$TARGET_VERSION" >> $BASH_ENV; + echo "export TARGET_MODULE=$TARGET_MODULE" >> $BASH_ENV; echo "Creating prerelease ${TARGET_VERSION} for ${TARGET_MODULE}"; - npx athloi -F ${TARGET_MODULE} version ${TARGET_VERSION}; + - run: + name: Bump version number + command: npx athloi -F ${TARGET_MODULE} version ${TARGET_VERSION}; - run: name: NPM publish command: npx athloi -F ${TARGET_MODULE} publish -- --access=public --tag=pre-release deploy: - <<: *container_config_node8 + <<: *container_config_node steps: - *attach_workspace - - add_ssh_keys: - fingerprints: - - "2b:98:17:21:34:bf:5d:3b:15:a5:82:77:90:11:03:e9" - run: - name: Publish GitHub Pages - command: ./private/scripts/gh-pages + name: Deploy Storybook + command: npm run deploy-storybook:ci workflows: @@ -184,18 +168,32 @@ workflows: jobs: - build: filters: - <<: *filters_branch_build + <<: *filters_ignore_tags_renovate_nori_build - test: - filters: - <<: *filters_branch_build + requires: + - build + - e2e-test: requires: - build - deploy: filters: - <<: *filters_master_branch + <<: *filters_only_main requires: - test + renovate-nori-build-test: + jobs: + - waiting-for-approval: + type: approval + filters: + <<: *filters_only_renovate_nori + - build: + requires: + - waiting-for-approval + - test: + requires: + - build + build-test-publish-deploy: jobs: - build: diff --git a/.eslintignore b/.eslintignore index c29184fe1..0fe84d9ab 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,4 @@ +**/*.ts **/coverage/** **/node_modules/** **/bower_components/** @@ -6,4 +7,5 @@ **/public/** **/public-prod/** **/blueprints/** -tools/x-docs/static/** \ No newline at end of file +web/static/** +/e2e/** \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index e3967a3f0..5d5b914a8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,12 +1,11 @@ module.exports = { + parser: '@typescript-eslint/parser', env: { node: true, browser: true, es6: true }, - plugins: [ - 'jsx-a11y' - ], + plugins: ['jsx-a11y'], extends: [ 'eslint:recommended', // https://github.com/jest-community/eslint-plugin-jest @@ -14,7 +13,8 @@ module.exports = { // https://github.com/yannickcr/eslint-plugin-react 'plugin:react/recommended', // https://github.com/evcohen/eslint-plugin-jsx-a11y - 'plugin:jsx-a11y/recommended' + 'plugin:jsx-a11y/recommended', + 'prettier' ], parserOptions: { ecmaFeatures: { @@ -35,12 +35,15 @@ module.exports = { // We don't use display names for SFCs 'react/display-name': 'off', // This rule is intended to catch < or > but it's too eager - 'react/no-unescaped-entities': 'off' + 'react/no-unescaped-entities': 'off', + // this rule is deprecated and replaced with label-has-associated-control + 'jsx-a11y/label-has-for': 'off', + 'jsx-a11y/label-has-associated-control': 'error' }, overrides: [ { // Components in x-dash interact with x-engine rather than React - files: [ 'components/**/*.jsx' ], + files: ['components/*/src/**/*.jsx', 'components/*/__tests__/**/*.jsx'], settings: { react: { pragma: 'h', @@ -52,4 +55,4 @@ module.exports = { } } ] -}; +} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a48dd077b..9e20308da 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,12 +1,22 @@ -If this is your first `x-dash` pull request please familiarise yourself with the contribution guide before submitting. - -**TL;DR** - - - Discuss features first - - Update the documentation - - No hacks, experiments or temporary workarounds - - Reviewers are empowered to say no - - Reference other issues - - Update affected stories and snapshots - - Follow the code style - - Decide on a version (major, minor, or patch) +If this is your first `x-dash` pull request please familiarise yourself with the [contribution guide](https://github.com/Financial-Times/x-dash/blob/HEAD/contribution.md) before submitting. + +## If you're creating a component: + +- Add the `Component` label to this Pull Request +- If this will be a long-lived PR, consider using smaller PRs targeting this branch for individual features, so your team can review them without involving x-dash maintainers + - If you're using this workflow, create a Label and a Project for your component and ensure all small PRs are attached to them. Add the Project to the [Components board](https://github.com/Financial-Times/x-dash/projects/4) + - put a link to this Pull Request in the Project description + - set the Project to `Automated kanban with reviews`, but remove the `To Do` column + - If you're not using this workflow, add this Pull Request to the [Components board](https://github.com/Financial-Times/x-dash/projects/4). + +## + +- Discuss features first +- Update the documentation +- **Must** be tested in FT.com and Apps before merge +- No hacks, experiments or temporary workarounds +- Reviewers are empowered to say no +- Reference other issues +- Update affected stories and snapshots +- Follow the code style +- Decide on a version (major, minor, or patch) diff --git a/.github/settings.yml b/.github/settings.yml new file mode 100644 index 000000000..8c831e50d --- /dev/null +++ b/.github/settings.yml @@ -0,0 +1,16 @@ +_extends: github-apps-config-next +branches: + - name: main + protection: + required_pull_request_reviews: + required_approving_review_count: 1 + dismiss_stale_reviews: true + require_code_owner_reviews: false + required_status_checks: + strict: true + contexts: + - 'ci/circleci: test' + enforce_admins: true + restrictions: + users: [] + teams: [] diff --git a/.gitignore b/.gitignore index aed1a305d..78ec717b3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ bower_components npm-debug.log .DS_Store dist +.idea +coverage \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..5e454b3c5 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "printWidth": 110, + "semi": false, + "singleQuote": true, + "bracketSpacing": true, + "arrowParens": "always", + "jsxBracketSameLine": true, + "trailingComma": "none" +} diff --git a/.prettierrc.json b/.prettierrc.json deleted file mode 100644 index c2f098c4c..000000000 --- a/.prettierrc.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "printWidth": 100, - "semi": true, - "singleQuote": true, - "bracketSpacing": true, - "arrowParens": "always", - "jsxBracketSameLine": true -} diff --git a/.snyk b/.snyk new file mode 100644 index 000000000..861428bd4 --- /dev/null +++ b/.snyk @@ -0,0 +1,4 @@ +# Snyk (https://snyk.io) policy file, which patches or ignores known vulnerabilities. +version: v1.13.5 +ignore: {} +patch: {} diff --git a/.storybook/.browserslistrc b/.storybook/.browserslistrc new file mode 100644 index 000000000..a4eb887f0 --- /dev/null +++ b/.storybook/.browserslistrc @@ -0,0 +1,4 @@ +last 2 Chrome versions +last 2 FF versions +last 2 Edge versions +Safari >= 12 diff --git a/tools/x-storybook/.storybook/build-service.js b/.storybook/build-service.js similarity index 55% rename from tools/x-storybook/.storybook/build-service.js rename to .storybook/build-service.js index bbeb441a9..4c8267456 100644 --- a/tools/x-storybook/.storybook/build-service.js +++ b/.storybook/build-service.js @@ -1,23 +1,25 @@ -import React from 'react'; -import { Helmet } from 'react-helmet'; +import React from 'react' +import { Helmet } from 'react-helmet' function buildServiceUrl(deps, type) { - const modules = Object.keys(deps).map((i) => `${i}@${deps[i]}`).join(','); - return `https://www.ft.com/__origami/service/build/v2/bundles/${type}?modules=${modules}`; + const modules = Object.keys(deps) + .map((i) => `${i}@${deps[i]}`) + .join(',') + return `https://www.ft.com/__origami/service/build/v2/bundles/${type}?modules=${modules}` } class BuildService extends React.Component { constructor(props) { - super(props); - this.initialised = []; + super(props) + this.initialised = [] } componentDidUpdate() { if (window.hasOwnProperty('Origami')) { for (const component in Origami) { if (typeof Origami[component].init === 'function') { - const instance = Origami[component].init(); - this.initialised.concat(instance); + const instance = Origami[component].init() + this.initialised.concat(instance) } } } @@ -26,22 +28,22 @@ class BuildService extends React.Component { componentWillUnmount() { this.initialised.forEach((instance) => { if (typeof instance.destroy === 'function') { - instance.destroy(); + instance.destroy() } - }); + }) } render() { - const js = buildServiceUrl(this.props.dependencies, 'js'); - const css = buildServiceUrl(this.props.dependencies, 'css'); + const js = buildServiceUrl(this.props.dependencies, 'js') + const css = buildServiceUrl(this.props.dependencies, 'css') return ( - ); + ) } } -export default BuildService; +export default BuildService diff --git a/.storybook/main.js b/.storybook/main.js new file mode 100644 index 000000000..df6cdb76b --- /dev/null +++ b/.storybook/main.js @@ -0,0 +1,4 @@ +module.exports = { + stories: ['../components/**/storybook/index.jsx'], + addons: ['@storybook/addon-essentials'] +} diff --git a/tools/x-storybook/.storybook/preview-head.html b/.storybook/preview-head.html similarity index 100% rename from tools/x-storybook/.storybook/preview-head.html rename to .storybook/preview-head.html diff --git a/.storybook/static/.gitignore b/.storybook/static/.gitignore new file mode 100644 index 000000000..c96a04f00 --- /dev/null +++ b/.storybook/static/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/.storybook/webpack.config.js b/.storybook/webpack.config.js new file mode 100644 index 000000000..7a3bd3f72 --- /dev/null +++ b/.storybook/webpack.config.js @@ -0,0 +1,92 @@ +// This configuration extends the existing Storybook Webpack config. +// See https://storybook.js.org/configurations/custom-webpack-config/ for more info. + +const path = require('path') +const glob = require('glob') +const fs = require('fs') +const xBabelConfig = require('../packages/x-babel-config') +const xEngine = require('../packages/x-engine/src/webpack') +const CopyPlugin = require('copy-webpack-plugin') +const WritePlugin = require('write-file-webpack-plugin') + +const excludePaths = [/node_modules/, /dist/] + +const cssCopy = fs.readdirSync(path.resolve('components')).reduce((mains, component) => { + const componentPkg = path.resolve('components', component, 'package.json') + + if (fs.existsSync(componentPkg)) { + const pkg = require(componentPkg) + + if (pkg.style) { + const styleResolved = path.resolve('components', component, pkg.style) + + return mains.concat({ + from: styleResolved, + to: path.resolve(__dirname, 'static/components', path.basename(pkg.name), pkg.style) + }) + } + } + + return mains +}, []) + +module.exports = ({ config }) => { + // HACK: extend existing JS rule to ensure all dependencies are correctly ignored + // from Babel transpilation. + // https://github.com/storybooks/storybook/issues/3346#issuecomment-459439438 + const jsRule = config.module.rules.find((rule) => rule.test.test('.jsx')) + jsRule.exclude = excludePaths + + // HACK: Instruct Babel to check module type before injecting Core JS polyfills + // https://github.com/i-like-robots/broken-webpack-bundle-test-case + const babelConfig = jsRule.use.find(({ loader }) => loader === 'babel-loader') || { + options: { presets: [] } + } + babelConfig.options.sourceType = 'unambiguous' + + // Override the Babel configuration for all x- components with our own + babelConfig.options.overrides = [ + { + test: /\/components\/x-[^\/]+\/src\//, + ...xBabelConfig() + } + ] + + // HACK: there is a bug in babel-plugin-minify-simplify which cannot + // handle how Babel transpiles restful destructing assignment so remove it. + // e.g. const { foo, ...qux } = { foo: 0, bar: 1, baz: 2 } + babelConfig.options.presets = babelConfig.options.presets.filter((preset) => { + const name = Array.isArray(preset) ? preset[0] : preset + return name.includes('babel-preset-minify') === false + }) + + config.module.rules.push({ + test: /\.(scss|sass)$/, + use: [ + { + loader: require.resolve('style-loader') + }, + { + loader: require.resolve('css-loader'), + options: { + url: false, + import: false, + modules: true + } + }, + { + loader: require.resolve('sass-loader'), + options: { + includePaths: glob.sync('./components/*/bower_components', { absolute: true }) + } + } + ] + }) + + // HACK: Ensure we only bundle one instance of React + config.resolve.alias.react = require.resolve('react') + + config.plugins.push(xEngine(), new CopyPlugin(cssCopy), new WritePlugin()) + + return config +} diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000..5b3748a6b --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,12 @@ +# See https://help.github.com/articles/about-codeowners/ for more information about this file. + +* @financial-times/platforms + +# component ownership + +components/x-privacy-manager @Financial-Times/ads +components/x-teaser @Financial-Times/content-discovery +components/x-teaser-timeline @Financial-Times/content-discovery +components/x-live-blog-post @Financial-Times/content-innovation +components/x-live-blog-wrapper @Financial-Times/content-innovation +components/x-gift-article @Financial-Times/accounts diff --git a/__mocks__/styleMock.js b/__mocks__/styleMock.js new file mode 100644 index 000000000..4ba52ba2c --- /dev/null +++ b/__mocks__/styleMock.js @@ -0,0 +1 @@ +module.exports = {} diff --git a/__tests__/snapshots.test.js b/__tests__/snapshots.test.js deleted file mode 100644 index 518ef658e..000000000 --- a/__tests__/snapshots.test.js +++ /dev/null @@ -1,35 +0,0 @@ -const renderer = require('react-test-renderer'); -const fs = require('fs'); -const path = require('path'); -const glob = require('glob'); - -const { workspaces } = require('../package.json'); - -const packagesGlob = workspaces.length > 1 - ? `{${workspaces.join(',')}}` - : workspaces[0]; - -const packageDirs = glob.sync(packagesGlob); - -for(const pkg of packageDirs) { - const pkgDir = path.resolve(pkg); - const storiesDir = path.resolve(pkgDir, 'stories'); - - if(fs.existsSync(storiesDir)) { - const { package: pkg, stories, component } = require(storiesDir); - const { presets = {default: {}} } = require(pkgDir); - const name = path.basename(pkg.name); - - describe(pkg.name, () => { - for (const { title, data } of stories) { - for (const [ preset, options ] of Object.entries(presets)) { - it(`renders a ${preset} ${title} ${name}`, () => { - const props = { ...data, ...options }; - const tree = renderer.create(component(props)).toJSON(); - expect(tree).toMatchSnapshot(); - }); - } - } - }); - } -} diff --git a/app.json b/app.json new file mode 100644 index 000000000..e28d05fb9 --- /dev/null +++ b/app.json @@ -0,0 +1,18 @@ +{ + "env": { + "NPM_CONFIG_PRODUCTION": { + "description": "don't prune devDependencies", + "value": "false" + } + }, + "formation": { + "web": { + "quantity": 1, + "size": "Standard-1X" + } + }, + "buildpacks": [ + {"url": "heroku/nodejs"}, + {"url": "https://github.com/heroku/heroku-buildpack-static.git"} + ] +} diff --git a/changelog.md b/changelog.md deleted file mode 100644 index 29a8b9726..000000000 --- a/changelog.md +++ /dev/null @@ -1,49 +0,0 @@ -# Changelog - -## v1 - -### v1.0.0-beta.9 - -- Refactors x-teaser to make the teaser standfirst a link (#268) - -### v1.0.0-beta.8 - -- Updates x-teaser for cross-compatibility with o-teaser v2 and v3 (#256) - -### v1.0.0-beta.7 - -- Added new x-node-jsx package to enable direct usage of .jsx files (#217) - -### v1.0.0-beta.6 - -- Refactors wrapping logic to enable re-wrapping a component with x-interaction (#214) - -### v1.0.0-beta.5 - -- Adds audio to ContentTypes type in TypeScript declaration file in x-teaser (#209) - -### v1.0.0-beta.4 - -- Add support for an isPodcast identifier to x-teaser (#211) - -### v1.0.0-beta.3 - -- Removes whitespace output around text output in x-teaser (#146) -- Refactors headshot prop to accept a string value in x-teaser (#153) -- Refactors theme indicator and theme rule to enable explicit overrides in x-teaser (#154) -- Adds parentTheme prop to x-teaser (#154) - - -### v1.0.0-beta.2 - -- Adds support for fragments to x-engine (#134) -- Removes obsolete syndication props from x-teaser (#137) -- Adds support for configurable image lazy loading to x-teaser (#135) -- Refactors rendering of title prop to avoid stringification in x-teaser (#139) -- Updates presets to enable relative time by default in x-teaser (#142) -- Adds XXL image size prop to x-teaser (#143) -- Refactors teaser rulesets to avoid minification issues in x-teaser (#144) - -### v1.0.0-beta.1 - -- Initial public release of x-engine, x-interaction, x-handlebars, and x-teaser diff --git a/components/x-article-save-button/stories/default.js b/components/x-article-save-button/stories/default.js deleted file mode 100644 index 66d6159c5..000000000 --- a/components/x-article-save-button/stories/default.js +++ /dev/null @@ -1,17 +0,0 @@ -exports.title = 'ArticleSaveButton'; - -const data = { - contentId: '0000-0000-0000-0000', - contentTitle: 'UK crime agency steps up assault on Russian dirty money', - csrfToken: 'dummy-token', - saved: false, - trackableId: 'trackable-id' -}; - -exports.data = data; - -exports.knobs = Object.keys(data); - -// This reference is only required for hot module loading in development -// -exports.m = module; diff --git a/components/x-article-save-button/stories/index.js b/components/x-article-save-button/stories/index.js deleted file mode 100644 index 495147ecb..000000000 --- a/components/x-article-save-button/stories/index.js +++ /dev/null @@ -1,14 +0,0 @@ -const { ArticleSaveButton } = require('../'); - -exports.component = ArticleSaveButton; -exports.package = require('../package.json'); -exports.knobs = require('./knobs'); -exports.dependencies = { - "o-colors": "^4.7.2", - "o-icons": "^5.7.1", - 'o-normalise': '^1.6.0', - 'o-typography': '^5.5.0' -}; -exports.stories = [ - require('./default') -]; diff --git a/components/x-article-save-button/stories/knobs.js b/components/x-article-save-button/stories/knobs.js deleted file mode 100644 index c20330588..000000000 --- a/components/x-article-save-button/stories/knobs.js +++ /dev/null @@ -1,15 +0,0 @@ -module.exports = (data, { text, boolean }) => { - - const Groups = { - Form: 'Form', - Content: 'Content' - }; - - return { - csrfToken: text('CSRF token', data.csrfToken, Groups.Form), - contentId: text('Content ID', data.contentId, Groups.Content), - contentTitle: text('Content title', data.contentTitle, Groups.Content), - trackableId: text('Trackable ID', data.trackableId), - saved: boolean('Saved', data.saved, Groups.Content), - }; -}; diff --git a/components/x-article-save-button/storybook/index.jsx b/components/x-article-save-button/storybook/index.jsx new file mode 100644 index 000000000..f16ec51fd --- /dev/null +++ b/components/x-article-save-button/storybook/index.jsx @@ -0,0 +1,37 @@ +import React from 'react' +import { ArticleSaveButton } from '../src/ArticleSaveButton' +import BuildService from '../../../.storybook/build-service' + +// Set up basic document styling using the Origami build service +const dependencies = { + 'o-fonts': '^3.0.0' +} + +export default { + title: 'x-article-save-button' +} + +export const _ArticleSaveButton = (args) => { + return ( +
+ + +
+ ) +} + +_ArticleSaveButton.args = { + saved: false, + csrfToken: 'dummy-token', + trackableId: 'trackable-id', + contentId: '0000-0000-0000-0000', + contentTitle: 'UK crime agency steps up assault on Russian dirty money' +} + +_ArticleSaveButton.argTypes = { + csrfToken: { name: 'CSRF token' }, + contentId: { name: 'Content ID' }, + contentTitle: { name: 'Content title' }, + trackableId: { name: 'Trackable ID' }, + saved: { name: 'Saved' } +} diff --git a/components/x-follow-button/.bowerrc b/components/x-follow-button/.bowerrc new file mode 100644 index 000000000..59e9a5925 --- /dev/null +++ b/components/x-follow-button/.bowerrc @@ -0,0 +1,8 @@ +{ + "registry": { + "search": [ + "https://origami-bower-registry.ft.com", + "https://registry.bower.io" + ] + } +} diff --git a/components/x-follow-button/__tests__/x-follow-button.test.jsx b/components/x-follow-button/__tests__/x-follow-button.test.jsx new file mode 100644 index 000000000..b4b0337e6 --- /dev/null +++ b/components/x-follow-button/__tests__/x-follow-button.test.jsx @@ -0,0 +1,174 @@ +const { h } = require('@financial-times/x-engine') +const { mount } = require('@financial-times/x-test-utils/enzyme') + +import { FollowButton } from '../src/FollowButton' + +describe('x-follow-button', () => { + describe('concept name', () => { + it('when conceptNameAsButtonText prop is true, and the topic name is provided, the button is named by this name', () => { + const subject = mount( + + ) + expect(subject.find('button').text()).toEqual('dummy concept name') + }) + + it('when conceptNameAsButtonText prop is false, the button has a default name', () => { + const subject = mount( + + ) + expect(subject.find('button').text()).toEqual('Add to myFT') + }) + + it('when conceptNameAsButtonText prop is true, and the topic name is not provided, the button has a default name', () => { + const subject = mount() + expect(subject.find('button').text()).toEqual('Add to myFT') + }) + + it('when conceptNameAsButtonText prop is not provided, the button has a default name', () => { + const subject = mount() + expect(subject.find('button').text()).toEqual('Add to myFT') + }) + }) + describe('conceptId prop', () => { + it('assigns conceptId prop to data-concept-id attribute of the button', async () => { + const subject = mount() + expect(subject.find('button').prop('data-concept-id')).toEqual('dummy-id') + }) + + it('assigns conceptId prop to data-concept-id attribute of the form', async () => { + const subject = mount() + expect(subject.find('form').prop('data-concept-id')).toEqual('dummy-id') + }) + }) + + describe('form action', () => { + it('assigns follow-plus-digest-email put action if followPlusDigestEmail is true', async () => { + const subject = mount() + expect(subject.find('form').prop('action')).toEqual( + '/__myft/api/core/follow-plus-digest-email/dummy-id?method=put' + ) + }) + + it('assigns followed/concept delete action if isFollowed is true', async () => { + const subject = mount() + expect(subject.find('form').prop('action')).toEqual( + '/__myft/api/core/followed/concept/dummy-id?method=delete' + ) + }) + + it('assigns followed/concept put action if isFollowed and followPlusDigestEmail are not passed', async () => { + const subject = mount() + expect(subject.find('form').prop('action')).toEqual( + '/__myft/api/core/followed/concept/dummy-id?method=put' + ) + }) + }) + + describe('isFollowed', () => { + describe('when true', () => { + it('button text is "Added"', () => { + const subject = mount() + expect(subject.find('button').text()).toEqual('Added') + }) + + it('button aria-pressed is "true"', () => { + const subject = mount() + expect(subject.find('button').prop('aria-pressed')).toEqual('true') + }) + + it('button title is "Remove ConceptName from myFT"', () => { + const subject = mount() + expect(subject.find('button').prop('title')).toEqual('Remove ConceptName from myFT') + }) + + it('button aria-label is "Remove conceptName from myFT"', () => { + const subject = mount() + expect(subject.find('button').prop('aria-label')).toEqual('Remove ConceptName from myFT') + }) + }) + + describe('when false', () => { + it('button text is "Add to myFT"', () => { + const subject = mount() + expect(subject.find('button').text()).toEqual('Add to myFT') + }) + + it('button aria-pressed is "false"', () => { + const subject = mount() + expect(subject.find('button').prop('aria-pressed')).toEqual('false') + }) + + it('button title is "Add ConceptName to myFT"', () => { + const subject = mount() + expect(subject.find('button').prop('title')).toEqual('Add ConceptName to myFT') + }) + + it('button aria-label is "Add ConceptName to myFT"', () => { + const subject = mount() + expect(subject.find('button').prop('aria-label')).toEqual('Add ConceptName to myFT') + }) + }) + }) + + describe('followPlusDigestEmail', () => { + describe('when true', () => { + it('form has data-myft-ui-variant property which is true', () => { + const subject = mount() + expect(subject.find('form').prop('data-myft-ui-variant')).toEqual(true) + }) + + it('button has data-trackable-context-messaging property which is add-to-myft-plus-digest-button', () => { + const subject = mount() + expect(subject.find('button').prop('data-trackable-context-messaging')).toEqual( + 'add-to-myft-plus-digest-button' + ) + }) + }) + + describe('when false', () => { + it('form has data-myft-ui-variant property which is true', () => { + const subject = mount() + expect(subject.find('form').prop('data-myft-ui-variant')).toEqual(undefined) + }) + + it('button has data-trackable-context-messaging property which is add-to-myft-plus-digest-button', () => { + const subject = mount() + expect(subject.find('button').prop('data-trackable-context-messaging')).toEqual(null) + }) + }) + }) + + describe('form properties', () => { + it('method = GET', () => { + const subject = mount() + expect(subject.find('form').prop('method')).toEqual('GET') + }) + }) + + describe('button properties', () => { + it('data-trackable="follow"', () => { + const subject = mount() + expect(subject.find('button').prop('data-trackable')).toEqual('follow') + }) + + it('type="submit"', () => { + const subject = mount() + expect(subject.find('button').prop('type')).toEqual('submit') + }) + }) + + describe('csrf token', () => { + it('if passed creates an invisible input field', () => { + const subject = mount() + expect(subject.find('input').prop('value')).toEqual('dummyToken') + expect(subject.find('input').prop('type')).toEqual('hidden') + expect(subject.find('input').prop('name')).toEqual('token') + expect(subject.find('input').prop('data-myft-csrf-token')).toEqual(true) + }) + + it('if not passed an invisible input field is not created', () => { + const subject = mount() + expect(subject.find('input')).toEqual({}) + }) + }) +}) diff --git a/components/x-follow-button/bower.json b/components/x-follow-button/bower.json new file mode 100644 index 000000000..48cc79ee1 --- /dev/null +++ b/components/x-follow-button/bower.json @@ -0,0 +1,10 @@ +{ + "name": "x-follow-button", + "private": true, + "main": "dist/FollowButton.es5.js", + "dependencies": { + "o-colors": "^5.0.3", + "o-icons": "^6.0.0", + "o-typography": "^6.1.0" + } +} diff --git a/components/x-follow-button/package.json b/components/x-follow-button/package.json new file mode 100644 index 000000000..78abb478d --- /dev/null +++ b/components/x-follow-button/package.json @@ -0,0 +1,30 @@ +{ + "name": "@financial-times/x-follow-button", + "version": "1.0.0", + "description": "", + "main": "dist/FollowButton.cjs.js", + "style": "dist/FollowButton.css", + "browser": "dist/FollowButton.es5.js", + "module": "dist/FollowButton.esm.js", + "scripts": { + "prepare": "bower install && npm run build", + "build": "node rollup.js", + "start": "node rollup.js --watch" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@financial-times/x-rollup": "file:../../packages/x-rollup", + "@financial-times/x-test-utils": "file:../../packages/x-test-utils", + "bower": "^1.7.9", + "node-sass": "^4.9.2" + }, + "peerDependencies": { + "@financial-times/x-engine": "file:../../packages/x-engine" + }, + "dependencies": { + "@financial-times/x-engine": "file:../../packages/x-engine", + "classnames": "^2.2.6" + } +} diff --git a/components/x-follow-button/readme.md b/components/x-follow-button/readme.md new file mode 100644 index 000000000..838f36ecf --- /dev/null +++ b/components/x-follow-button/readme.md @@ -0,0 +1,43 @@ +# x-follow-button + +This module provides a template for myFT follow topic button, and is intended to replace the legacy handlebars component in [n-myft-ui](https://github.com/Financial-Times/n-myft-ui/tree/HEAD/components/follow-button). + +## Installation + +```bash +npm install --save @financial-times/x-follow-button +``` + +## Props + +(Some of the properties don't influence the way button looks or acts, but can be used for e.g. client-side Javascript in the apps). + +Feature | Type | Required | Default value | Description +----------------------------|---------|----------|----------------|--------------- +`conceptId` | String | yes | none | UUID of the concept +`conceptName` | String | yes | none | Name of the concept +`conceptNameAsButtonText` | Boolean | no | `false` | If true will use the concept name as the button text, otherwise will default to "Add to MyFT" or "Remove from MyFT" (depending on isFollowed prop). +`isFollowed` | Boolean | no | `false` | Whether the concept is followed or not. +`csrfToken` | String | no | none | CSRF token (will be included in a hidden form field). +`variant` | String | no | `standard` | One of `standard`, `inverse`, `opinion` or `monochrome`. Other values will be ignored. +`followPlusDigestEmail` | Boolean | no | `false` | Whether following the topic should also subscribe to the digest. + +## Client side behaviour + +For users with JavaScript enabled, the default form submit action is prevented, and a custom event (named 'x-follow-button') will be dispatched on the form element. + +This custom event will contain the following in its `detail` object: + +Property | Value +-------------------|----------------- +`action` | `add` or `remove` +`actorType` | `user` +`relationshipName` | `followed` +`subjectType` | `concept` +`subjectId` | the value of the `conceptId` prop +`token` | the value of the `csrfToken` prop + +It is up to the consumer of this component to listen for the `x-follow-button` event, and use this data, along with the user's ID, and carry out the appropriate action. + +For example, if using `next-myft-client` to carry out the follow/unfollow action, n-myft-ui provides a x-button-interaction component for this: +https://github.com/Financial-Times/n-myft-ui/blob/HEAD/components/x-button-integration/index.js diff --git a/components/x-follow-button/rollup.js b/components/x-follow-button/rollup.js new file mode 100644 index 000000000..9347ae8b4 --- /dev/null +++ b/components/x-follow-button/rollup.js @@ -0,0 +1,4 @@ +const xRollup = require('@financial-times/x-rollup') +const pkg = require('./package.json') + +xRollup({ input: './src/FollowButton.jsx', pkg }) diff --git a/components/x-follow-button/src/FollowButton.jsx b/components/x-follow-button/src/FollowButton.jsx new file mode 100644 index 000000000..9c5d5ffb2 --- /dev/null +++ b/components/x-follow-button/src/FollowButton.jsx @@ -0,0 +1,79 @@ +import { h } from '@financial-times/x-engine' +import classNames from 'classnames' +import styles from './styles/main.scss' + +export const FollowButton = (props) => { + const { + conceptNameAsButtonText = false, + conceptId, + conceptName, + isFollowed, + csrfToken, + followPlusDigestEmail, + onSubmit, + variant + } = props + const VARIANTS = ['standard', 'inverse', 'opinion', 'monochrome'] + + const getFormAction = () => { + if (followPlusDigestEmail) { + return `/__myft/api/core/follow-plus-digest-email/${conceptId}?method=put` + } else if (isFollowed) { + return `/__myft/api/core/followed/concept/${conceptId}?method=delete` + } else { + return `/__myft/api/core/followed/concept/${conceptId}?method=put` + } + } + + const getButtonText = () => { + if (conceptNameAsButtonText && conceptName) { + return conceptName + } + + return isFollowed ? 'Added' : 'Add to myFT' + } + + const getAccessibleText = () => + isFollowed ? `Remove ${conceptName} from myFT` : `Add ${conceptName} to myFT` + + return ( +
{ + event.preventDefault() + const detail = { + action: isFollowed ? 'remove' : 'add', + actorType: 'user', + actorId: null, // myft client sets to user id from session + relationshipName: 'followed', + subjectType: 'concept', + subjectId: conceptId, + token: csrfToken + } + + if (typeof onSubmit === 'function') { + onSubmit(detail) + } + + event.target.dispatchEvent(new CustomEvent('x-follow-button', { bubbles: true, detail })) + }} + {...(followPlusDigestEmail ? { 'data-myft-ui-variant': true } : null)}> + {csrfToken && } +
+ +
+ this.giftArticleActions = actions} /> +
+ + } +} +``` + +For more information about triggering actions, see the [x-interaction documentation][interaction]. + +All `x-` components are designed to be compatible with a variety of runtimes, not just React. Check out the [`x-engine`][engine] documentation for a list of recommended libraries and frameworks. + +[jsx-wtf]: https://jasonformat.com/wtf-is-jsx/ +[interaction]: /components/x-interaction#triggering-actions-externally +[engine]: https://github.com/Financial-Times/x-dash/tree/HEAD/packages/x-engine + +### Properties + +Property | Type | Required | Note +--------------------------|---------|----------|---- +`isFreeArticle` | Boolean | yes | Only non gift form is displayed when this value is `true`. +`article` | Object | yes | Must contain `id`, `title` and `url` properties +`showMobileShareLinks` | Boolean | no | For ft.com on mobile sharing. +`nativeShare` | Boolean | no | This is a property for App to display Native Sharing. +`apiProtocol` | String | no | The protocol to use when making requests to the gift article and URL shortening services. Ignored if `apiDomain` is not set. +`apiDomain` | String | no | The domain to use when making requests to the gift article and URL shortening services. + +### +`isArticleSharingUxUpdates` boolean has been added as part of ACC-749 to enable AB testing of the impact of minor UX improvements to x-gift-article. Once AB testing is done, and decision to keep / remove has been made, the changes made in https://github.com/Financial-Times/x-dash/pull/579 need to be ditched or baked in as default. diff --git a/components/x-gift-article/rollup.js b/components/x-gift-article/rollup.js new file mode 100644 index 000000000..8973a9a58 --- /dev/null +++ b/components/x-gift-article/rollup.js @@ -0,0 +1,4 @@ +const xRollup = require('@financial-times/x-rollup') +const pkg = require('./package.json') + +xRollup({ input: './src/GiftArticle.jsx', pkg }) diff --git a/components/x-gift-article/src/Buttons.jsx b/components/x-gift-article/src/Buttons.jsx new file mode 100644 index 000000000..77d028e39 --- /dev/null +++ b/components/x-gift-article/src/Buttons.jsx @@ -0,0 +1,65 @@ +import { h } from '@financial-times/x-engine' +import { ShareType } from './lib/constants' +import styles from './GiftArticle.scss' + +const ButtonsClassName = styles.buttons + +const ButtonClassNames = styles['buttonBaseStyle'] + +const ButtonWithGapClassNames = [ButtonClassNames, 'js-copy-link', styles['button--with-gap']].join(' ') + +export default ({ + shareType, + isGiftUrlCreated, + mailtoUrl, + showCopyButton, + nativeShare, + actions, + giftCredits +}) => { + if (isGiftUrlCreated || shareType === ShareType.nonGift) { + if (nativeShare) { + return ( +
+ +
+ ) + } + + return ( +
+ {showCopyButton && ( + + )} + + Email link to Share this article + +
+ ) + } + + return ( +
+ +
+ ) +} diff --git a/components/x-gift-article/src/CopyConfirmation.jsx b/components/x-gift-article/src/CopyConfirmation.jsx new file mode 100644 index 000000000..9b2256a6c --- /dev/null +++ b/components/x-gift-article/src/CopyConfirmation.jsx @@ -0,0 +1,34 @@ +import { h } from '@financial-times/x-engine' +import styles from './GiftArticle.scss' + +const confirmationClassNames = [ + styles['o-message'], + styles['o-message--alert'], + + styles['o-message--success'], + styles['copy-confirmation'] +].join(' ') + +export default ({ hideCopyConfirmation, isArticleSharingUxUpdates }) => ( +
+
+
+

+ {isArticleSharingUxUpdates ? ( + The link has been copied to your clipboard + ) : ( + + The link has been copied to your clipboard + + )} +

+
+ + +
+
+) diff --git a/components/x-gift-article/src/Form.jsx b/components/x-gift-article/src/Form.jsx new file mode 100644 index 000000000..a2a8b515f --- /dev/null +++ b/components/x-gift-article/src/Form.jsx @@ -0,0 +1,37 @@ +import { h } from '@financial-times/x-engine' +import Title from './Title' +import RadioButtonsSection from './RadioButtonsSection' +import UrlSection from './UrlSection' +import MobileShareButtons from './MobileShareButtons' +import CopyConfirmation from './CopyConfirmation' +import styles from './GiftArticle.scss' + +export default (props) => ( +
+
+
+ + + {!props.isFreeArticle && ( + <RadioButtonsSection + shareType={props.shareType} + isArticleSharingUxUpdates={props.isArticleSharingUxUpdates} + showGiftUrlSection={props.actions.showGiftUrlSection} + showNonGiftUrlSection={props.actions.showNonGiftUrlSection} + /> + )} + + <UrlSection {...props} /> + </div> + </form> + + {props.showCopyConfirmation && ( + <CopyConfirmation + hideCopyConfirmation={props.actions.hideCopyConfirmation} + isArticleSharingUxUpdates={props.isArticleSharingUxUpdates} + /> + )} + + {props.showMobileShareLinks && <MobileShareButtons mobileShareLinks={props.mobileShareLinks} />} + </div> +) diff --git a/components/x-gift-article/src/GiftArticle.jsx b/components/x-gift-article/src/GiftArticle.jsx new file mode 100644 index 000000000..38c4232de --- /dev/null +++ b/components/x-gift-article/src/GiftArticle.jsx @@ -0,0 +1,179 @@ +import { h } from '@financial-times/x-engine' +import { withActions } from '@financial-times/x-interaction' + +import Loading from './Loading' +import Form from './Form' + +import ApiClient from './lib/api' +import { copyToClipboard, createMailtoUrl } from './lib/share-link-actions' +import tracking from './lib/tracking' +import * as updaters from './lib/updaters' + +const isCopySupported = + typeof document !== 'undefined' && document.queryCommandSupported && document.queryCommandSupported('copy') + +const todayDate = new Date() +const monthNow = `${updaters.monthNames[todayDate.getMonth()]}` + +const withGiftFormActions = withActions( + (initialProps) => { + const api = new ApiClient({ + protocol: initialProps.apiProtocol, + domain: initialProps.apiDomain + }) + + return { + showGiftUrlSection() { + return updaters.showGiftUrlSection + }, + + showNonGiftUrlSection() { + return async (state) => { + const update = updaters.showNonGiftUrlSection(state) + + if (!state.isNonGiftUrlShortened) { + const { url, isShortened } = await api.getShorterUrl(state.urls.nonGift) + + if (isShortened) { + Object.assign(update, updaters.setShortenedNonGiftUrl(url)(state)) + } + } + + return update + } + }, + + async createGiftUrl() { + const { redemptionUrl, redemptionLimit } = await api.getGiftUrl(initialProps.article.id) + + if (redemptionUrl) { + const { url, isShortened } = await api.getShorterUrl(redemptionUrl) + tracking.createGiftLink(url, redemptionUrl) + + return updaters.setGiftUrl(url, redemptionLimit, isShortened) + } else { + // TODO do something + } + }, + + copyGiftUrl(event) { + copyToClipboard(event) + + return (state) => { + const giftUrl = state.urls.gift + tracking.copyLink('giftLink', giftUrl) + + return { showCopyConfirmation: true } + } + }, + + copyNonGiftUrl(event) { + copyToClipboard(event) + + return (state) => { + const nonGiftUrl = state.urls.nonGift + tracking.copyLink('nonGiftLink', nonGiftUrl) + + return { showCopyConfirmation: true } + } + }, + + emailGiftUrl() { + return (state) => { + tracking.emailLink('giftLink', state.urls.gift) + } + }, + + emailNonGiftUrl() { + return (state) => { + tracking.emailLink('nonGiftLink', state.urls.nonGift) + } + }, + + hideCopyConfirmation() { + return { showCopyConfirmation: false } + }, + + shareByNativeShare() { + throw new Error(`shareByNativeShare should be implemented by x-gift-article's consumers`) + }, + + activate() { + return async (state) => { + if (initialProps.isFreeArticle) { + const { url, isShortened } = await api.getShorterUrl(state.urls.nonGift) + + if (isShortened) { + return updaters.setShortenedNonGiftUrl(url)(state) + } + } else { + const { giftCredits, monthlyAllowance, nextRenewalDate } = await api.getGiftArticleAllowance() + + // avoid to use giftCredits >= 0 because it returns true when null and "" + if (giftCredits > 0 || giftCredits === 0) { + return updaters.setAllowance(giftCredits, monthlyAllowance, nextRenewalDate) + } else { + return { invalidResponseFromApi: true } + } + } + } + } + } + }, + (props) => { + const initialState = { + title: 'Share this article', + giftCredits: undefined, + monthlyAllowance: undefined, + monthNow: monthNow, + showCopyButton: isCopySupported, + isGiftUrlCreated: false, + isGiftUrlShortened: false, + isNonGiftUrlShortened: false, + isArticleSharingUxUpdates: false, + + urls: { + dummy: 'https://on.ft.com/gift_link', + gift: undefined, + nonGift: `${props.article.url}?shareType=nongift` + }, + + mailtoUrls: { + gift: undefined, + nonGift: createMailtoUrl(props.article.title, `${props.article.url}?shareType=nongift`) + }, + + mobileShareLinks: props.showMobileShareLinks + ? { + facebook: `http://www.facebook.com/sharer.php?u=${encodeURIComponent( + props.article.url + )}&t=${encodeURIComponent(props.article.title)}`, + twitter: `https://twitter.com/intent/tweet?url=${encodeURIComponent( + props.article.url + )}&text=${encodeURIComponent(props.article.title)}&via=financialtimes`, + linkedin: `http://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent( + props.article.url + )}&title=${encodeURIComponent(props.article.title)}&source=Financial+Times`, + whatsapp: `whatsapp://send?text=${encodeURIComponent( + props.article.title + )}%20-%20${encodeURIComponent(props.article.url)}` + } + : undefined + } + + const expandedProps = Object.assign({}, props, initialState) + const sectionProps = props.isFreeArticle + ? updaters.showNonGiftUrlSection(expandedProps) + : updaters.showGiftUrlSection(expandedProps) + + return Object.assign(initialState, sectionProps) + } +) + +const BaseGiftArticle = (props) => { + return props.isLoading ? <Loading /> : <Form {...props} /> +} + +const GiftArticle = withGiftFormActions(BaseGiftArticle) + +export { GiftArticle } diff --git a/components/x-gift-article/src/GiftArticle.scss b/components/x-gift-article/src/GiftArticle.scss new file mode 100644 index 000000000..e3a0f2d36 --- /dev/null +++ b/components/x-gift-article/src/GiftArticle.scss @@ -0,0 +1,141 @@ +@import './lib/variables'; +@import 'o-normalise/main'; + +$o-buttons-is-silent: true; +@import 'o-buttons/main'; + +$o-loading-is-silent: true; +@import 'o-loading/main'; + +$o-forms-is-silent: true; +@import 'o-forms/main'; + +$o-message-is-silent: true; +@import 'o-message/main'; + +$o-typography-is-silent: true; +@import 'o-typography/main'; + +.container { + @include oTypographySans; + strong { + font-weight: 600; + } +} + + +.share-form { + max-width: none; + padding: 0; + margin: 0; + + @include oForms($opts: ( + 'elements': ('text', 'radio-round'), + 'features': ('inline', 'disabled') + )); + + .radio-button-section { + margin-bottom: 12px; + } + + .share-option-title { + @include oNormaliseVisuallyHidden(); + } + + .share-option-title { + @include oNormaliseVisuallyHidden(); + } + + .share-option-title { + @include oNormaliseVisuallyHidden(); + } + + .share-option-title { + @include oNormaliseVisuallyHidden(); + } + + .o-forms-field{ + @media only screen and (min-width: 600px) { + label[for$="Link"]{ + margin-bottom: 0; + } + } + } +} + +@media only screen and (min-width: 600px) { + .url-section { + display: grid; + grid-template-columns: auto min-content; + grid-template-rows: auto auto; + grid-template-areas: "share-url buttons" + "message message"; + grid-column-gap: 20px; + } +} + +.title { + @include oTypographyHeading(4); + +} + +.url-input { + grid-area: share-url; + max-width: none; +} + +.copy-confirmation { + margin-top: 8px; +} + +@include oMessage($opts: ( + 'types': ('alert'), + 'states': ('success'), +)); + +.message { + grid-area: message; + font-size: 16px; + margin-top: 12px; +} + +.buttonBaseStyle { + @include oButtonsContent($opts: ('type': 'primary', 'size':'big')); +} + +.buttons { + grid-area: buttons; + text-align: right; + white-space: nowrap; + margin-top: 12px; +} + +.button--with-gap { + margin-right: 5px; +} + +.loading-spinner { + @include oLoading($opts: ('themes':('dark'), 'sizes':('large'))); +} + +.loading-spinner__container { + display: flex; + justify-content: center; + align-items: center; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0,0,0,0); + border: 0; +} diff --git a/components/x-gift-article/src/Loading.jsx b/components/x-gift-article/src/Loading.jsx new file mode 100644 index 000000000..423a987a2 --- /dev/null +++ b/components/x-gift-article/src/Loading.jsx @@ -0,0 +1,8 @@ +import { h } from '@financial-times/x-engine' +import styles from './GiftArticle.scss' + +export default () => ( + <div className={styles['loading-spinner__container']}> + <div className={styles['loading-spinner']}></div> + </div> +) diff --git a/components/x-gift-article/src/Message.jsx b/components/x-gift-article/src/Message.jsx new file mode 100644 index 000000000..156c75c1e --- /dev/null +++ b/components/x-gift-article/src/Message.jsx @@ -0,0 +1,95 @@ +import { h } from '@financial-times/x-engine' +import { ShareType } from './lib/constants' +import styles from './GiftArticle.scss' + +const messageClassName = styles.message + +export default ({ + shareType, + isGiftUrlCreated, + isFreeArticle, + giftCredits, + monthlyAllowance, + nextRenewalDateText, + redemptionLimit, + invalidResponseFromApi, + isArticleSharingUxUpdates +}) => { + if (isArticleSharingUxUpdates) { + if (isFreeArticle) { + return null + } + + if (shareType === ShareType.gift) { + if (giftCredits === 0) { + return ( + <div className={messageClassName}> + You’ve used all your <strong>gift article credits</strong> + <br /> + You’ll get your next {monthlyAllowance} on <strong>{nextRenewalDateText}</strong> + </div> + ) + } + + if (invalidResponseFromApi) { + return <div className={messageClassName}>Unable to fetch gift credits. Please try again later</div> + } + + return ( + <div className={messageClassName}> + A gift link can be opened up to <strong>{redemptionLimit ? redemptionLimit : 3} times</strong> + </div> + ) + } + + if (shareType === ShareType.nonGift) { + return <div className={messageClassName}>This link can only be read by existing subscribers</div> + } + } + + if (isFreeArticle) { + return ( + <div className={messageClassName}> + This article is currently <strong>free</strong> for anyone to read + </div> + ) + } + + if (shareType === ShareType.gift) { + if (giftCredits === 0) { + return ( + <div className={messageClassName}> + You’ve used all your <strong>gift article credits</strong> + <br /> + You’ll get your next {monthlyAllowance} on <strong>{nextRenewalDateText}</strong> + </div> + ) + } + + if (isGiftUrlCreated) { + return ( + <div className={messageClassName}> + This link can be opened up to {redemptionLimit} times and is valid for 90 days + </div> + ) + } + + if (invalidResponseFromApi) { + return <div className={messageClassName}>Unable to fetch gift credits. Please try again later</div> + } + + return ( + <div className={messageClassName}> + You have{' '} + <strong> + {giftCredits} gift article {giftCredits === 1 ? 'credit' : 'credits'} + </strong>{' '} + left this month + </div> + ) + } + + if (shareType === ShareType.nonGift) { + return <div className={messageClassName}>This link can only be read by existing subscribers</div> + } +} diff --git a/components/x-gift-article/src/MobileShareButtons.jsx b/components/x-gift-article/src/MobileShareButtons.jsx new file mode 100644 index 000000000..0a010edb1 --- /dev/null +++ b/components/x-gift-article/src/MobileShareButtons.jsx @@ -0,0 +1,44 @@ +import { h } from '@financial-times/x-engine' +import Title from './Title' + +import styles from './MobileShareButtons.scss' + +export default ({ mobileShareLinks }) => ( + <div className={styles.container}> + <Title title={'Share on Social'} /> + <div className={styles['container-inner']}> + <span className={styles.button} data-share="facebook"> + <a + className={styles.facebook} + rel="noopener" + href={mobileShareLinks.facebook} + data-trackable="facebook"> + Facebook <span className={styles['hidden-button-text']}>(opens new window)</span> + </a> + </span> + <span className={styles.button} data-share="twitter"> + <a className={styles.twitter} rel="noopener" href={mobileShareLinks.twitter} data-trackable="twitter"> + Twitter <span className={styles['hidden-button-text']}>(opens new window)</span> + </a> + </span> + <span className={styles.button} data-share="linkedin"> + <a + className={styles.linkedin} + rel="noopener" + href={mobileShareLinks.linkedin} + data-trackable="linkedin"> + LinkedIn <span className={styles['hidden-button-text']}>(opens new window)</span> + </a> + </span> + <span className={styles.button} data-share="whatsapp"> + <a + className={styles.whatsapp} + rel="noopener" + href={mobileShareLinks.whatsapp} + data-trackable="whatsapp"> + Whatsapp <span className={styles['hidden-button-text']}>(opens new window)</span> + </a> + </span> + </div> + </div> +) diff --git a/components/x-gift-article/src/MobileShareButtons.scss b/components/x-gift-article/src/MobileShareButtons.scss new file mode 100644 index 000000000..a8d772f77 --- /dev/null +++ b/components/x-gift-article/src/MobileShareButtons.scss @@ -0,0 +1,80 @@ +@import './lib/variables'; + +@mixin shareButton($social-media-name, $background-color) { + &, + &:hover, + &:active:not([disabled]), + &:not([disabled]):hover, + &:focus { + width: 100%; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + text-decoration: none; + font-size: 16px; + font-weight: 600; + background-image: url(socialIconUrl($social-media-name)); + background-position: left; + background-repeat: no-repeat; + background-size: 40px; + height: 40px; + + &:before { + position: absolute; + left: 0; + top: 0; + } + + background-color: $background-color; + + } +} + +@function socialIconUrl($icon-name) { + $image-service-icon-url: "https://www.ft.com/__origami/service/image/v2/images/raw/ftsocial-v2:#{$icon-name}?source=#{$system-code}&tint=white"; + + @return $image-service-icon-url; +} + +.container { + margin-top: 36px; + width: 100%; + .container-inner { + margin-left: -10px; + } +} + +.button { + width: calc(50% - 10px); + margin: 10px 0 0 10px; + display: inline-block; +} + +.hidden-button-text { + position: absolute; + clip: rect(0 0 0 0); + margin: -1px; + border: 0; + overflow: hidden; + padding: 0; + width: 1px; + height: 1px; + white-space: nowrap; +} + +.facebook { + @include shareButton('facebook', #3b579d); +} + +.twitter { + @include shareButton('twitter', #1da1f2); +} + +.linkedin { + @include shareButton('linkedin', #0077b5); +} + +.whatsapp { + @include shareButton('whatsapp', #25d366); +} diff --git a/components/x-gift-article/src/RadioButtonsSection.jsx b/components/x-gift-article/src/RadioButtonsSection.jsx new file mode 100644 index 000000000..c94181e4b --- /dev/null +++ b/components/x-gift-article/src/RadioButtonsSection.jsx @@ -0,0 +1,58 @@ +import { h } from '@financial-times/x-engine' +import { ShareType } from './lib/constants' +import styles from './GiftArticle.scss' + +const radioSectionClassNames = [ + styles['o-forms-input'], + styles['o-forms-input--radio-round'], + styles['o-forms-input--inline'], + styles['o-forms-field'], + styles['radio-button-section'] +].join(' ') + +export default ({ shareType, showGiftUrlSection, isArticleSharingUxUpdates, showNonGiftUrlSection }) => ( + <div className={radioSectionClassNames} role="group" aria-labelledby="article-share-options"> + <span className={styles['share-option-title']} id="article-share-options"> + Article share options + </span> + <label htmlFor="giftLink"> + <input + type="radio" + name="gift-form__radio" + value="giftLink" + id="giftLink" + checked={shareType === ShareType.gift} + onChange={showGiftUrlSection} + /> + {isArticleSharingUxUpdates ? ( + <span className={styles['o-forms-input__label']}> + Gift to <strong>anyone</strong> (uses <strong>1 credit</strong>) + </span> + ) : ( + <span className={styles['o-forms-input__label']}> + with <strong>anyone</strong> (uses 1 gift credit) + </span> + )} + </label> + + <label htmlFor="nonGiftLink"> + <input + type="radio" + name="gift-form__radio" + value="nonGiftLink" + id="nonGiftLink" + checked={shareType === ShareType.nonGift} + onChange={showNonGiftUrlSection} + /> + {isArticleSharingUxUpdates ? ( + <span className={styles['o-forms-input__label']}> + Share with <strong>other FT subscribers</strong> + </span> + ) : ( + <span className={styles['o-forms-input__label']}> + with <strong>other FT subscribers</strong> + </span> + )} + </label> + </div> +) diff --git a/components/x-gift-article/src/Title.jsx b/components/x-gift-article/src/Title.jsx new file mode 100644 index 000000000..53dfafd24 --- /dev/null +++ b/components/x-gift-article/src/Title.jsx @@ -0,0 +1,35 @@ +import { h } from '@financial-times/x-engine' +import styles from './GiftArticle.scss' + +const titleClassNames = [styles.title].join(' ') + +export default ({ + giftCredits, + monthlyAllowance, + monthNow, + isFreeArticle, + isArticleSharingUxUpdates, + title = '' +}) => { + if (isArticleSharingUxUpdates) { + if (title !== 'Share on Social') { + if (isFreeArticle) { + title = 'This article is free for anyone to read' + } else { + title = `You have ${giftCredits} out of ${monthlyAllowance} gift credits left in ${monthNow}` + } + } + + return ( + <div className={titleClassNames} id="gift-article-title"> + {title} + </div> + ) + } else { + return ( + <div className={titleClassNames} id="gift-article-title"> + {title} + </div> + ) + } +} diff --git a/components/x-gift-article/src/Url.jsx b/components/x-gift-article/src/Url.jsx new file mode 100644 index 000000000..9b83e5284 --- /dev/null +++ b/components/x-gift-article/src/Url.jsx @@ -0,0 +1,23 @@ +import { h } from '@financial-times/x-engine' +import { ShareType } from './lib/constants' +import styles from './GiftArticle.scss' + +const urlWrapperClassNames = [styles['o-forms-input'], styles['o-forms-input--text']].join(' ') + +const urlClassNames = [styles['url-input']].join(' ') + +export default ({ shareType, isGiftUrlCreated, url, urlType }) => { + return ( + <span className={urlWrapperClassNames}> + <input + type="text" + name={urlType} + value={url} + className={urlClassNames} + disabled={shareType === ShareType.gift && !isGiftUrlCreated} + readOnly + aria-label="Gift article shareable link" + /> + </span> + ) +} diff --git a/components/x-gift-article/src/UrlSection.jsx b/components/x-gift-article/src/UrlSection.jsx new file mode 100644 index 000000000..b1d6e8547 --- /dev/null +++ b/components/x-gift-article/src/UrlSection.jsx @@ -0,0 +1,75 @@ +import { h } from '@financial-times/x-engine' +import { ShareType } from './lib/constants' +import Url from './Url' +import Message from './Message' +import Buttons from './Buttons' +import styles from './GiftArticle.scss' + +const urlSectionClassNames = ['js-gift-article__url-section', styles['url-section']].join(' ') + +export default ({ + shareType, + isGiftUrlCreated, + isFreeArticle, + url, + urlType, + giftCredits, + monthlyAllowance, + nextRenewalDateText, + mailtoUrl, + redemptionLimit, + showCopyButton, + nativeShare, + invalidResponseFromApi, + isArticleSharingUxUpdates, + actions +}) => { + const hideUrlShareElements = giftCredits === 0 && shareType === ShareType.gift + const showUrlShareElements = !hideUrlShareElements + + return ( + <div + className={urlSectionClassNames} + data-section-id={shareType + 'Link'} + data-trackable={shareType + 'Link'}> + {showUrlShareElements && ( + <Url + {...{ + shareType, + isGiftUrlCreated, + url, + urlType + }} + /> + )} + + <Message + {...{ + shareType, + isGiftUrlCreated, + isFreeArticle, + giftCredits, + monthlyAllowance, + nextRenewalDateText, + redemptionLimit, + invalidResponseFromApi, + isArticleSharingUxUpdates + }} + /> + + {showUrlShareElements && ( + <Buttons + {...{ + shareType, + isGiftUrlCreated, + mailtoUrl, + showCopyButton, + nativeShare, + actions, + giftCredits + }} + /> + )} + </div> + ) +} diff --git a/components/x-gift-article/src/lib/api.js b/components/x-gift-article/src/lib/api.js new file mode 100644 index 000000000..04e399066 --- /dev/null +++ b/components/x-gift-article/src/lib/api.js @@ -0,0 +1,79 @@ +export default class ApiClient { + constructor({ protocol, domain } = {}) { + this.protocol = protocol + this.domain = domain + } + + getFetchUrl(path) { + let base = '' + if (this.domain) { + base = `//${this.domain}` + + if (this.protocol) { + base = `${this.protocol}:${base}` + } + } + + return `${base}${path}` + } + + fetchJson(path, additionalOptions) { + const url = this.getFetchUrl(path) + const options = Object.assign( + { + credentials: 'include' + }, + additionalOptions + ) + + return fetch(url, options).then((response) => response.json()) + } + + async getGiftArticleAllowance() { + try { + const json = await this.fetchJson('/article/gift-credits') + + return { + monthlyAllowance: json.allowance, + giftCredits: json.remainingCredits, + nextRenewalDate: json.renewalDate + } + } catch (e) { + return { monthlyAllowance: undefined, giftCredits: undefined, nextRenewalDate: undefined } + } + } + + async getGiftUrl(articleId) { + try { + const json = await this.fetchJson('/article/gift-link/' + encodeURIComponent(articleId)) + + if (json.errors) { + throw new Error(`Failed to get gift article link: ${json.errors.join(', ')}`) + } + + return { + ...json + } + } catch (e) { + return { redemptionUrl: undefined, redemptionLimit: undefined } + } + } + + async getShorterUrl(originalUrl) { + let url = originalUrl + let isShortened = false + + try { + const json = await this.fetchJson('/article/shorten-url/' + encodeURIComponent(originalUrl)) + + if (json.shortenedUrl) { + isShortened = true + url = json.shortenedUrl + } + } catch (e) { + // do nothing because it just returns original url at the end + } + + return { url, isShortened } + } +} diff --git a/components/x-gift-article/src/lib/constants.js b/components/x-gift-article/src/lib/constants.js new file mode 100644 index 000000000..792b42a11 --- /dev/null +++ b/components/x-gift-article/src/lib/constants.js @@ -0,0 +1,10 @@ +export const ShareType = { + gift: 'gift', + nonGift: 'nonGift' +} + +export const UrlType = { + dummy: 'example-gift-link', + gift: 'gift-link', + nonGift: 'non-gift-link' +} diff --git a/components/x-gift-article/src/lib/share-link-actions.js b/components/x-gift-article/src/lib/share-link-actions.js new file mode 100644 index 000000000..d39ddb3fb --- /dev/null +++ b/components/x-gift-article/src/lib/share-link-actions.js @@ -0,0 +1,38 @@ +function createMailtoUrl(articleTitle, shareUrl) { + const subject = encodeURIComponent(articleTitle) + const body = encodeURIComponent(shareUrl) + + return `mailto:?subject=${subject}&body=${body}` +} + +function copyToClipboard(event) { + const urlSection = event.target.closest('.js-gift-article__url-section') + const inputEl = urlSection.querySelector('input') + const oldContentEditable = inputEl.contentEditable + const oldReadOnly = inputEl.readOnly + const range = document.createRange() + + inputEl.contenteditable = true + inputEl.readonly = false + inputEl.focus() + range.selectNodeContents(inputEl) + + const selection = window.getSelection() + + try { + selection.removeAllRanges() + selection.addRange(range) + inputEl.setSelectionRange(0, 999999) + } catch (err) { + inputEl.select() // IE11 etc. + } + inputEl.contentEditable = oldContentEditable + inputEl.readOnly = oldReadOnly + document.execCommand('copy') + inputEl.blur() +} + +module.exports = { + createMailtoUrl, + copyToClipboard +} diff --git a/components/x-gift-article/src/lib/tracking.js b/components/x-gift-article/src/lib/tracking.js new file mode 100644 index 000000000..302fbad00 --- /dev/null +++ b/components/x-gift-article/src/lib/tracking.js @@ -0,0 +1,35 @@ +function dispatchEvent(detail) { + const event = new CustomEvent('oTracking.event', { + detail, + bubbles: true + }) + + document.body.dispatchEvent(event) +} + +module.exports = { + createGiftLink: (link, longUrl) => + dispatchEvent({ + category: 'gift-link', + action: 'create', + linkType: 'giftLink', + link, + longUrl + }), + + copyLink: (linkType, link) => + dispatchEvent({ + category: 'gift-link', + action: 'copy', + linkType, + link + }), + + emailLink: (linkType, link) => + dispatchEvent({ + category: 'gift-link', + action: 'mailto', + linkType, + link + }) +} diff --git a/components/x-gift-article/src/lib/updaters.js b/components/x-gift-article/src/lib/updaters.js new file mode 100644 index 000000000..ea471c401 --- /dev/null +++ b/components/x-gift-article/src/lib/updaters.js @@ -0,0 +1,84 @@ +import { createMailtoUrl } from './share-link-actions' +import { ShareType, UrlType } from './constants' + +export const monthNames = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December' +] + +export const showGiftUrlSection = (props) => ({ + shareType: ShareType.gift, + url: props.urls.gift || props.urls.dummy, + urlType: props.urls.gift ? UrlType.gift : UrlType.dummy, + mailtoUrl: props.mailtoUrls.gift, + showCopyConfirmation: false +}) + +export const showNonGiftUrlSection = (props) => ({ + shareType: ShareType.nonGift, + url: props.urls.nonGift, + urlType: UrlType.nonGift, + mailtoUrl: props.mailtoUrls.nonGift, + showCopyConfirmation: false +}) + +export const setGiftUrl = (url, redemptionLimit, isShortened) => (props) => { + const mailtoUrl = createMailtoUrl(props.article.title, url) + + return { + url, + mailtoUrl, + redemptionLimit, + isGiftUrlCreated: true, + isGiftUrlShortened: isShortened, + urlType: UrlType.gift, + + urls: Object.assign(props.urls, { + gift: url + }), + + mailtoUrls: Object.assign(props.mailtoUrls, { + gift: mailtoUrl + }) + } +} + +export const setAllowance = (giftCredits, monthlyAllowance, nextRenewalDate) => { + const date = new Date(nextRenewalDate) + const nextRenewalDateText = `${monthNames[date.getMonth()]} ${date.getDate()}` + + return { + giftCredits, + monthlyAllowance, + nextRenewalDateText, + invalidResponseFromApi: false + } +} + +export const setShortenedNonGiftUrl = (shortenedUrl) => (props) => { + const mailtoUrl = createMailtoUrl(props.article.title, shortenedUrl) + + return { + url: shortenedUrl, + mailtoUrl: mailtoUrl, + isNonGiftUrlShortened: true, + + urls: Object.assign(props.urls, { + gift: shortenedUrl + }), + + mailtoUrls: Object.assign(props.mailtoUrls, { + gift: mailtoUrl + }) + } +} diff --git a/components/x-gift-article/src/lib/variables.scss b/components/x-gift-article/src/lib/variables.scss new file mode 100644 index 000000000..74825af23 --- /dev/null +++ b/components/x-gift-article/src/lib/variables.scss @@ -0,0 +1,3 @@ +// This is needed for calls to the image service for Icons used in Social, +// and oMessage +$system-code:'github:Financial-Times/x-dash' !default; diff --git a/components/x-gift-article/storybook/error-response.js b/components/x-gift-article/storybook/error-response.js new file mode 100644 index 000000000..ce3c250c1 --- /dev/null +++ b/components/x-gift-article/storybook/error-response.js @@ -0,0 +1,27 @@ +const articleUrl = 'https://www.ft.com/content/blahblahblah' +const nonGiftArticleUrl = `${articleUrl}?shareType=nongift` + +exports.args = { + title: 'Share this article (unable to fetch credits)', + isFreeArticle: false, + article: { + id: 'article id', + url: articleUrl, + title: 'Title Title Title Title' + } +} + +// This reference is only required for hot module loading in development +// <https://webpack.js.org/concepts/hot-module-replacement/> +exports.m = module + +exports.fetchMock = (fetchMock) => { + fetchMock + .restore() + .get('/article/gift-credits', { + throw: new Error('bad membership api') + }) + .get(`/article/shorten-url/${encodeURIComponent(nonGiftArticleUrl)}`, { + shortenedUrl: 'https://shortened-non-gift-url' + }) +} diff --git a/components/x-gift-article/storybook/free-article.js b/components/x-gift-article/storybook/free-article.js new file mode 100644 index 000000000..718ff1bb4 --- /dev/null +++ b/components/x-gift-article/storybook/free-article.js @@ -0,0 +1,30 @@ +const articleUrl = 'https://www.ft.com/content/blahblahblah' +const nonGiftArticleUrl = `${articleUrl}?shareType=nongift` + +exports.args = { + title: 'Share this article (free)', + isFreeArticle: true, + article: { + title: 'Title Title Title Title', + id: 'base-gift-article-static-id', + url: articleUrl + } +} + +// This reference is only required for hot module loading in development +// <https://webpack.js.org/concepts/hot-module-replacement/> +exports.m = module + +exports.fetchMock = (fetchMock) => { + fetchMock + .restore() + .get('/article/gift-credits', { + allowance: 20, + consumedCredits: 5, + remainingCredits: 15, + renewalDate: '2018-08-01T00:00:00Z' + }) + .get(`/article/shorten-url/${encodeURIComponent(nonGiftArticleUrl)}`, { + shortenedUrl: 'https://shortened-non-gift-url' + }) +} diff --git a/components/x-gift-article/storybook/index.jsx b/components/x-gift-article/storybook/index.jsx new file mode 100644 index 000000000..c9abcdc2d --- /dev/null +++ b/components/x-gift-article/storybook/index.jsx @@ -0,0 +1,108 @@ +import { GiftArticle } from '../src/GiftArticle' +import fetchMock from 'fetch-mock' +import React from 'react' +import { Helmet } from 'react-helmet' +import BuildService from '../../../.storybook/build-service' + +const dependencies = { + 'o-fonts': '^3.0.0' +} + +export default { + title: 'x-gift-article' +} + +export const WithGiftCredits = (args) => { + require('./with-gift-credits').fetchMock(fetchMock) + return ( + <div className="story-container"> + {dependencies && <BuildService dependencies={dependencies} />} + <Helmet> + <link rel="stylesheet" href={`components/x-gift-article/dist/GiftArticle.css`} /> + </Helmet> + <GiftArticle {...args} actionsRef={(actions) => actions?.activate()} /> + </div> + ) +} +WithGiftCredits.storyName = 'With gift credits' +WithGiftCredits.args = require('./with-gift-credits').args + +export const WithoutGiftCredits = (args) => { + require('./without-gift-credits').fetchMock(fetchMock) + return ( + <div className="story-container"> + {dependencies && <BuildService dependencies={dependencies} />} + <Helmet> + <link rel="stylesheet" href={`components/x-gift-article/dist/GiftArticle.css`} /> + </Helmet> + <GiftArticle {...args} actionsRef={(actions) => actions?.activate()} /> + </div> + ) +} + +WithoutGiftCredits.storyName = 'Without gift credits' +WithoutGiftCredits.args = require('./without-gift-credits').args + +export const WithGiftLink = (args) => { + require('./with-gift-link').fetchMock(fetchMock) + return ( + <div className="story-container"> + {dependencies && <BuildService dependencies={dependencies} />} + <Helmet> + <link rel="stylesheet" href={`components/x-gift-article/dist/GiftArticle.css`} /> + </Helmet> + <GiftArticle {...args} /> + </div> + ) +} + +WithGiftLink.storyName = 'With gift link' +WithGiftLink.args = require('./with-gift-link').args + +export const FreeArticle = (args) => { + require('./free-article').fetchMock(fetchMock) + return ( + <div className="story-container"> + {dependencies && <BuildService dependencies={dependencies} />} + <Helmet> + <link rel="stylesheet" href={`components/x-gift-article/dist/GiftArticle.css`} /> + </Helmet> + <GiftArticle {...args} actionsRef={(actions) => actions?.activate()} /> + </div> + ) +} + +FreeArticle.storyName = 'Free article' +FreeArticle.args = require('./free-article').args + +export const NativeShare = (args) => { + require('./native-share').fetchMock(fetchMock) + return ( + <div className="story-container"> + {dependencies && <BuildService dependencies={dependencies} />} + <Helmet> + <link rel="stylesheet" href={`components/x-gift-article/dist/GiftArticle.css`} /> + </Helmet> + <GiftArticle {...args} actionsRef={(actions) => actions?.activate()} /> + </div> + ) +} + +NativeShare.storyName = 'Native share' +NativeShare.args = require('./native-share').args + +export const ErrorResponse = (args) => { + require('./error-response').fetchMock(fetchMock) + return ( + <div className="story-container"> + {dependencies && <BuildService dependencies={dependencies} />} + <Helmet> + <link rel="stylesheet" href={`components/x-gift-article/dist/GiftArticle.css`} /> + </Helmet> + <GiftArticle {...args} actionsRef={(actions) => actions?.activate()} /> + </div> + ) +} + +ErrorResponse.storyName = 'Error response' +ErrorResponse.args = require('./error-response').args diff --git a/components/x-gift-article/storybook/native-share.js b/components/x-gift-article/storybook/native-share.js new file mode 100644 index 000000000..68ff177ac --- /dev/null +++ b/components/x-gift-article/storybook/native-share.js @@ -0,0 +1,41 @@ +const articleId = 'article id' +const articleUrl = 'https://www.ft.com/content/blahblahblah' +const articleUrlRedeemed = 'https://gift-url-redeemed' +const nonGiftArticleUrl = `${articleUrl}?shareType=nongift` + +exports.args = { + title: 'Share this article (on App)', + isFreeArticle: false, + article: { + id: articleId, + url: articleUrl, + title: 'Title Title Title Title' + }, + nativeShare: true, + id: 'base-gift-article-static-id' +} + +// This reference is only required for hot module loading in development +// <https://webpack.js.org/concepts/hot-module-replacement/> +exports.m = module + +exports.fetchMock = (fetchMock) => { + fetchMock + .restore() + .get('/article/gift-credits', { + allowance: 20, + consumedCredits: 2, + remainingCredits: 18, + renewalDate: '2018-08-01T00:00:00Z' + }) + .get(`/article/shorten-url/${encodeURIComponent(articleUrlRedeemed)}`, { + shortenedUrl: 'https://shortened-gift-url' + }) + .get(`/article/shorten-url/${encodeURIComponent(nonGiftArticleUrl)}`, { + shortenedUrl: 'https://shortened-non-gift-url' + }) + .get(`/article/gift-link/${encodeURIComponent(articleId)}`, { + redemptionUrl: articleUrlRedeemed, + remainingAllowance: 1 + }) +} diff --git a/components/x-gift-article/storybook/with-gift-credits.js b/components/x-gift-article/storybook/with-gift-credits.js new file mode 100644 index 000000000..3f1ed3b65 --- /dev/null +++ b/components/x-gift-article/storybook/with-gift-credits.js @@ -0,0 +1,41 @@ +const articleId = 'article id' +const articleUrl = 'https://www.ft.com/content/blahblahblah' +const articleUrlRedeemed = 'https://gift-url-redeemed' +const nonGiftArticleUrl = `${articleUrl}?shareType=nongift` + +exports.args = { + title: 'Share this article (with credit)', + isFreeArticle: false, + article: { + id: articleId, + url: articleUrl, + title: 'Title Title Title Title' + }, + showMobileShareLinks: true, + id: 'base-gift-article-static-id' +} + +// This reference is only required for hot module loading in development +// <https://webpack.js.org/concepts/hot-module-replacement/> +exports.m = module + +exports.fetchMock = (fetchMock) => { + fetchMock + .restore() + .get('/article/gift-credits', { + allowance: 20, + consumedCredits: 5, + remainingCredits: 15, + renewalDate: '2018-08-01T00:00:00Z' + }) + .get(`/article/shorten-url/${encodeURIComponent(articleUrlRedeemed)}`, { + shortenedUrl: 'https://shortened-gift-url' + }) + .get(`/article/shorten-url/${encodeURIComponent(nonGiftArticleUrl)}`, { + shortenedUrl: 'https://shortened-non-gift-url' + }) + .get(`/article/gift-link/${encodeURIComponent(articleId)}`, { + redemptionUrl: articleUrlRedeemed, + remainingAllowance: 1 + }) +} diff --git a/components/x-gift-article/storybook/with-gift-link.js b/components/x-gift-article/storybook/with-gift-link.js new file mode 100644 index 000000000..1c8ad313f --- /dev/null +++ b/components/x-gift-article/storybook/with-gift-link.js @@ -0,0 +1,28 @@ +const articleId = 'article id' +const articleUrl = 'https://www.ft.com/content/blahblahblah' +const articleUrlRedeemed = 'https://gift-url-redeemed' + +exports.args = { + title: 'Share this article (with gift link)', + isFreeArticle: false, + isGiftUrlCreated: true, + redemptionLimit: 3, + article: { + id: articleId, + url: articleUrl, + title: 'Title Title Title Title' + }, + showMobileShareLinks: true, + id: 'base-gift-article-static-id' +} + +// This reference is only required for hot module loading in development +// <https://webpack.js.org/concepts/hot-module-replacement/> +exports.m = module + +exports.fetchMock = (fetchMock) => { + fetchMock.restore().get(`/article/gift-link/${encodeURIComponent(articleId)}`, { + redemptionUrl: articleUrlRedeemed, + remainingAllowance: 1 + }) +} diff --git a/components/x-gift-article/storybook/without-gift-credits.js b/components/x-gift-article/storybook/without-gift-credits.js new file mode 100644 index 000000000..7fdd9149c --- /dev/null +++ b/components/x-gift-article/storybook/without-gift-credits.js @@ -0,0 +1,30 @@ +const articleUrl = 'https://www.ft.com/content/blahblahblah' +const nonGiftArticleUrl = `${articleUrl}?shareType=nongift` + +exports.args = { + title: 'Share this article (without credit)', + isFreeArticle: false, + article: { + id: 'article id', + url: articleUrl, + title: 'Title Title Title Title' + } +} + +// This reference is only required for hot module loading in development +// <https://webpack.js.org/concepts/hot-module-replacement/> +exports.m = module + +exports.fetchMock = (fetchMock) => { + fetchMock + .restore() + .get('/article/gift-credits', { + allowance: 20, + consumedCredits: 20, + remainingCredits: 0, + renewalDate: '2018-08-01T00:00:00Z' + }) + .get(`/article/shorten-url/${encodeURIComponent(nonGiftArticleUrl)}`, { + shortenedUrl: 'https://shortened-non-gift-url' + }) +} diff --git a/components/x-increment/__tests__/x-increment.test.jsx b/components/x-increment/__tests__/x-increment.test.jsx index 544f6726b..4ccff4281 100644 --- a/components/x-increment/__tests__/x-increment.test.jsx +++ b/components/x-increment/__tests__/x-increment.test.jsx @@ -1,30 +1,30 @@ -const { h } = require('@financial-times/x-engine'); -const { mount } = require('@financial-times/x-test-utils/enzyme'); +const { h } = require('@financial-times/x-engine') +const { mount } = require('@financial-times/x-test-utils/enzyme') -const { Increment } = require('../'); +const { Increment } = require('../') describe('x-increment', () => { it('should increment when action is triggered', async () => { - const subject = mount(<Increment count={1} />); - await subject.find('BaseIncrement').prop('actions').increment(); + const subject = mount(<Increment count={1} />) + await subject.find('BaseIncrement').prop('actions').increment() - expect(subject.find('span').text()).toEqual('2'); - }); + expect(subject.find('span').text()).toEqual('2') + }) it('should increment by amount from action arg', async () => { - const subject = mount(<Increment count={1} />); - await subject.find('BaseIncrement').prop('actions').increment({ amount: 2 }); + const subject = mount(<Increment count={1} />) + await subject.find('BaseIncrement').prop('actions').increment({ amount: 2 }) - expect(subject.find('span').text()).toEqual('3'); - }); + expect(subject.find('span').text()).toEqual('3') + }) it('should increment when clicked, waiting for timeout', async () => { - const subject = mount(<Increment count={1} timeout={1000} />); - const start = Date.now(); + const subject = mount(<Increment count={1} timeout={1000} />) + const start = Date.now() - await subject.find('button').prop('onClick')(); + await subject.find('button').prop('onClick')() - expect(Date.now() - start).toBeCloseTo(1000, -2); // negative precision ⇒ left of decimal point - expect(subject.find('span').text()).toEqual('2'); - }); -}); + expect(Date.now() - start).toBeCloseTo(1000, -2) // negative precision ⇒ left of decimal point + expect(subject.find('span').text()).toEqual('2') + }) +}) diff --git a/components/x-increment/package.json b/components/x-increment/package.json index eba49cf3a..001a6ad36 100644 --- a/components/x-increment/package.json +++ b/components/x-increment/package.json @@ -3,11 +3,11 @@ "version": "0.0.0", "private": true, "description": "", + "source": "src/Increment.jsx", "main": "dist/Increment.cjs.js", "module": "dist/Increment.esm.js", "browser": "dist/Increment.es5.js", "scripts": { - "prepare": "npm run build", "build": "node rollup.js", "start": "node rollup.js --watch" }, diff --git a/components/x-increment/rollup.js b/components/x-increment/rollup.js index e29c5583b..a2312ca80 100644 --- a/components/x-increment/rollup.js +++ b/components/x-increment/rollup.js @@ -1,4 +1,4 @@ -const xRollup = require('@financial-times/x-rollup'); -const pkg = require('./package.json'); +const xRollup = require('@financial-times/x-rollup') +const pkg = require('./package.json') -xRollup({ input: './src/Increment.jsx', pkg }); +xRollup({ input: './src/Increment.jsx', pkg }) diff --git a/components/x-increment/src/Increment.jsx b/components/x-increment/src/Increment.jsx index 5b91f7c10..d3aa1a462 100644 --- a/components/x-increment/src/Increment.jsx +++ b/components/x-increment/src/Increment.jsx @@ -1,28 +1,27 @@ -import { h } from '@financial-times/x-engine'; -import { withActions } from '@financial-times/x-interaction'; +import { h } from '@financial-times/x-engine' +import { withActions } from '@financial-times/x-interaction' -const delay = ms => new Promise(r => setTimeout(r, ms)); +const delay = (ms) => new Promise((r) => setTimeout(r, ms)) -const withIncrementActions = withActions(({timeout}) => ({ +const withIncrementActions = withActions(({ timeout }) => ({ async increment({ amount = 1 } = {}) { - await delay(timeout); + await delay(timeout) - return ({count}) => ({ - count: count + amount, - }); - }, -})); + return ({ count }) => ({ + count: count + amount + }) + } +})) -const BaseIncrement = ({count, actions: {increment}, isLoading}) => <div> - <span>{count}</span> - <button onClick={() => increment()} disabled={isLoading}> - {isLoading - ? 'Loading...' - : 'Increment' - } - </button> -</div>; +const BaseIncrement = ({ count, actions: { increment }, isLoading }) => ( + <div> + <span>{count}</span> + <button onClick={() => increment()} disabled={isLoading}> + {isLoading ? 'Loading...' : 'Increment'} + </button> + </div> +) -const Increment = withIncrementActions(BaseIncrement); +const Increment = withIncrementActions(BaseIncrement) -export { Increment }; +export { Increment } diff --git a/components/x-increment/stories/async.js b/components/x-increment/stories/async.js deleted file mode 100644 index 67764e1d2..000000000 --- a/components/x-increment/stories/async.js +++ /dev/null @@ -1,15 +0,0 @@ -exports.title = 'Async'; - -const data = { - count: 1, - timeout: 1000, - id: 'base-increment-static-id', -}; - -exports.data = data; - -exports.knobs = Object.keys(data); - -// This reference is only required for hot module loading in development -// <https://webpack.js.org/concepts/hot-module-replacement/> -exports.m = module; diff --git a/components/x-increment/stories/increment.js b/components/x-increment/stories/increment.js deleted file mode 100644 index 904bdd73c..000000000 --- a/components/x-increment/stories/increment.js +++ /dev/null @@ -1,10 +0,0 @@ -exports.title = 'Increment'; - -exports.data = { - count: 1, - id: 'base-increment-static-id', -}; - -// This reference is only required for hot module loading in development -// <https://webpack.js.org/concepts/hot-module-replacement/> -exports.m = module; diff --git a/components/x-increment/stories/index.js b/components/x-increment/stories/index.js deleted file mode 100644 index 211967353..000000000 --- a/components/x-increment/stories/index.js +++ /dev/null @@ -1,10 +0,0 @@ -const { Increment } = require('../'); - -exports.component = Increment; -exports.package = require('../package.json'); -exports.stories = [ - require('./increment'), - require('./async'), -]; - -exports.knobs = require('./knobs'); diff --git a/components/x-increment/stories/knobs.js b/components/x-increment/stories/knobs.js deleted file mode 100644 index 7bf3de5bd..000000000 --- a/components/x-increment/stories/knobs.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = (data, { number }) => { - return { - count() { - return number('Count', data.count, {}); - } - }; -}; diff --git a/components/x-increment/storybook/index.jsx b/components/x-increment/storybook/index.jsx new file mode 100644 index 000000000..09ee6201f --- /dev/null +++ b/components/x-increment/storybook/index.jsx @@ -0,0 +1,25 @@ +import React from 'react' +import { Increment } from '../src/Increment' + +export default { + title: 'x-increment' +} + +export const Sync = () => { + const data = { + count: 1, + id: 'base-increment-static-id' + } + + return <Increment {...data} /> +} + +export const Async = () => { + const data = { + count: 1, + timeout: 1000, + id: 'base-increment-static-id' + } + + return <Increment {...data} /> +} diff --git a/components/x-interaction/__tests__/registerComponent.test.js b/components/x-interaction/__tests__/registerComponent.test.js new file mode 100644 index 000000000..77799cbc4 --- /dev/null +++ b/components/x-interaction/__tests__/registerComponent.test.js @@ -0,0 +1,62 @@ +const { + registerComponent, + getComponentByName, + getComponent, + getComponentName +} = require('../src/concerns/register-component') +const { withActions } = require('../') + +describe('registerComponent', () => { + let name + let Component + beforeAll(() => { + name = 'testComponent' + Component = withActions({})(() => null) + }) + + it(`should register a component in registerComponent`, () => { + registerComponent(Component, name) + + const actualComponent = getComponentByName(name) + expect(actualComponent).toBeTruthy() + }) + + it('should throw an error if the component has already been registered', () => { + expect(() => registerComponent(Component, name)).toThrow( + 'x-interaction a component has already been registered under that name, please use another name.' + ) + }) + + it('should throw an error if the component is not x-interaction wrapped', () => { + const unwrappedComponentName = 'unwrappedComponent' + const unwrappedComponent = { _wraps: null } + + expect(() => registerComponent(unwrappedComponent, unwrappedComponentName)).toThrow( + 'only x-interaction wrapped components (i.e. the component returned from withActions) can be registered' + ) + }) + + it('should get component that is already registered in getComponent', () => { + expect(getComponent(Component)).toBeTruthy() + }) + + it('should get component by name in getComponentByName', () => { + const actualComponent = getComponentByName(name) + + expect(actualComponent).toEqual(Component) + }) + + it('should get component name in getComponentName', () => { + const actualName = getComponentName(Component) + + expect(actualName).toBe(name) + }) + + it('should return Unknown if Component is not registered in getComponentName', () => { + const unregisteredComponent = withActions({})(() => null) + + const actualName = getComponentName(unregisteredComponent) + + expect(actualName).toBe('Unknown') + }) +}) diff --git a/components/x-interaction/__tests__/serialiser.test.js b/components/x-interaction/__tests__/serialiser.test.js new file mode 100644 index 000000000..ab91780a9 --- /dev/null +++ b/components/x-interaction/__tests__/serialiser.test.js @@ -0,0 +1,70 @@ +import { Serialiser } from '../src/concerns/serialiser' +import * as registerComponent from '../src/concerns/register-component' +const { withActions } = require('../') +const xEngine = require('@financial-times/x-engine') + +describe('serialiser', () => { + let serialiser + let Component + let name + beforeAll(() => { + serialiser = new Serialiser() + name = 'testComponent' + Component = withActions({})(() => null) + }) + + it('pushes Component to data array in addData', () => { + jest.spyOn(registerComponent, 'getComponent').mockReturnValue(Component) + jest.spyOn(registerComponent, 'getComponentName').mockReturnValue(name) + + serialiser.addData('id', Component, {}) + + expect(serialiser.data.length).toEqual(1) + }) + + it('throws Error if component is not registered in addData', () => { + jest.spyOn(registerComponent, 'getComponent').mockReturnValue(undefined) + + expect(() => serialiser.addData('id', Component, {})).toThrow( + `a Serialiser's addData was called for an unregistered component. ensure you're registering your component before attempting to output the hydration data` + ) + }) + + it('throws Error if serialiser is destroyed in addData', () => { + serialiser.destroyed = true + jest.spyOn(registerComponent, 'getComponent').mockReturnValue(Component) + + expect(() => serialiser.addData('id', Component, {})).toThrow( + `an interaction component was rendered after flushHydrationData was called. ensure you're outputting the hydration data after rendering every component` + ) + }) + + it('throws Error if serialiser is destroyed in flushHydrationData', () => { + serialiser.destroyed = true + expect(() => serialiser.flushHydrationData()).toThrow( + `a Serialiser's flushHydrationData was called twice. ensure you're not reusing a Serialiser between requests` + ) + }) + + it('returns data and sets destroyed to true if serialiser is not destroyed in flushHydrationData', () => { + serialiser.destroyed = false + serialiser.data = [{ id: 'id', component: Component, props: '' }] + + const data = serialiser.flushHydrationData() + + expect(data).toEqual(serialiser.data) + expect(serialiser.destroyed).toEqual(true) + }) + + it('renders the hydration data in outputHydrationData', () => { + const expectedData = {} + jest.spyOn(xEngine, 'h').mockReturnValue('target') + jest.spyOn(xEngine, 'render').mockReturnValue(expectedData) + + const data = serialiser.outputHydrationData() + + expect(xEngine.render).toHaveBeenCalled() + expect(xEngine.h).toHaveBeenCalled() + expect(data).toEqual(expectedData) + }) +}) diff --git a/components/x-interaction/__tests__/x-interaction.test.jsx b/components/x-interaction/__tests__/x-interaction.test.jsx index 2708f39ad..591ff4661 100644 --- a/components/x-interaction/__tests__/x-interaction.test.jsx +++ b/components/x-interaction/__tests__/x-interaction.test.jsx @@ -1,7 +1,7 @@ -const { h } = require('@financial-times/x-engine'); -const { mount } = require('@financial-times/x-test-utils/enzyme'); +const { h } = require('@financial-times/x-engine') +const { mount } = require('@financial-times/x-test-utils/enzyme') -const { withActions } = require('../'); +const { withActions } = require('../') describe('x-interaction', () => { describe('withActions', () => { @@ -9,289 +9,284 @@ describe('x-interaction', () => { const originalProps = { className: 'foo', bax: Math.random(), - [Math.random()]: 'baz', - }; + [Math.random()]: 'baz' + } - const Base = () => null; - const Wrapped = withActions({})(Base); - const target = mount(<Wrapped {...originalProps} />); + const Base = () => null + const Wrapped = withActions({})(Base) + const target = mount(<Wrapped {...originalProps} />) - expect(target.find(Base).props()).toMatchObject(originalProps); - }); + expect(target.find(Base).props()).toMatchObject(originalProps) + }) it('should add actions', () => { - const Base = () => null; + const Base = () => null const Wrapped = withActions({ - foo() {}, - })(Base); + foo() {} + })(Base) - const target = mount(<Wrapped />); - const props = target.find(Base).props(); + const target = mount(<Wrapped />) + const props = target.find(Base).props() - expect(props).toHaveProperty('actions'); - expect(props.actions.foo).toBeInstanceOf(Function); - }); + expect(props).toHaveProperty('actions') + expect(props.actions.foo).toBeInstanceOf(Function) + }) it('should call underlying function when action is called, passing through args', () => { - const foo = jest.fn(); + const foo = jest.fn() - const Base = () => null; + const Base = () => null const Wrapped = withActions({ - foo, - })(Base); + foo + })(Base) - const target = mount(<Wrapped />); - const props = target.find(Base).props(); + const target = mount(<Wrapped />) + const props = target.find(Base).props() const args = ['bar', 'baz'].concat( // random length args to verify they're all passed through Array(Math.floor(10 * Math.random())).fill('quux') - ); + ) - props.actions.foo(...args); + props.actions.foo(...args) - expect(foo).toHaveBeenLastCalledWith(...args); - }); + expect(foo).toHaveBeenLastCalledWith(...args) + }) it('should return promise from action even when synchronous', () => { - const Base = () => null; + const Base = () => null const Wrapped = withActions({ - foo() {}, - })(Base); + foo() {} + })(Base) - const target = mount(<Wrapped />); - const props = target.find(Base).props(); + const target = mount(<Wrapped />) + const props = target.find(Base).props() - expect( - props.actions.foo() - ).toBeInstanceOf(Promise); - }); + expect(props.actions.foo()).toBeInstanceOf(Promise) + }) it('should update props of base with return value of action', async () => { - const Base = () => null; + const Base = () => null const Wrapped = withActions({ - foo: () => ({ bar: 10 }), - })(Base); + foo: () => ({ bar: 10 }) + })(Base) - const target = mount(<Wrapped bar={5} />); + const target = mount(<Wrapped bar={5} />) - await target.find(Base).prop('actions').foo(); - target.update(); // tell enzyme things have changed + await target.find(Base).prop('actions').foo() + target.update() // tell enzyme things have changed - expect( - target.find(Base).prop('bar') - ).toBe(10); - }); + expect(target.find(Base).prop('bar')).toBe(10) + }) it('should update props of base using updater function from action', async () => { - const Base = () => null; + const Base = () => null const Wrapped = withActions({ - foo: () => ({bar}) => ({ bar: bar + 5 }), - })(Base); + foo: + () => + ({ bar }) => ({ bar: bar + 5 }) + })(Base) - const target = mount(<Wrapped bar={5} />); + const target = mount(<Wrapped bar={5} />) - await target.find(Base).prop('actions').foo(); - target.update(); + await target.find(Base).prop('actions').foo() + target.update() - expect( - target.find(Base).prop('bar') - ).toBe(10); - }); + expect(target.find(Base).prop('bar')).toBe(10) + }) it('should update props of base using async updater function from action', async () => { - const Base = () => null; + const Base = () => null const Wrapped = withActions({ - foo: () => async ({bar}) => ({ bar: bar + 5 }), - })(Base); - - const target = mount(<Wrapped bar={5} />); + foo: + () => + async ({ bar }) => ({ bar: bar + 5 }) + })(Base) - await target.find(Base).prop('actions').foo(); - target.update(); + const target = mount(<Wrapped bar={5} />) - expect( - target.find(Base).prop('bar') - ).toBe(10); - }); + await target.find(Base).prop('actions').foo() + target.update() + expect(target.find(Base).prop('bar')).toBe(10) + }) it('should wait for promises and apply resolved state updates', async () => { - const Base = () => null; + const Base = () => null const Wrapped = withActions({ - foo: () => Promise.resolve({ bar: 10 }), - })(Base); + foo: () => Promise.resolve({ bar: 10 }) + })(Base) - const target = mount(<Wrapped bar={5} />); + const target = mount(<Wrapped bar={5} />) - await target.find(Base).prop('actions').foo(); - target.update(); // tell enzyme things have changed + await target.find(Base).prop('actions').foo() + target.update() // tell enzyme things have changed - expect( - target.find(Base).prop('bar') - ).toBe(10); - }); + expect(target.find(Base).prop('bar')).toBe(10) + }) it('should set isLoading to true while waiting for promises', async () => { - const Base = () => null; + const Base = () => null const Wrapped = withActions({ - foo: () => new Promise(resolve => { - setTimeout(resolve, 200, { bar: 10 }); - }), - })(Base); + foo: () => + new Promise((resolve) => { + setTimeout(resolve, 200, { bar: 10 }) + }) + })(Base) - const target = mount(<Wrapped bar={5} />); - const promise = target.find(Base).prop('actions').foo(); + const target = mount(<Wrapped bar={5} />) + const promise = target.find(Base).prop('actions').foo() - await Promise.resolve(); // wait one microtask - target.update(); + await Promise.resolve() // wait one microtask + target.update() - expect( - target.find(Base).prop('isLoading') - ).toBe(true); + expect(target.find(Base).prop('isLoading')).toBe(true) - await promise; - target.update(); + await promise + target.update() - expect( - target.find(Base).prop('isLoading') - ).toBe(false); - }); + expect(target.find(Base).prop('isLoading')).toBe(false) + }) it(`shouldn't set isLoading back to false until everything is finished`, async () => { - const Base = () => null; + const Base = () => null const Wrapped = withActions({ - foo: () => new Promise(resolve => { - setTimeout(resolve, 200, { bar: 10 }); - }), - })(Base); + foo: () => + new Promise((resolve) => { + setTimeout(resolve, 200, { bar: 10 }) + }) + })(Base) - const target = mount(<Wrapped bar={5} />); - const promise1 = target.find(Base).prop('actions').foo(); + const target = mount(<Wrapped bar={5} />) + const promise1 = target.find(Base).prop('actions').foo() - await new Promise(resolve => { - setTimeout(resolve, 100); - }); + await new Promise((resolve) => { + setTimeout(resolve, 100) + }) - const promise2 = target.find(Base).prop('actions').foo(); - target.update(); + const promise2 = target.find(Base).prop('actions').foo() + target.update() - expect( - target.find(Base).prop('isLoading') - ).toBe(true); + expect(target.find(Base).prop('isLoading')).toBe(true) - await promise1; - target.update(); + await promise1 + target.update() - expect( - target.find(Base).prop('isLoading') - ).toBe(true); + expect(target.find(Base).prop('isLoading')).toBe(true) - await promise2; - target.update(); - - expect( - target.find(Base).prop('isLoading') - ).toBe(false); - }); + await promise2 + target.update() + expect(target.find(Base).prop('isLoading')).toBe(false) + }) it('should update when outside props change but prefer state changes', async () => { - const Base = () => null; + const Base = () => null const Wrapped = withActions({ - foo: () => ({ bar: 15 }), - })(Base); + foo: () => ({ bar: 15 }) + })(Base) + + const target = mount(<Wrapped bar={5} />) - const target = mount(<Wrapped bar={5} />); + target.setProps({ bar: 10 }) + target.update() - target.setProps({ bar: 10 }); - target.update(); + expect(target.find(Base).prop('bar')).toBe(10) - expect( - target.find(Base).prop('bar') - ).toBe(10); + await target.find(Base).prop('actions').foo() + target.update() + + expect(target.find(Base).prop('bar')).toBe(15) + }) + + it('should pass changed outside props to state updaters', async () => { + const Base = () => null + const Wrapped = withActions({ + foo: + () => + ({ bar }) => ({ bar: bar + 5 }) + })(Base) - await target.find(Base).prop('actions').foo(); - target.update(); + const target = mount(<Wrapped bar={5} />) - expect( - target.find(Base).prop('bar') - ).toBe(15); - }); + target.setProps({ bar: 10 }) + target.update() + + await target.find(Base).prop('actions').foo() + target.update() + + expect(target.find(Base).prop('bar')).toBe(15) + }) describe('actionsRef', () => { it('should pass actions to actionsRef on mount and null on unmount', async () => { - const actionsRef = jest.fn(); + const actionsRef = jest.fn() - const Base = () => null; + const Base = () => null const Wrapped = withActions({ - foo() {}, - })(Base); + foo() {} + })(Base) - const target = mount(<Wrapped actionsRef={actionsRef} />); + const target = mount(<Wrapped actionsRef={actionsRef} />) - expect(actionsRef).toHaveBeenCalled(); - expect(actionsRef.mock.calls[0][0]).toHaveProperty('foo'); + expect(actionsRef).toHaveBeenCalled() + expect(actionsRef.mock.calls[0][0]).toHaveProperty('foo') - target.unmount(); + target.unmount() - expect(actionsRef).toHaveBeenLastCalledWith(null); - }); + expect(actionsRef).toHaveBeenLastCalledWith(null) + }) it('should pass all actions for rewrapped components', async () => { - const actionsRef = jest.fn(); + const actionsRef = jest.fn() - const Base = () => null; + const Base = () => null const Wrapped = withActions({ - bar() {}, - })(withActions({ - foo() {}, - })(Base)); + bar() {} + })( + withActions({ + foo() {} + })(Base) + ) - mount(<Wrapped actionsRef={actionsRef} />); + mount(<Wrapped actionsRef={actionsRef} />) - expect(actionsRef).toHaveBeenCalled(); - expect(actionsRef.mock.calls[0][0]).toHaveProperty('foo'); - expect(actionsRef.mock.calls[0][0]).toHaveProperty('bar'); - }); - }); + expect(actionsRef).toHaveBeenCalled() + expect(actionsRef.mock.calls[0][0]).toHaveProperty('foo') + expect(actionsRef.mock.calls[0][0]).toHaveProperty('bar') + }) + }) it(`shouldn't reset props when others change`, async () => { - const Base = () => null; + const Base = () => null const Wrapped = withActions({ foo: () => ({ bar: 10 }), - baz: () => ({ quux: 10 }), - })(Base); + baz: () => ({ quux: 10 }) + })(Base) - const target = mount(<Wrapped bar={5} quux={5} />); + const target = mount(<Wrapped bar={5} quux={5} />) - await target.find(Base).prop('actions').foo(); - await target.find(Base).prop('actions').baz(); - target.update(); // tell enzyme things have changed + await target.find(Base).prop('actions').foo() + await target.find(Base).prop('actions').baz() + target.update() // tell enzyme things have changed - expect( - target.find(Base).prop('bar') - ).toBe(10); + expect(target.find(Base).prop('bar')).toBe(10) - expect( - target.find(Base).prop('quux') - ).toBe(10); - }); + expect(target.find(Base).prop('quux')).toBe(10) + }) it('should get default state from second argument', async () => { - const Base = () => null; - const Wrapped = withActions({}, { - foo: 5 - })(Base); - - const target = mount(<Wrapped />); - - expect( - target.find(Base).prop('foo') - ).toBe(5); - }); - - }); - - describe.skip('server rendering'); -}); + const Base = () => null + const Wrapped = withActions( + {}, + { + foo: 5 + } + )(Base) + + const target = mount(<Wrapped />) + + expect(target.find(Base).prop('foo')).toBe(5) + }) + }) +}) diff --git a/components/x-interaction/package.json b/components/x-interaction/package.json index 8bf33f778..f5c4a75fd 100644 --- a/components/x-interaction/package.json +++ b/components/x-interaction/package.json @@ -2,11 +2,11 @@ "name": "@financial-times/x-interaction", "version": "0.0.0", "description": "This module enables you to write x-dash components that respond to events and change their own data.", + "source": "src/Interaction.jsx", "main": "dist/Interaction.cjs.js", "module": "dist/Interaction.esm.js", "browser": "dist/Interaction.es5.js", "scripts": { - "prepare": "npm run build", "build": "node rollup.js", "start": "node rollup.js --watch" }, @@ -27,9 +27,9 @@ "type": "git", "url": "https://github.com/Financial-Times/x-dash.git" }, - "homepage": "https://github.com/Financial-Times/x-dash/tree/master/components/x-interaction", + "homepage": "https://github.com/Financial-Times/x-dash/tree/HEAD/components/x-interaction", "engines": { - "node": ">= 6.0.0" + "node": "12.x" }, "publishConfig": { "access": "public" diff --git a/components/x-interaction/readme.md b/components/x-interaction/readme.md index 1e2bafac8..b7c614c7a 100644 --- a/components/x-interaction/readme.md +++ b/components/x-interaction/readme.md @@ -4,7 +4,7 @@ This module enables you to write x-dash components that respond to events and ch ## Installation -This module is compatible with Node 6+ and is distributed on npm. +This module is supported on Node 12 and is distributed on npm. ```bash npm install --save @financial-times/x-interaction @@ -111,9 +111,40 @@ export const Greeting = greetingActions(BaseGreeting); ### Hydrating server-rendered markup +[Hydration](https://en.wikipedia.org/wiki/Hydration_(web_development)#:~:text=In%20web%20development%2C%20hydration%20or,handlers%20to%20the%20HTML%20elements. +): a technique in which client-side JavaScript converts a static HTML web page done by server-side rendering, into a dynamic web page by attaching event handlers to the HTML elements. + When you have an `x-interaction` component rendered by the server, and you want to attach the client-side version of the component to handle the actions, rather than rendering the component manually (which might become unwieldy, especially if you have many components & instances on the page), you can have `x-interaction` manage it for you. -There are two parts to this: serialising and hydrating. +There are three parts to this: registering the component, serialising and hydrating. + +#### Registering the component + +To register the component you'll need to call `x-interaction`'s `registerComponent` function, providing the component and its name as arguments. + +```jsx +import {withActions, registerComponent} from '@financial-times/x-interaction'; + +const greetingActions = withActions({ + actionOne() { + return {greeting: "world"}; + }, + + actionTwo() { + return ({greeting}) => ({ + greeting: greeting.toUpperCase(), + }); + }, +}); + +const Greeting = greetingActions(({greeting, actions}) => <div> + hello {greeting} + <button onClick={actions.actionOne}>"world"</button> + <button onClick={actions.actionTwo}>uppercase</button> +</div>); + +registerComponent(Greeting, 'Greeting') +``` #### Serialising @@ -123,12 +154,12 @@ This instance should be passed to every `x-interaction` component you render, as Finally, after every `x-interaction` component is rendered, you should output the hydration data. `x-interaction` exports a `HydrationData` component, which takes a serialiser as a property and renders a `<script>` tag containing its hydration data, assigned to a global variable that can be picked up by the `x-interaction` client-side runtime. A serialiser cannot be used again after its data has been output by a `HydrationData` component. -Here's a full example of using `Serialiser` and `HydrationData`: +Here's a full example of using `Serialiser` and `HydrationData` using the `Greeting` component we registered in the previous step. ```js import express from 'express'; +import { Greeting } from './Greeting' import { Serialiser, HydrationData } from '@financial-times/x-interaction'; -import { Increment } from '@financial-times/x-increment'; const app = express(); @@ -136,7 +167,7 @@ app.get('/', (req, res) => { const serialiser = new Serialiser(); res.send(` - ${Increment({ count: 1, serialiser })} + ${Greeting({ serialiser })} ${HydrationData({ serialiser })} `); }); @@ -148,7 +179,7 @@ When rendered on the server side, components output an extra wrapper element, wi `x-interaction` exports a function `hydrate`. This should be called on the client side. It inspects the global serialisation data on the page, uses the identifiers to find the wrapper elements, and calls `render` from your chosen `x-engine` client-side runtime to render component instances into the wrappers. -Before calling `hydrate`, you must first `import` any `x-interaction` components that will be rendered on the page. The components register themselves with the `x-interaction` runtime when imported; you don't need to do anything with the imported component. This will also ensure the component is included in your client-side bundle. +Before calling `hydrate`, you must first `import` any `x-interaction` components that will be rendered on the page. The components register themselves with the `x-interaction` runtime when imported; you don't need to do anything with the imported component. This will also ensure the component is included in your client-side bundle. Similarly if the component that you're server side rendering is just a component that you've created through `withActions`, make sure you import that component along with its registerComponent invokation. Because `hydrate` expects the wrappers to be present in the DOM when called, it should be called after [`DOMContentLoaded`](https://developer.mozilla.org/en-US/docs/Web/Events/DOMContentLoaded). Depending on your page structure, it might be appropriate to hydrate the component when it's scrolled into view. diff --git a/components/x-interaction/rollup.js b/components/x-interaction/rollup.js index 922d28e45..bb83e4f4d 100644 --- a/components/x-interaction/rollup.js +++ b/components/x-interaction/rollup.js @@ -1,4 +1,4 @@ -const xRollup = require('@financial-times/x-rollup'); -const pkg = require('./package.json'); +const xRollup = require('@financial-times/x-rollup') +const pkg = require('./package.json') -xRollup({ input: './src/Interaction.jsx', pkg }); +xRollup({ input: './src/Interaction.jsx', pkg }) diff --git a/components/x-interaction/src/Hydrate.jsx b/components/x-interaction/src/Hydrate.jsx index 8bd0a0069..6367c4ab6 100644 --- a/components/x-interaction/src/Hydrate.jsx +++ b/components/x-interaction/src/Hydrate.jsx @@ -1,80 +1,89 @@ -import { h, render, Component } from '@financial-times/x-engine'; -import { getComponent } from './concerns/register-component'; +import { h, render, Component } from '@financial-times/x-engine' +import { getComponentByName } from './concerns/register-component' export class HydrationWrapper extends Component { render() { - const {Component, props, id} = this.props; - return <Component {...props} id={id} actionsRef={a => this.actions = a} />; + const { Component, props, id } = this.props + return <Component {...props} id={id} actionsRef={(a) => (this.actions = a)} /> } componentDidMount() { - if(this.props.wrapper) { - this.props.wrapper.addEventListener('x-interaction.trigger-action', this); + if (this.props.wrapper) { + this.props.wrapper.addEventListener('x-interaction.trigger-action', this) } } componentWillUnmount() { - if(this.props.wrapper) { - this.props.wrapper.removeEventListener('x-interaction.trigger-action', this); + if (this.props.wrapper) { + this.props.wrapper.removeEventListener('x-interaction.trigger-action', this) } } handleEvent(event) { - const {action, args = []} = event.detail; + const { action, args = [] } = event.detail - if(this.actions && this.actions[action]) { - this.actions[action](...args); + if (this.actions && this.actions[action]) { + this.actions[action](...args) } } } export function hydrate() { if (typeof window === 'undefined') { - throw new Error('x-interaction hydrate should only be called in the browser'); + throw new Error('x-interaction hydrate should only be called in the browser') } if (!('_xDashInteractionHydrationData' in window)) { throw new Error( `x-interaction hydrate was called without hydration data available. this can happen if you call hydrate before the serialised data is available, or if you're not including the hydration data with your server-rendered markup.` - ); + ) } - const serialiserOrdering = `make sure you're always outputting the serialiser's data in the same request that the serialiser was created. see https://financial-times.github.io/x-dash/components/x-interaction/#hydrating for more details.`; + const serialiserOrdering = `make sure you're always outputting the serialiser's data in the same request that the serialiser was created. see https://financial-times.github.io/x-dash/components/x-interaction/#hydrating for more details.` window._xDashInteractionHydrationData.forEach(({ id, component, props }) => { - const wrapper = document.querySelector(`[data-x-dash-id="${id}"]`); + const wrapper = document.querySelector(`[data-x-dash-id="${id}"]`) - if(!wrapper) { + if (!wrapper) { throw new Error( `component markup for ${id} was not found on the page. It was expected to be an instance of ${component}. it's likely that this hydration data is from another request. ${serialiserOrdering}` - ); + ) } - const Component = getComponent(component); + const Component = getComponentByName(component) - while (wrapper.firstChild) { - wrapper.removeChild(wrapper.firstChild); + if (!Component) { + throw new Error( + `x-interaction hydrate was called using unregistered component: ${component}. please verify you're registering your component using x-interaction's registerComponent function before attempting to hydrate.` + ) } - render(<HydrationWrapper {...{ - Component, - props, - id, - wrapper, - }} />, wrapper); - }); - - document.querySelectorAll('[data-x-dash-id]').forEach(element => { - const {xDashId} = element.dataset; - - const hasData = window._xDashInteractionHydrationData.some( - ({ id }) => id === xDashId - ); + while (wrapper.firstChild) { + wrapper.removeChild(wrapper.firstChild) + } - if(!hasData) { + render( + <HydrationWrapper + {...{ + Component, + props, + id, + wrapper + }} + />, + wrapper + ) + }) + + document.querySelectorAll('[data-x-dash-id]').forEach((element) => { + const { xDashId } = element.dataset + + const hasData = window._xDashInteractionHydrationData.some(({ id }) => id === xDashId) + + if (!hasData) { throw new Error( `found component markup for ${xDashId} without any hydration data. it's likely that its hydration data has been output in another request, or that the component was rendered after the serialisation data was output. ${serialiserOrdering}` - ); + ) } - }); + }) } diff --git a/components/x-interaction/src/HydrationData.jsx b/components/x-interaction/src/HydrationData.jsx index b727b0120..d8129d29f 100644 --- a/components/x-interaction/src/HydrationData.jsx +++ b/components/x-interaction/src/HydrationData.jsx @@ -1,11 +1,17 @@ -import { h } from '@financial-times/x-engine'; +import { h } from '@financial-times/x-engine' -export const HydrationData = ({serialiser}) => { - if(serialiser) { - const data = serialiser.flushHydrationData(); +export const HydrationData = ({ serialiser }) => { + if (serialiser) { + const data = serialiser.flushHydrationData() - return <script dangerouslySetInnerHTML={{__html: `window._xDashInteractionHydrationData = ${JSON.stringify(data)}`}} />; + return ( + <script + dangerouslySetInnerHTML={{ + __html: `window._xDashInteractionHydrationData = ${JSON.stringify(data)}` + }} + /> + ) } - return null; -}; + return null +} diff --git a/components/x-interaction/src/Interaction.jsx b/components/x-interaction/src/Interaction.jsx index a75163cbf..ab8200b97 100644 --- a/components/x-interaction/src/Interaction.jsx +++ b/components/x-interaction/src/Interaction.jsx @@ -1,70 +1,65 @@ -import { h } from '@financial-times/x-engine'; -import { InteractionClass } from './InteractionClass'; -import { InteractionSSR } from './InteractionSSR'; -import wrapComponentName from './concerns/wrap-component-name'; -import { registerComponent } from './concerns/register-component'; +import { h } from '@financial-times/x-engine' +import { InteractionClass } from './InteractionClass' +import { InteractionSSR } from './InteractionSSR' +import wrapComponentName from './concerns/wrap-component-name' // use the class version for clientside and the static version for server -const Interaction = typeof window !== 'undefined' ? InteractionClass : InteractionSSR; +const Interaction = typeof window !== 'undefined' ? InteractionClass : InteractionSSR -const invoke = (fnOrObj, ...args) => typeof fnOrObj === 'function' - ? fnOrObj(...args) - : fnOrObj; +const invoke = (fnOrObj, ...args) => (typeof fnOrObj === 'function' ? fnOrObj(...args) : fnOrObj) -export const withActions = (getActions, getDefaultState = {}) => (Component) => { - const _wraps = { getActions, getDefaultState, Component }; +export const withActions = + (getActions, getDefaultState = {}) => + (Component) => { + const _wraps = { getActions, getDefaultState, Component } - // if the component we're wrapping is already wrapped, we don't want - // to wrap it further. so, discard its wrapper and rewrap the original - // component with the new actions on top - if(Component._wraps) { - const wrappedGetActions = Component._wraps.getActions; - const wrappedGetDefaultState = Component._wraps.getDefaultState; + // if the component we're wrapping is already wrapped, we don't want + // to wrap it further. so, discard its wrapper and rewrap the original + // component with the new actions on top + if (Component._wraps) { + const wrappedGetActions = Component._wraps.getActions + const wrappedGetDefaultState = Component._wraps.getDefaultState - Component = Component._wraps.Component; + Component = Component._wraps.Component - getActions = initialState => Object.assign( - invoke(wrappedGetActions, initialState), - invoke(_wraps.getActions, initialState) - ); + getActions = (initialState) => + Object.assign(invoke(wrappedGetActions, initialState), invoke(_wraps.getActions, initialState)) - getDefaultState = initialState => Object.assign( - invoke(wrappedGetDefaultState, initialState), - invoke(_wraps.getDefaultState, initialState) - ); - } - - function Enhanced({ - id, - actionsRef, - serialiser, - ...initialState - }) { - const actions = invoke(getActions, initialState); - const defaultState = invoke(getDefaultState, initialState); + getDefaultState = (initialState) => + Object.assign( + invoke(wrappedGetDefaultState, initialState), + invoke(_wraps.getDefaultState, initialState) + ) + } - return <Interaction {...{ - id, - Component, - initialState: Object.assign({}, defaultState, initialState), - actionsRef, - serialiser, - actions, - }} />; - } + function Enhanced({ id, actionsRef, serialiser, ...initialState }) { + const actions = invoke(getActions, initialState) + const defaultState = invoke(getDefaultState, initialState) - // store what we're wrapping for later wrappers to replace - Enhanced._wraps = _wraps; + return ( + <Interaction + {...{ + id, + Component, + initialState: Object.assign({}, defaultState, initialState), + actionsRef, + serialiser, + actions + }} + /> + ) + } - // set the displayName of the Enhanced component for debugging - wrapComponentName(Component, Enhanced); + // store what we're wrapping for later wrappers to replace + Enhanced._wraps = _wraps - // register the component under its name for later hydration from serialised data - registerComponent(Enhanced); + // set the displayName of the Enhanced component for debugging + wrapComponentName(Component, Enhanced) - return Enhanced; -}; + return Enhanced + } -export { hydrate } from './Hydrate'; -export { HydrationData } from './HydrationData'; -export { Serialiser } from './concerns/serialiser'; +export { hydrate } from './Hydrate' +export { HydrationData } from './HydrationData' +export { Serialiser } from './concerns/serialiser' +export { registerComponent } from './concerns/register-component' diff --git a/components/x-interaction/src/InteractionClass.jsx b/components/x-interaction/src/InteractionClass.jsx index a5288fd93..ea13b048b 100644 --- a/components/x-interaction/src/InteractionClass.jsx +++ b/components/x-interaction/src/InteractionClass.jsx @@ -1,58 +1,63 @@ -import { h, Component } from '@financial-times/x-engine'; -import { InteractionRender } from './InteractionRender'; -import mapValues from './concerns/map-values'; +import { h, Component } from '@financial-times/x-engine' +import { InteractionRender } from './InteractionRender' +import mapValues from './concerns/map-values' export class InteractionClass extends Component { constructor(props, ...args) { - super(props, ...args); + super(props, ...args) this.state = { state: {}, - inFlight: 0, - }; + inFlight: 0 + } + + this.createActions(props) + } + createActions(props) { this.actions = mapValues(props.actions, (func) => async (...args) => { // mark as loading one microtask later. if the action is synchronous then // setting loading back to false will happen in the same microtask and no // additional render will be scheduled. Promise.resolve().then(() => { - this.setState(({ inFlight }) => ({ inFlight: inFlight + 1 })); - }); - - const stateUpdate = await Promise.resolve(func(...args)); - - const nextState = typeof stateUpdate === 'function' - ? Object.assign( - this.state.state, - await Promise.resolve(stateUpdate(Object.assign( - {}, - props.initialState, - this.state.state - ))) - ) - : Object.assign(this.state.state, stateUpdate); + this.setState(({ inFlight }) => ({ inFlight: inFlight + 1 })) + }) + + const stateUpdate = await Promise.resolve(func(...args)) - return new Promise(resolve => - this.setState({state: nextState}, () => ( + const nextState = + typeof stateUpdate === 'function' + ? Object.assign( + this.state.state, + await Promise.resolve(stateUpdate(Object.assign({}, props.initialState, this.state.state))) + ) + : Object.assign(this.state.state, stateUpdate) + + return new Promise((resolve) => + this.setState({ state: nextState }, () => this.setState(({ inFlight }) => ({ inFlight: inFlight - 1 }), resolve) - )) - ); - }); + ) + ) + }) + } + + componentWillReceiveProps(props) { + this.createActions(props) } componentDidMount() { - if(this.props.actionsRef) { - this.props.actionsRef(this.actions); + if (this.props.actionsRef) { + this.props.actionsRef(this.actions) } } componentWillUnmount() { - if(this.props.actionsRef) { - this.props.actionsRef(null); + if (this.props.actionsRef) { + this.props.actionsRef(null) } } render() { - return <InteractionRender {...this.props} {...this.state} actions={this.actions} />; + return <InteractionRender {...this.props} {...this.state} actions={this.actions} /> } } diff --git a/components/x-interaction/src/InteractionRender.jsx b/components/x-interaction/src/InteractionRender.jsx index 1f33b27c8..ce12e805c 100644 --- a/components/x-interaction/src/InteractionRender.jsx +++ b/components/x-interaction/src/InteractionRender.jsx @@ -1,12 +1,5 @@ -import { h } from '@financial-times/x-engine'; +import { h } from '@financial-times/x-engine' -export const InteractionRender = ({ - id, - actions, - state, - initialState, - inFlight, - Component, -}) => ( +export const InteractionRender = ({ id, actions, state, initialState, inFlight, Component }) => ( <Component {...initialState} {...state} {...{ id, actions }} isLoading={inFlight > 0} /> -); +) diff --git a/components/x-interaction/src/InteractionSSR.jsx b/components/x-interaction/src/InteractionSSR.jsx index c5dc81eab..747491eec 100644 --- a/components/x-interaction/src/InteractionSSR.jsx +++ b/components/x-interaction/src/InteractionSSR.jsx @@ -1,25 +1,27 @@ -import { h } from '@financial-times/x-engine'; -import getComponentName from './concerns/get-component-name'; -import shortId from '@quarterto/short-id'; +import { h } from '@financial-times/x-engine' +import { getComponentName } from './concerns/register-component' +import shortId from '@quarterto/short-id' -import {InteractionRender} from './InteractionRender'; +import { InteractionRender } from './InteractionRender' export const InteractionSSR = ({ initialState, Component, id = `${getComponentName(Component)}-${shortId()}`, actions, - serialiser, + serialiser }) => { - if(serialiser) { + if (serialiser) { serialiser.addData({ id, Component, props: initialState - }); + }) } - return <div data-x-dash-id={id}> - <InteractionRender {...{ Component, initialState, id, actions }} /> - </div>; -}; + return ( + <div data-x-dash-id={id}> + <InteractionRender {...{ Component, initialState, id, actions }} /> + </div> + ) +} diff --git a/components/x-interaction/src/concerns/get-component-name.js b/components/x-interaction/src/concerns/get-component-name.js deleted file mode 100644 index f72a088bf..000000000 --- a/components/x-interaction/src/concerns/get-component-name.js +++ /dev/null @@ -1,6 +0,0 @@ -const getComponentName = Component => - Component.displayName - || Component.name - || 'Unknown'; - -export default getComponentName; diff --git a/components/x-interaction/src/concerns/map-values.js b/components/x-interaction/src/concerns/map-values.js index d70398691..a7fe8e2d2 100644 --- a/components/x-interaction/src/concerns/map-values.js +++ b/components/x-interaction/src/concerns/map-values.js @@ -1,8 +1,10 @@ -const mapValues = (obj, fn) => Object.keys(obj).reduce( - (mapped, key) => Object.assign(mapped, { - [key]: fn(obj[key], key, obj), - }), - {} -); +const mapValues = (obj, fn) => + Object.keys(obj).reduce( + (mapped, key) => + Object.assign(mapped, { + [key]: fn(obj[key], key, obj) + }), + {} + ) -export default mapValues; +export default mapValues diff --git a/components/x-interaction/src/concerns/register-component.js b/components/x-interaction/src/concerns/register-component.js index 7b85f5d3e..047da3f97 100644 --- a/components/x-interaction/src/concerns/register-component.js +++ b/components/x-interaction/src/concerns/register-component.js @@ -1,9 +1,34 @@ -const registeredComponents = {}; +const registeredComponents = {} +const xInteractionName = Symbol('x-interaction-name') -export function registerComponent(component) { - registeredComponents[component.wrappedDisplayName] = component; +export function registerComponent(Component, name) { + if (registeredComponents[name]) { + throw new Error( + `x-interaction a component has already been registered under that name, please use another name.` + ) + } + + if (!Component._wraps) { + throw new Error( + `only x-interaction wrapped components (i.e. the component returned from withActions) can be registered` + ) + } + + Component[xInteractionName] = name + // add name to original component so we can access the wrapper from the original + Component._wraps.Component[xInteractionName] = name + registeredComponents[name] = Component +} + +export function getComponent(Component) { + const name = Component[xInteractionName] + return registeredComponents[name] +} + +export function getComponentByName(name) { + return registeredComponents[name] } -export function getComponent(name) { - return registeredComponents[name]; +export function getComponentName(Component) { + return Component[xInteractionName] || 'Unknown' } diff --git a/components/x-interaction/src/concerns/serialiser.js b/components/x-interaction/src/concerns/serialiser.js index 8297b2aec..0ce0d99b1 100644 --- a/components/x-interaction/src/concerns/serialiser.js +++ b/components/x-interaction/src/concerns/serialiser.js @@ -1,35 +1,47 @@ -import { h, render } from '@financial-times/x-engine'; -import getComponentName from './get-component-name'; -import { HydrationData } from '../HydrationData'; +import { h, render } from '@financial-times/x-engine' +import { HydrationData } from '../HydrationData' +import { getComponent, getComponentName } from './register-component' export class Serialiser { constructor() { - this.destroyed = false; - this.data = []; + this.destroyed = false + this.data = [] } - addData({id, Component, props}) { - if(this.destroyed) { - throw new Error(`an interaction component was rendered after flushHydrationData was called. ensure you're outputting the hydration data after rendering every component`); + addData({ id, Component, props }) { + const registeredComponent = getComponent(Component) + + if (!registeredComponent) { + throw new Error( + `a Serialiser's addData was called for an unregistered component. ensure you're registering your component before attempting to output the hydration data` + ) + } + + if (this.destroyed) { + throw new Error( + `an interaction component was rendered after flushHydrationData was called. ensure you're outputting the hydration data after rendering every component` + ) } this.data.push({ id, component: getComponentName(Component), - props, - }); + props + }) } flushHydrationData() { - if(this.destroyed) { - throw new Error(`a Serialiser's flushHydrationData was called twice. ensure you're not reusing a Serialiser between requests`); + if (this.destroyed) { + throw new Error( + `a Serialiser's flushHydrationData was called twice. ensure you're not reusing a Serialiser between requests` + ) } - this.destroyed = true; - return this.data; + this.destroyed = true + return this.data } outputHydrationData() { - return render(h(HydrationData, {serialiser: this})); + return render(h(HydrationData, { serialiser: this })) } } diff --git a/components/x-interaction/src/concerns/wrap-component-name.js b/components/x-interaction/src/concerns/wrap-component-name.js index fa5a59359..88cdcc2d3 100644 --- a/components/x-interaction/src/concerns/wrap-component-name.js +++ b/components/x-interaction/src/concerns/wrap-component-name.js @@ -1,9 +1,7 @@ -import getComponentName from './get-component-name'; - function wrapComponentName(Component, Enhanced) { - const originalDisplayName = getComponentName(Component); - Enhanced.displayName = `withActions(${originalDisplayName})`; - Enhanced.wrappedDisplayName = originalDisplayName; + const originalDisplayName = Component.displayName || Component.name + Enhanced.displayName = `withActions(${originalDisplayName})` + Enhanced.wrappedDisplayName = originalDisplayName } -export default wrapComponentName; +export default wrapComponentName diff --git a/components/x-live-blog-post/.bowerrc b/components/x-live-blog-post/.bowerrc new file mode 100644 index 000000000..59e9a5925 --- /dev/null +++ b/components/x-live-blog-post/.bowerrc @@ -0,0 +1,8 @@ +{ + "registry": { + "search": [ + "https://origami-bower-registry.ft.com", + "https://registry.bower.io" + ] + } +} diff --git a/components/x-live-blog-post/.npmignore b/components/x-live-blog-post/.npmignore new file mode 100644 index 000000000..a44a9e753 --- /dev/null +++ b/components/x-live-blog-post/.npmignore @@ -0,0 +1,3 @@ +src/ +stories/ +rollup.js diff --git a/components/x-live-blog-post/bower.json b/components/x-live-blog-post/bower.json new file mode 100644 index 000000000..586366606 --- /dev/null +++ b/components/x-live-blog-post/bower.json @@ -0,0 +1,11 @@ +{ + "name": "@financial-times/x-live-blog-post", + "description": "", + "main": "dist/LiveBlogPost.cjs.js", + "private": true, + "dependencies": { + "o-colors": "^5.2.4", + "o-spacing": "^2.0.4", + "o-typography": "^6.4.0" + } +} diff --git a/components/x-live-blog-post/package.json b/components/x-live-blog-post/package.json new file mode 100644 index 000000000..e21693b5c --- /dev/null +++ b/components/x-live-blog-post/package.json @@ -0,0 +1,39 @@ +{ + "name": "@financial-times/x-live-blog-post", + "version": "1.0.0", + "description": "", + "main": "dist/LiveBlogPost.cjs.js", + "module": "dist/LiveBlogPost.esm.js", + "browser": "dist/LiveBlogPost.es5.js", + "style": "dist/LiveBlogPost.css", + "scripts": { + "prepare": "bower install && npm run build", + "build": "node rollup.js", + "start": "node rollup.js --watch" + }, + "keywords": [ + "x-dash" + ], + "author": "", + "license": "ISC", + "dependencies": { + "@financial-times/x-engine": "file:../../packages/x-engine" + }, + "devDependencies": { + "@financial-times/x-test-utils": "file:../../packages/x-test-utils", + "@financial-times/x-rollup": "file:../../packages/x-rollup", + "bower": "^1.8.8", + "node-sass": "^4.9.2" + }, + "repository": { + "type": "git", + "url": "https://github.com/Financial-Times/x-dash.git" + }, + "homepage": "https://github.com/Financial-Times/x-dash/tree/HEAD/components/x-live-blog-post", + "engines": { + "node": "12.x" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/components/x-live-blog-post/readme.md b/components/x-live-blog-post/readme.md new file mode 100644 index 000000000..b3d189401 --- /dev/null +++ b/components/x-live-blog-post/readme.md @@ -0,0 +1,56 @@ +# x-live-blog-post + +This module displays a live blog post with title, body, timestamp and share buttons. + + +## Installation + +This module is supported on Node 12 and is distributed on npm. + +```bash +npm install --save @financial-times/x-live-blog-post +``` + +The [`x-engine`][engine] module is used to inject your chosen runtime into the component. Please read the `x-engine` documentation first if you are consuming `x-` components for the first time in your application. + +[engine]: https://github.com/Financial-Times/x-dash/tree/HEAD/packages/x-engine + + +## Usage + +The components provided by this module are all functions that expect a map of [properties](#properties). They can be used with vanilla JavaScript or JSX (If you are not familiar check out [WTF is JSX][jsx-wtf] first). For example if you were writing your application using React you could use the component like this: + +```jsx +import React from 'react'; +import { LiveBlogPost } from '@financial-times/x-live-blog-post'; + +// A == B == C +const a = LiveBlogPost(props); +const b = <LiveBlogPost {...props} />; +const c = React.createElement(LiveBlogPost, props); +``` + +All `x-` components are designed to be compatible with a variety of runtimes, not just React. Check out the [`x-engine`][engine] documentation for a list of recommended libraries and frameworks. + +[jsx-wtf]: https://jasonformat.com/wtf-is-jsx/ + +### Properties + +Deprecated properties should only be used when data comes from the Wordpress CMS. +Once we decommission live blogs powered by Wordpress these properties can be removed. + +Feature | Type | Notes +--------------------|--------|---------------------------- +`id` | String | Unique id to reference the content +`postId` | String | Deprecated - Unique id to reference the content +`title` | String | Title of the content +`bodyHTML` | String | Body of the content +`byline` | String | Byline for the post, sometimes used to render the author's name. +`content` | String | Deprecated - Body of the content +`isBreakingNews` | Bool | When `true` displays "breaking news" tag +`publishedDate` | String | ISO timestamp of publish date +`publishedTimestamp`| String | Deprecated - ISO timestamp of publish date +`articleUrl` | String | Url of the main article that includes this post +`showShareButtons` | Bool | default: `false` - Shows social media share buttons when `true` +`backToTop` | String | Function | Shows the back to top link at the bottom of posts and manages navigating to `selected top` with a javascript function or a hashed href (string). If this prop is a string it will rely on standard browser behaviour to navigate to the element `id` provided that represents the top. If this prop is a function then that function should control the experience of navigating/scrolling to the top position. When using a function please call event.preventDefault() at the top level. + diff --git a/components/x-live-blog-post/rollup.js b/components/x-live-blog-post/rollup.js new file mode 100644 index 000000000..53b33f837 --- /dev/null +++ b/components/x-live-blog-post/rollup.js @@ -0,0 +1,4 @@ +const xRollup = require('@financial-times/x-rollup') +const pkg = require('./package.json') + +xRollup({ input: './src/LiveBlogPost.jsx', pkg }) diff --git a/components/x-live-blog-post/src/LiveBlogPost.jsx b/components/x-live-blog-post/src/LiveBlogPost.jsx new file mode 100644 index 000000000..e56a59b4b --- /dev/null +++ b/components/x-live-blog-post/src/LiveBlogPost.jsx @@ -0,0 +1,93 @@ +import { h, Fragment } from '@financial-times/x-engine' +import ShareButtons from './ShareButtons' +import Timestamp from './Timestamp' +import styles from './LiveBlogPost.scss' + +/** + * Triggers a page scroll depending on what the type of `backToTopProp` is. + * A function will be called onClick. + * A string with be transformed to a hashed href. e.g backToTopProp="top" becomes "#top" + * + * @param {(function | string)} backToTopProp + * @returns + */ +function generateBackToTopComponent(backToTopProp) { + if (!backToTopProp) { + return + } + + if (typeof backToTopProp === 'string') { + const processTopRef = (ref) => { + return ref.includes('#') ? ref : `#${ref}` + } + return ( + <a + href={processTopRef(backToTopProp)} + aria-labelledby="Back to top" + className={styles['live-blog-post-controls__back-to-top-link']}> + Back to top + </a> + ) + } + + if (typeof backToTopProp === 'function') { + return ( + <button + onClick={backToTopProp} + aria-labelledby="Back to top" + className={styles['live-blog-post-controls__back-to-top-button']}> + Back to top + </button> + ) + } +} + +const LiveBlogPost = (props) => { + const { + id, + postId, // Remove once wordpress is no longer in use + title, + content, // Remove once wordpress is no longer in use + bodyHTML, + publishedTimestamp, // Remove once wordpress is no longer in use + publishedDate, + isBreakingNews, // Remove once wordpress is no longer in use + standout = {}, + articleUrl, + showShareButtons = false, + byline, + ad, + backToTop + } = props + + const showBreakingNewsLabel = standout.breakingNews || isBreakingNews + + const BackToTopComponent = generateBackToTopComponent(backToTop) + + return ( + <article + className={`live-blog-post ${styles['live-blog-post']}`} + data-trackable="live-post" + id={`post-${id || postId}`} + data-x-component="live-blog-post"> + <div className="live-blog-post__meta"> + <Timestamp publishedTimestamp={publishedDate || publishedTimestamp} /> + </div> + {showBreakingNewsLabel && <div className={styles['live-blog-post__breaking-news']}>Breaking news</div>} + {title && <h2 className={styles['live-blog-post__title']}>{title}</h2>} + {byline && <p className={styles['live-blog-post__byline']}>{byline}</p>} + <div + className={`${styles['live-blog-post__body']} n-content-body article--body`} + dangerouslySetInnerHTML={{ __html: bodyHTML || content }} + /> + <div className={styles['live-blog-post__controls']}> + {showShareButtons && <ShareButtons postId={id || postId} articleUrl={articleUrl} title={title} />} + {Boolean(BackToTopComponent) && <Fragment>{BackToTopComponent}</Fragment>} + </div> + + {ad} + </article> + ) +} + +export { LiveBlogPost } diff --git a/components/x-live-blog-post/src/LiveBlogPost.scss b/components/x-live-blog-post/src/LiveBlogPost.scss new file mode 100644 index 000000000..62731fec6 --- /dev/null +++ b/components/x-live-blog-post/src/LiveBlogPost.scss @@ -0,0 +1,102 @@ +@import 'o-typography/main'; +@import 'o-spacing/main'; +@import 'o-colors/main'; + +.live-blog-post { + border-bottom: 1px solid oColorsByName('black-20'); + margin-top: oSpacingByName('s8'); + color: oColorsByName('black-90'); + padding-bottom: oSpacingByName('s8'); +} + +.live-blog-post__title { + @include oTypographyDisplay($scale: 5); + margin-top: oSpacingByName('s4'); + margin-bottom: oSpacingByName('s1'); +} + +.live-blog-post__breaking-news + .live-blog-post__title { + margin-top: oSpacingByName('s1'); +} + +.live-blog-post__byline { + @include oTypographySans($scale: 0, $line-height: 1.25); + color: oColorsByName('black-80'); + margin-top: oSpacingByName('s3'); + margin-bottom: 20px; +} + +.live-blog-post__body { + @include oTypographySerif($scale: 1, $line-height: 1.55); + margin-top: oSpacingByName('s6'); +} + +.live-blog-post__body ul { + @include oTypographyList('unordered'); +} + +.live-blog-post__timestamp { + @include oTypographySans($scale: -1, $weight: 'semibold'); + text-transform: uppercase; +} + +.live-blog-post__timestamp-exact-time { + @include oTypographySans($scale: -1, $weight: 'light'); + color: oColorsByName('black-60'); + padding-left: oSpacingByName('s2'); +} + +.live-blog-post__timestamp-container:after { + content: ''; + display: block; + width: oSpacingByName('s4'); + border-bottom: 4px solid oColorsByName('black-90'); + padding-top: oSpacingByName('s1'); +} + +.live-blog-post__breaking-news { + @include oTypographySans($scale: -2); + color: oColorsByName('crimson'); + text-transform: uppercase; + margin-top: oSpacingByName('s4'); +} + +.live-blog-post__breaking-news:before { + content: ''; + display: inline-block; + width: oSpacingByName('s2'); + height: oSpacingByName('s2'); + margin-right: oSpacingByName('s1'); + border-radius: 50%; + background-color: oColorsByName('crimson'); +} + +.live-blog-post__controls { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + margin-top: oSpacingByName('s6'); +} + +.live-blog-post__controls .live-blog-post-controls__back-to-top-link, +.live-blog-post__controls .live-blog-post-controls__back-to-top-button { + @include oTypographySans($scale: 1); + color: oColorsByName('teal'); + text-decoration: underline; + margin-left: auto; +} + +.live-blog-post__controls .live-blog-post-controls__back-to-top-button { + background: unset; + border: unset; +} + +.live-blog-post__controls .live-blog-post-controls__back-to-top-button:hover { + cursor: pointer; +} + +.live-blog-post:first-child .live-blog-post-controls__back-to-top-link, +.live-blog-post:first-child .live-blog-post-controls__back-to-top-button { + display: none; +} \ No newline at end of file diff --git a/components/x-live-blog-post/src/ShareButtons.jsx b/components/x-live-blog-post/src/ShareButtons.jsx new file mode 100644 index 000000000..296a8347c --- /dev/null +++ b/components/x-live-blog-post/src/ShareButtons.jsx @@ -0,0 +1,63 @@ +import { h } from '@financial-times/x-engine' + +export default ({ postId, articleUrl, title }) => { + const shareUrl = articleUrl ? new URL(articleUrl) : null + if (shareUrl) { + shareUrl.hash = `post-${postId}` + } + + const twitterUrl = `https://twitter.com/intent/tweet?url=${encodeURIComponent( + shareUrl + )}&text=${encodeURIComponent(title)}&via=financialtimes` + const facebookUrl = `http://www.facebook.com/sharer.php?u=${encodeURIComponent( + shareUrl + )}&t=${encodeURIComponent(title)}` + const linkedInUrl = `http://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent( + shareUrl + )}&title=${encodeURIComponent(title)}&source=Financial+Times` + + return ( + <div> + <div + data-o-component="o-share" + data-o-share-location={`live-blog-post-${postId}`} + className="o-share o-share--small"> + <ul data-toolbar="share"> + <li className="o-share__action" data-share="twitter"> + <a + className="o-share__icon o-share__icon--twitter" + rel="noopener" + href={twitterUrl} + data-trackable="twitter"> + <span className="o-share__text" aria-label={`Share ${title} on Twitter`}> + Share on Twitter (opens new window) + </span> + </a> + </li> + <li className="o-share__action" data-share="facebook"> + <a + className="o-share__icon o-share__icon--facebook" + rel="noopener" + href={facebookUrl} + data-trackable="facebook"> + <span className="o-share__text" aria-label={`Share ${title} on Facebook`}> + Share on Facebook (opens new window) + </span> + </a> + </li> + <li className="o-share__action" data-share="linkedin"> + <a + className="o-share__icon o-share__icon--linkedin" + rel="noopener" + href={linkedInUrl} + data-trackable="linkedin"> + <span className="o-share__text" aria-label={`Share ${title} on LinkedIn`}> + Share on LinkedIn (opens new window) + </span> + </a> + </li> + </ul> + </div> + </div> + ) +} diff --git a/components/x-live-blog-post/src/Timestamp.jsx b/components/x-live-blog-post/src/Timestamp.jsx new file mode 100644 index 000000000..7e6944ffb --- /dev/null +++ b/components/x-live-blog-post/src/Timestamp.jsx @@ -0,0 +1,44 @@ +import { h } from '@financial-times/x-engine' +import styles from './LiveBlogPost.scss' + +export default ({ publishedTimestamp }) => { + const now = new Date() + const oneDay = 24 * 60 * 60 * 1000 + const date = new Date(publishedTimestamp) + const formatted = date.toLocaleString() + + let format + let showExactTime + + if (now.getTime() - date.getTime() < oneDay) { + // display published date in 'xx minutes ago' format + // and render exact time next to it + format = 'time-ago-no-seconds' + showExactTime = true + } else { + // don't display time string if the post is older than one day + // because it is already included in the formatted timestamp + format = 'MMM dd, HH:mm' + showExactTime = false + } + + return ( + <div className={styles['live-blog-post__timestamp-container']}> + <time + data-o-component="o-date" + className={`o-date ${styles['live-blog-post__timestamp']}`} + dateTime={publishedTimestamp} + data-o-date-format={format} + itemProp="datePublished"> + <span data-o-date-printer>{formatted}</span> + {showExactTime && ( + <span + className={`o-date ${styles['live-blog-post__timestamp-exact-time']}`} + data-o-date-printer + data-o-date-format="HH:mm" + itemProp="exactTime"></span> + )} + </time> + </div> + ) +} diff --git a/components/x-live-blog-post/src/__tests__/LiveBlogPost.test.jsx b/components/x-live-blog-post/src/__tests__/LiveBlogPost.test.jsx new file mode 100644 index 000000000..be108307a --- /dev/null +++ b/components/x-live-blog-post/src/__tests__/LiveBlogPost.test.jsx @@ -0,0 +1,193 @@ +const { h } = require('@financial-times/x-engine') +const { mount } = require('@financial-times/x-test-utils/enzyme') + +import { LiveBlogPost } from '../LiveBlogPost' + +const breakingNewsWordpress = { + postId: '12345', + title: 'Test', + content: '<p>Test</p>', + publishedTimestamp: new Date().toISOString(), + isBreakingNews: true, + articleUrl: 'Https://www.ft.com', + showShareButtons: true +} + +const regularPostWordpress = { + postId: '12345', + title: 'Test title', + content: '<p><i>Test body</i></p>', + publishedTimestamp: new Date().toISOString(), + isBreakingNews: false, + articleUrl: 'Https://www.ft.com', + showShareButtons: true +} + +const breakingNewsSpark = { + id: '12345', + title: 'Test', + byline: 'Test author', + bodyHTML: '<p>Test</p>', + publishedDate: new Date().toISOString(), + standout: { + breakingNews: true + }, + articleUrl: 'Https://www.ft.com', + showShareButtons: true +} + +const regularPostSpark = { + id: '12345', + title: 'Test title', + byline: 'Test author', + bodyHTML: '<p><i>Test body</i></p>', + publishedDate: new Date().toISOString(), + isBreakingNews: false, + articleUrl: 'Https://www.ft.com', + showShareButtons: true +} + +const backToTopPostSpark = { + id: '12345', + title: 'Test title', + byline: 'Test author', + bodyHTML: '<p><i>Test body</i></p>', + publishedDate: new Date().toISOString(), + isBreakingNews: false, + articleUrl: 'Https://www.ft.com', + showShareButtons: true, + backToTop: () => {} +} + +describe('x-live-blog-post', () => { + describe('Spark cms', () => { + describe('title property exists', () => { + it('renders title', () => { + const liveBlogPost = mount(<LiveBlogPost {...regularPostSpark} />) + + expect(liveBlogPost.html()).toContain('Test title') + expect(liveBlogPost.html()).toContain('</h2>') + }) + }) + + describe('title property is missing', () => { + let postWithoutTitle = Object.assign({}, regularPostSpark) + + beforeAll(() => { + delete postWithoutTitle.title + }) + + it('skips rendering of the title', () => { + const liveBlogPost = mount(<LiveBlogPost {...postWithoutTitle} />) + + expect(liveBlogPost.html()).not.toContain('</h2>') + }) + }) + + it('renders timestamp', () => { + const liveBlogPost = mount(<LiveBlogPost {...regularPostSpark} />) + + expect(liveBlogPost.html()).toContain(regularPostSpark.publishedTimestamp) + }) + + it('renders byline', () => { + const liveBlogPost = mount(<LiveBlogPost {...regularPostSpark} />) + + expect(liveBlogPost.html()).toContain('Test author') + }) + + it('renders sharing buttons', () => { + const liveBlogPost = mount(<LiveBlogPost {...regularPostSpark} />) + + expect(liveBlogPost.html()).toContain('o-share__icon--linkedin') + }) + + it('renders breaking news tag when the post is a breaking news', () => { + const liveBlogPost = mount(<LiveBlogPost {...breakingNewsSpark} />) + + expect(liveBlogPost.html()).toContain('Breaking news') + }) + + it('does not render breaking news tag when the post is not breaking news', () => { + const liveBlogPost = mount(<LiveBlogPost {...regularPostSpark} />) + + expect(liveBlogPost.html()).not.toContain('Breaking news') + }) + + it('does not escape content html', () => { + const liveBlogPost = mount(<LiveBlogPost {...regularPostSpark} />) + + expect(liveBlogPost.html()).toContain('<p><i>Test body</i></p>') + }) + + describe('Back to top post', () => { + it('renders back to top link', () => { + const liveBlogPost = mount(<LiveBlogPost {...backToTopPostSpark} />) + + expect(liveBlogPost.html()).toContain('Back to top') + expect(liveBlogPost.html()).toContain('</a>') + }) + }) + }) + + describe('Wordpress cms', () => { + describe('title property exists', () => { + it('renders title', () => { + const liveBlogPost = mount(<LiveBlogPost {...regularPostWordpress} />) + + expect(liveBlogPost.html()).toContain('Test title') + expect(liveBlogPost.html()).toContain('</h2>') + }) + }) + + describe('title property is missing', () => { + let postWithoutTitle = Object.assign({}, regularPostWordpress) + + beforeAll(() => { + delete postWithoutTitle.title + }) + + it('skips rendering of the title', () => { + const liveBlogPost = mount(<LiveBlogPost {...postWithoutTitle} />) + + expect(liveBlogPost.html()).not.toContain('</h2>') + }) + }) + + it('renders timestamp', () => { + const liveBlogPost = mount(<LiveBlogPost {...regularPostWordpress} />) + + expect(liveBlogPost.html()).toContain(regularPostWordpress.publishedTimestamp) + }) + + it('renders sharing buttons', () => { + const liveBlogPost = mount(<LiveBlogPost {...regularPostWordpress} />) + + expect(liveBlogPost.html()).toContain('o-share__icon--linkedin') + }) + + it('renders breaking news tag when the post is a breaking news', () => { + const liveBlogPost = mount(<LiveBlogPost {...breakingNewsWordpress} />) + + expect(liveBlogPost.html()).toContain('Breaking news') + }) + + it('does not render breaking news tag when the post is not breaking news', () => { + const liveBlogPost = mount(<LiveBlogPost {...regularPostWordpress} />) + + expect(liveBlogPost.html()).not.toContain('Breaking news') + }) + + it('does not escape content html', () => { + const liveBlogPost = mount(<LiveBlogPost {...regularPostWordpress} />) + + expect(liveBlogPost.html()).toContain('<p><i>Test body</i></p>') + }) + }) + + it('adds a data-x-component attribute', () => { + const liveBlogPost = mount(<LiveBlogPost {...regularPostSpark} />) + + expect(liveBlogPost.html()).toContain('data-x-component="live-blog-post"') + }) +}) diff --git a/components/x-live-blog-post/src/__tests__/ShareButtons.test.jsx b/components/x-live-blog-post/src/__tests__/ShareButtons.test.jsx new file mode 100644 index 000000000..eac1c8d5a --- /dev/null +++ b/components/x-live-blog-post/src/__tests__/ShareButtons.test.jsx @@ -0,0 +1,41 @@ +const { h } = require('@financial-times/x-engine') +const { mount } = require('@financial-times/x-test-utils/enzyme') + +import ShareButtons from '../ShareButtons' + +const props = { + postId: '12345', + title: 'Test title', + articleUrl: 'https://www.ft.com' +} + +describe('x-live-blog-post', () => { + describe('ShareButtons', () => { + it('renders correct twitter url', () => { + const shareButtons = mount(<ShareButtons {...props} />) + const twitterButton = shareButtons.find('.o-share__icon--twitter').first() + + expect(twitterButton.prop('href')).toEqual( + 'https://twitter.com/intent/tweet?url=https%3A%2F%2Fwww.ft.com%2F%23post-12345&text=Test%20title&via=financialtimes' + ) + }) + + it('renders correct facebook url', () => { + const shareButtons = mount(<ShareButtons {...props} />) + const facebookButton = shareButtons.find('.o-share__icon--facebook').first() + + expect(facebookButton.prop('href')).toEqual( + 'http://www.facebook.com/sharer.php?u=https%3A%2F%2Fwww.ft.com%2F%23post-12345&t=Test%20title' + ) + }) + + it('renders correct linkedin url', () => { + const shareButtons = mount(<ShareButtons {...props} />) + const linkedinButton = shareButtons.find('.o-share__icon--linkedin').first() + + expect(linkedinButton.prop('href')).toEqual( + 'http://www.linkedin.com/shareArticle?mini=true&url=https%3A%2F%2Fwww.ft.com%2F%23post-12345&title=Test%20title&source=Financial+Times' + ) + }) + }) +}) diff --git a/components/x-live-blog-post/src/__tests__/Timestamp.test.jsx b/components/x-live-blog-post/src/__tests__/Timestamp.test.jsx new file mode 100644 index 000000000..7443ff891 --- /dev/null +++ b/components/x-live-blog-post/src/__tests__/Timestamp.test.jsx @@ -0,0 +1,58 @@ +const { h } = require('@financial-times/x-engine') +const { mount } = require('@financial-times/x-test-utils/enzyme') + +import Timestamp from '../Timestamp' + +function twoDaysAgo() { + const now = new Date() + const twoDays = 2 * 24 * 60 * 60 * 1000 + return new Date(now.getTime() - twoDays) +} + +function twoMinutesAgo() { + const now = new Date() + const twoMinutes = 2 * 60 * 1000 + return new Date(now.getTime() - twoMinutes) +} + +describe('x-live-blog-post', () => { + describe('TimeStamp', () => { + describe('when timestamp is older than 24 hours', () => { + it('renders the time in MMM dd, HH:mm', () => { + const date = twoDaysAgo() + const timestamp = mount(<Timestamp publishedTimestamp={date.toISOString()} />) + const dateEl = timestamp.find('time[data-o-component="o-date"]') + + expect(dateEl.prop('data-o-date-format')).toEqual('MMM dd, HH:mm') + expect(dateEl.prop('dateTime')).toEqual(date.toISOString()) + expect(dateEl.text()).toEqual(date.toLocaleString()) + }) + + it('does not render exact time', () => { + const timestamp = mount(<Timestamp publishedTimestamp={twoDaysAgo().toISOString()} />) + const exactTimeEl = timestamp.find('span').at(1) + + expect(exactTimeEl).not.toExist() + }) + }) + + describe('when timestamp is in the last 24 hours', () => { + it('renders the time in "time ago" format', () => { + const date = twoMinutesAgo() + const timestamp = mount(<Timestamp publishedTimestamp={date.toISOString()} />) + const dateEl = timestamp.find('time[data-o-component="o-date"]') + + expect(dateEl.prop('data-o-date-format')).toEqual('time-ago-no-seconds') + expect(dateEl.prop('dateTime')).toEqual(date.toISOString()) + expect(dateEl.text()).toEqual(date.toLocaleString()) + }) + + it('renders the exact time', () => { + const timestamp = mount(<Timestamp publishedTimestamp={twoMinutesAgo().toISOString()} />) + const exactTimeEl = timestamp.find('span').at(1) + + expect(exactTimeEl.prop('data-o-date-format')).toEqual('HH:mm') + }) + }) + }) +}) diff --git a/components/x-live-blog-post/storybook/index.jsx b/components/x-live-blog-post/storybook/index.jsx new file mode 100644 index 000000000..25cc0f064 --- /dev/null +++ b/components/x-live-blog-post/storybook/index.jsx @@ -0,0 +1,66 @@ +import React from 'react' +import { LiveBlogPost } from '../src/LiveBlogPost' +import BuildService from '../../../.storybook/build-service' + +const dependencies = { + 'o-typography': '^6.0.0' +} + +export default { + title: 'x-live-blog-post', + parameters: { + escapeHTML: false + } +} + +export const ContentBody = (args) => { + return ( + <div className="story-container"> + {dependencies && <BuildService dependencies={dependencies} />} + <LiveBlogPost {...args} /> + <LiveBlogPost {...args} /> + </div> + ) +} + +ContentBody.args = { + title: 'Turkey’s virus deaths may be 25% higher than official figure', + byline: 'George Russell', + isBreakingNews: false, + standout: { + breakingNews: false + }, + bodyHTML: + '<p>Turkey’s death toll from coronavirus could be as much as 25 per cent higher than the government’s official tally, adding the country of 83m people to the raft of nations that have struggled to accurately capture the impact of the pandemic.</p>\n<p>Ankara has previously rejected suggestions that municipal data from Istanbul, the epicentre of the country’s Covid-19 outbreak, showed that there were more deaths from the disease than reported.</p>\n<p>But an analysis of individual death records by the Financial Times raises questions about the Turkish government’s explanation for a spike in all-cause mortality in the city of almost 16m people.</p>\n<p><a href="https://www.ft.com/content/80bb222c-b6eb-40ea-8014-563cbe9e0117" target="_blank">Read the article here</a></p>\n<p><img class="picture" src="http://blogs.ft.com/the-world/files/2020/05/istanbul_excess_morts_l.jpg"></p>', + id: '12345', + publishedDate: '2020-05-13T18:52:28.000Z', + articleUrl: 'https://www.ft.com/content/2b665ec7-a88f-3998-8f39-5371f9c791ed', + showShareButtons: true, + backToTop: '#Top' +} + +export const ContentBodyWithBackToTopButton = (args) => { + return ( + <div className="story-container"> + {dependencies && <BuildService dependencies={dependencies} />} + <LiveBlogPost {...args} /> + <LiveBlogPost {...args} /> + </div> + ) +} + +ContentBodyWithBackToTopButton.args = { + title: 'Turkey’s virus deaths may be 25% higher than official figure', + byline: 'George Russell', + isBreakingNews: false, + standout: { + breakingNews: false + }, + bodyHTML: + '<p>Turkey’s death toll from coronavirus could be as much as 25 per cent higher than the government’s official tally, adding the country of 83m people to the raft of nations that have struggled to accurately capture the impact of the pandemic.</p>\n<p>Ankara has previously rejected suggestions that municipal data from Istanbul, the epicentre of the country’s Covid-19 outbreak, showed that there were more deaths from the disease than reported.</p>\n<p>But an analysis of individual death records by the Financial Times raises questions about the Turkish government’s explanation for a spike in all-cause mortality in the city of almost 16m people.</p>\n<p><a href="https://www.ft.com/content/80bb222c-b6eb-40ea-8014-563cbe9e0117" target="_blank">Read the article here</a></p>\n<p><img class="picture" src="http://blogs.ft.com/the-world/files/2020/05/istanbul_excess_morts_l.jpg"></p>', + id: '12345', + publishedDate: '2020-05-13T18:52:28.000Z', + articleUrl: 'https://www.ft.com/content/2b665ec7-a88f-3998-8f39-5371f9c791ed', + showShareButtons: true, + backToTop: () => {} +} diff --git a/components/x-live-blog-wrapper/.npmignore b/components/x-live-blog-wrapper/.npmignore new file mode 100644 index 000000000..a44a9e753 --- /dev/null +++ b/components/x-live-blog-wrapper/.npmignore @@ -0,0 +1,3 @@ +src/ +stories/ +rollup.js diff --git a/components/x-live-blog-wrapper/package.json b/components/x-live-blog-wrapper/package.json new file mode 100644 index 000000000..e060fa08a --- /dev/null +++ b/components/x-live-blog-wrapper/package.json @@ -0,0 +1,37 @@ +{ + "name": "@financial-times/x-live-blog-wrapper", + "version": "0.0.0", + "description": "", + "main": "dist/LiveBlogWrapper.cjs.js", + "module": "dist/LiveBlogWrapper.esm.js", + "browser": "dist/LiveBlogWrapper.es5.js", + "scripts": { + "build": "node rollup.js", + "start": "node rollup.js --watch" + }, + "keywords": [ + "x-dash" + ], + "author": "", + "license": "ISC", + "dependencies": { + "@financial-times/x-engine": "file:../../packages/x-engine", + "@financial-times/x-live-blog-post": "file:../x-live-blog-post", + "@financial-times/x-interaction": "file:../x-interaction" + }, + "devDependencies": { + "@financial-times/x-rollup": "file:../../packages/x-rollup", + "@financial-times/x-test-utils": "file:../../packages/x-test-utils" + }, + "repository": { + "type": "git", + "url": "https://github.com/Financial-Times/x-dash.git" + }, + "homepage": "https://github.com/Financial-Times/x-dash/tree/HEAD/components/x-live-blog-wrapper", + "engines": { + "node": "12.x" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/components/x-live-blog-wrapper/readme.md b/components/x-live-blog-wrapper/readme.md new file mode 100644 index 000000000..e9d8411b2 --- /dev/null +++ b/components/x-live-blog-wrapper/readme.md @@ -0,0 +1,159 @@ +# x-live-blog-wrapper + +This module displays a list of live blog posts using `x-live-blog-post` component. It also connects to an event stream which provides updates for the list. Based on these update events this component will add, remove and update `x-live-blog-post` components in the list. + + +## Installation + +This module is supported on Node 12 and is distributed on npm. + +```bash +npm install --save @financial-times/x-live-blog-wrapper +``` + +The [`x-engine`][engine] module is used to inject your chosen runtime into the component. Please read the `x-engine` documentation first if you are consuming `x-` components for the first time in your application. + +[engine]: https://github.com/Financial-Times/x-dash/tree/HEAD/packages/x-engine + + +## Usage + +The components provided by this module are all functions that expect a map of [properties](#properties). They can be used with vanilla JavaScript or JSX (If you are not familiar check out [WTF is JSX][jsx-wtf] first). For example if you were writing your application using React you could use the component like this: + +```jsx +import React from 'react'; +import { LiveBlogWrapper } from '@financial-times/x-live-blog-wrapper'; + +// A == B == C +const a = LiveBlogWrapper(props); +const b = <LiveBlogWrapper {...props} />; +const c = React.createElement(LiveBlogWrapper, props); +``` + +All `x-` components are designed to be compatible with a variety of runtimes, not just React. Check out the [`x-engine`][engine] documentation for a list of recommended libraries and frameworks. + +[jsx-wtf]: https://jasonformat.com/wtf-is-jsx/ + +### Client side rendering +This component can be used at the client side. + +```jsx +import { LiveBlogWrapper } from '@financial-times/x-live-blog-wrapper'; + +<LiveBlogWrapper articleUrl="https://www.ft.com/content/live_blog_package_uuid" + showShareButtons={true} + id="live-blog-wrapper" + posts={posts} + /> +``` + +### Server side rendering and hydrating +When rendering this component at the server side, hydration data must be rendered to the document using `Serialiser` and `HydrationData` components which are provided by `x-interaction`. + +To successfully hydrate this component at the client side, the `id` property **must** be provided when rendering it at the server side. `x-interaction` will add this id to the markup as a `data-x-dash-id` attribute. This property can later be used to identify the markup. + +The consuming app needs to ensure that the `id` is unique. + +```jsx +import { Serialiser, HydrationData } from '@financial-times/x-interaction'; +import { LiveBlogWrapper } from '@financial-times/x-live-blog-wrapper'; + +const serialiser = new Serialiser(); + +<LiveBlogWrapper articleUrl="https://www.ft.com/content/live_blog_package_uuid" + showShareButtons={true} + id="live-blog-wrapper" + posts={posts} + serialiser={serialiser} /> +<HydrationData serialiser={serialiser} /> +``` + +To hydrate this component at the client side, use `hydrate()` function provided by `x-interaction`. + +```js +import { hydrate } from '@financial-times/x-interaction'; + +hydrate(); +``` + +### Inserting posts on the client side + +When live updates come in you can insert a new post by dispatching an action to the component's wrapper. + +Client side: +```js +import { hydrate } from '@financial-times/x-interaction'; + +hydrate(); + +const wrapperElement = document.querySelector( + `[data-live-blog-wrapper-id="x-dash-element-id"]` +); + +const post = { + id: '00000000-0000-0000-0000-000000000000', + ... +}; + +const action = 'insert-post'; +// wrapperElement must be the last argument. +const args = [ + post, + wrapperElement +]; + +wrapperElement.dispatchEvent( + new CustomEvent('x-interaction.trigger-action', { + detail: { action, args }, + bubbles: true + }) +); +``` + +### Client side events +This component dispatches the following client side events to notify the consuming app about live updates. Consuming apps typically use these events to initialise Origami components on the newly rendered markup. + +```jsx +<LiveBlogWrapper articleUrl="https://www.ft.com" + showShareButtons={true} + id="x-dash-element-id" + posts={posts} + serialiser={serialiser} /> +``` + +```js +... + +const wrapperElement = document.querySelector( + `[data-live-blog-wrapper-id="x-dash-element-id"]` +); + +wrapperElement.addEventListener('LiveBlogWrapper.INSERT_POST', + (event) => { + const { post } = event.detail; + + // post object contains data about a live blog post + // post.id can be used to identify the newly rendered + // LiveBlogPost element + }); +``` + +### Properties + +Feature | Type | Notes +-----------------|--------|---------------------------- +`articleUrl` | String | URL of the live blog - used for sharing +`showShareButtons` | Boolean | if `true` displays social media sharing buttons in posts +`posts` | Array | Array of live blog post data +`id` | String | **(required)** Unique id used for identifying the element in the document. + + +## Configuring the `next-live-event-api` endpoint URL. + +If you want to configure the URL for `next-live-event-api`, add the following plugin in your Webpack configuration file: + +```javascript +new webpack.DefinePlugin({ + LIVE_EVENT_API_URL: JSON.stringify('http://localhost:3003') +}) +``` \ No newline at end of file diff --git a/components/x-live-blog-wrapper/rollup.js b/components/x-live-blog-wrapper/rollup.js new file mode 100644 index 000000000..d32376e11 --- /dev/null +++ b/components/x-live-blog-wrapper/rollup.js @@ -0,0 +1,4 @@ +const xRollup = require('@financial-times/x-rollup') +const pkg = require('./package.json') + +xRollup({ input: './src/LiveBlogWrapper.jsx', pkg }) diff --git a/components/x-live-blog-wrapper/src/LiveBlogWrapper.jsx b/components/x-live-blog-wrapper/src/LiveBlogWrapper.jsx new file mode 100644 index 000000000..e76727ca3 --- /dev/null +++ b/components/x-live-blog-wrapper/src/LiveBlogWrapper.jsx @@ -0,0 +1,69 @@ +import { h } from '@financial-times/x-engine' +import { LiveBlogPost } from '@financial-times/x-live-blog-post' +import { withActions } from '@financial-times/x-interaction' +import { normalisePost } from './normalisePost' +import { dispatchEvent } from './dispatchEvent' +import { registerComponent } from '@financial-times/x-interaction' + +const withLiveBlogWrapperActions = withActions({ + insertPost(newPost, wrapper) { + return (props) => { + const normalisedNewPost = normalisePost(newPost) + const newPostAlreadyExists = props.posts.find((post) => post.id === normalisedNewPost.id) + if (!newPostAlreadyExists) { + props.posts.unshift(normalisedNewPost) + dispatchEvent(wrapper, 'LiveBlogWrapper.INSERT_POST', { post: normalisedNewPost }) + } + + return props + } + } +}) + +const BaseLiveBlogWrapper = ({ + posts = [], + ads = {}, + articleUrl, + showShareButtons, + id, + liveBlogWrapperElementRef +}) => { + posts.sort((a, b) => { + const timestampA = a.publishedDate || a.publishedTimestamp + const timestampB = b.publishedDate || b.publishedTimestamp + + // Newer posts on top + if (timestampA > timestampB) { + return -1 + } + + if (timestampB > timestampA) { + return 1 + } + + return 0 + }) + + const postElements = posts.map((post, index) => ( + <LiveBlogPost + key={`live-blog-post-${post.id}`} + {...post} + articleUrl={articleUrl} + showShareButtons={showShareButtons} + ad={ads[index]} + /> + )) + + return ( + <div className="x-live-blog-wrapper" data-live-blog-wrapper-id={id} ref={liveBlogWrapperElementRef}> + {postElements} + </div> + ) +} + +const LiveBlogWrapper = withLiveBlogWrapperActions(BaseLiveBlogWrapper) + +// This enables the component to work with x-interaction hydration +registerComponent(LiveBlogWrapper, 'LiveBlogWrapper') + +export { LiveBlogWrapper } diff --git a/components/x-live-blog-wrapper/src/__tests__/LiveBlogWrapper.test.jsx b/components/x-live-blog-wrapper/src/__tests__/LiveBlogWrapper.test.jsx new file mode 100644 index 000000000..6e876c475 --- /dev/null +++ b/components/x-live-blog-wrapper/src/__tests__/LiveBlogWrapper.test.jsx @@ -0,0 +1,119 @@ +const { h } = require('@financial-times/x-engine') +const { mount } = require('@financial-times/x-test-utils/enzyme') + +import { LiveBlogWrapper } from '../LiveBlogWrapper' + +import { dispatchEvent } from '../dispatchEvent' +jest.mock('../dispatchEvent') + +const post1 = { + id: '1', + title: 'Post 1 Title', + bodyHTML: '<p>Post 1 body</p>', + publishedDate: '2020-10-09T10:00:00.000Z', + isBreakingNews: true, + articleUrl: 'https://www.ft.com', + showShareButtons: true +} + +const post2 = { + id: '2', + title: 'Post 2 Title', + bodyHTML: '<p>Post 2 body</p>', + publishedDate: '2020-10-09T11:00:00.000Z', + isBreakingNews: false, + articleUrl: 'https://www.ft.com', + showShareButtons: true +} + +const ads = { + 1: ( + <div className="o-ads" data-o-ads-name="mid1"> + Ads + </div> + ) +} + +describe('x-live-blog-wrapper', () => { + it('has a displayName', () => { + expect(LiveBlogWrapper.displayName).toContain('BaseLiveBlogWrapper') + }) + + it('renders initial posts', () => { + const posts = [post1, post2] + const liveBlogWrapper = mount(<LiveBlogWrapper posts={posts} />) + + expect(liveBlogWrapper.html()).toContain('Post 1 Title') + expect(liveBlogWrapper.html()).toContain('Post 1 body') + expect(liveBlogWrapper.html()).toContain('Post 2 Title') + expect(liveBlogWrapper.html()).toContain('Post 2 body') + }) + + it('orders posts by date - new posts on top', () => { + const posts = [post1, post2] + const liveBlogWrapper = mount(<LiveBlogWrapper posts={posts} />) + + const articles = liveBlogWrapper.find('article') + expect(articles.at(0).html()).toContain('Post 2 Title') + expect(articles.at(1).html()).toContain('Post 1 Title') + }) + + it('renders an ad slot element at the given position', () => { + const posts = [post1, post2] + const liveBlogWrapper = mount(<LiveBlogWrapper posts={posts} ads={ads} />) + + const articles = liveBlogWrapper.find('article') + expect(articles.at(0).html()).not.toContain('Ads') + expect(articles.at(1).html()).toContain('Ads') + }) +}) + +describe('liveBlogWrapperActions', () => { + let posts + let actions + + beforeEach(() => { + posts = [post1, post2] + + // liveBlogActions are not exported from the module, but we can access them via + // the props of LiveBlogWrapper component. + const liveBlogWrapper = LiveBlogWrapper({}) + actions = liveBlogWrapper.props.actions + }) + + afterEach(() => { + dispatchEvent.mockReset() + }) + + it('inserts a new post to the top of the list', () => { + const target = 'target' + const post3 = { + id: '3' + } + + // insertPost function returns another function that takes the list of component props + // as an argument and returns the updated props. + actions.insertPost(post3, target)({ posts }) + + expect(posts.length).toEqual(3) + expect(posts[0].id).toEqual('3') + + expect(dispatchEvent).toHaveBeenCalledWith('target', 'LiveBlogWrapper.INSERT_POST', { + post: post3 + }) + }) + + it('does not insert a new post if a duplicate', () => { + // Clone an existing post to check if gets inserted again. + const duplicatePost = { ...posts[0] } + + // insertPost function returns another function that takes the list of component props + // as an argument and returns the updated props. + actions.insertPost(duplicatePost)({ posts }) + + expect(posts.length).toEqual(2) + expect(posts[0].id).toEqual('1') + + expect(dispatchEvent).not.toHaveBeenCalled() + }) +}) diff --git a/components/x-live-blog-wrapper/src/__tests__/dispatchEvent.test.js b/components/x-live-blog-wrapper/src/__tests__/dispatchEvent.test.js new file mode 100644 index 000000000..02617a205 --- /dev/null +++ b/components/x-live-blog-wrapper/src/__tests__/dispatchEvent.test.js @@ -0,0 +1,41 @@ +import { dispatchEvent } from '../dispatchEvent' + +const CustomEventMock = jest.fn() +global.CustomEvent = CustomEventMock + +const dispatchEventMock = jest.fn(() => {}) + +describe('dispatchEvent', () => { + beforeAll(() => { + jest.useFakeTimers() + }) + afterAll(() => { + dispatchEventMock.mockReset() + CustomEventMock.mockReset() + jest.useRealTimers() + }) + it('does nothing if ran on the server with no window', () => { + const originalWindow = global.window + delete global.window + dispatchEvent() + jest.runAllTimers() + expect(dispatchEventMock).not.toHaveBeenCalled() + expect(CustomEventMock).not.toHaveBeenCalled() + global.window = originalWindow + }) + it('does loads if window', () => { + const target = { + dispatchEvent: dispatchEventMock + } + dispatchEvent(target, 'test', { + foo: 'bar' + }) + jest.runAllTimers() + expect(dispatchEventMock).toHaveBeenCalled() + expect(CustomEventMock).toHaveBeenCalledWith('test', { + detail: { + foo: 'bar' + } + }) + }) +}) diff --git a/components/x-live-blog-wrapper/src/__tests__/normalisePost.test.js b/components/x-live-blog-wrapper/src/__tests__/normalisePost.test.js new file mode 100644 index 000000000..6451c0718 --- /dev/null +++ b/components/x-live-blog-wrapper/src/__tests__/normalisePost.test.js @@ -0,0 +1,28 @@ +import { normalisePost } from '../normalisePost' + +describe('normalisePost', () => { + it('does nothing if no post', () => { + expect(normalisePost({})).toEqual({}) + }) + it('does not change id property if id property exists', () => { + expect( + normalisePost({ + id: 123, + postId: 456 + }) + ).toEqual({ + id: 123, + postId: 456 + }) + }) + it('adds id property if doesnt it exist but postId property does', () => { + expect( + normalisePost({ + postId: 456 + }) + ).toEqual({ + id: 456, + postId: 456 + }) + }) +}) diff --git a/components/x-live-blog-wrapper/src/dispatchEvent.js b/components/x-live-blog-wrapper/src/dispatchEvent.js new file mode 100644 index 000000000..6c55ffdc3 --- /dev/null +++ b/components/x-live-blog-wrapper/src/dispatchEvent.js @@ -0,0 +1,34 @@ +/* +We dispatch live update events to notify the consuming app about added / updated posts. + +Consuming app uses these events to execute tasks like initialising Origami components +on the updated elements. + +We want the rendering of the updates in the DOM to finish before dispatching this event, +because the consumer needs to reference the updated DOM elements. + +If we dispatch the event in the same event loop with DOM element updates, consumer app +will handle the event before the updates are complete. + +window.setTimeout(fn, 0) will defer the execution of the inner function until the +current event loop completes, which is enough time for the DOM updates to finish. + +More information can be found in MDN setTimeout documentation. Please refer to +"Late timeouts" heading in this page: +https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout#Late_timeouts + +> ... the timeout can also fire later when the page (or the OS/browser itself) is busy +> with other tasks. One important case to note is that the function or code snippet +> cannot be executed until the thread that called setTimeout() has terminated. +> ... +> This is because even though setTimeout was called with a delay of zero, it's placed on +> a queue and scheduled to run at the next opportunity; not immediately. +*/ +const dispatchEvent = (target, eventType, data) => { + if (typeof window === 'undefined') { + return + } + window.setTimeout(() => target.dispatchEvent(new CustomEvent(eventType, { detail: data })), 0) +} + +export { dispatchEvent } diff --git a/components/x-live-blog-wrapper/src/normalisePost.js b/components/x-live-blog-wrapper/src/normalisePost.js new file mode 100644 index 000000000..d628d5582 --- /dev/null +++ b/components/x-live-blog-wrapper/src/normalisePost.js @@ -0,0 +1,9 @@ +// Remove this helper when we no longer need to handle incoming WordPress events. +const normalisePost = (post) => { + if (post && !post.id && post.postId) { + post.id = post.postId + } + return post +} + +export { normalisePost } diff --git a/components/x-live-blog-wrapper/storybook/index.jsx b/components/x-live-blog-wrapper/storybook/index.jsx new file mode 100644 index 000000000..f6e9ed7c0 --- /dev/null +++ b/components/x-live-blog-wrapper/storybook/index.jsx @@ -0,0 +1,84 @@ +import React from 'react' +import { LiveBlogWrapper } from '../src/LiveBlogWrapper' +import '../../x-live-blog-post/dist/LiveBlogPost.css' + +const Ad = (props) => { + const { + slotName, + targeting, + defaultFormat = 'false', + small = 'false', + medium = 'false', + large = 'false', + extra = 'false', + alignment = 'center' + } = props + + const classes = `o-ads o-ads--${alignment} o-ads--transition` + + return ( + <div + data-o-ads-name={slotName} + data-o-ads-targeting={targeting} + data-o-ads-formats-default={defaultFormat} + data-o-ads-formats-small={small} + data-o-ads-formats-medium={medium} + data-o-ads-formats-large={large} + data-o-ads-formats-extra={extra} + data-o-ads-label="true" + aria-hidden="true" + tabIndex="-1" + className={classes} + /> + ) +} + +const defaultProps = { + message: 'Test', + posts: [ + { + id: 12345, + title: 'Title 1', + bodyHTML: '<p>Post 1</p>', + isBreakingNews: false, + publishedDate: '2020-05-13T18:52:28.000Z', + articleUrl: 'https://www.ft.com/content/2b665ec7-a88f-3998-8f39-5371f9c791ed', + showShareButtons: true + }, + { + id: 12346, + title: 'Title 2', + bodyHTML: '<p>Post 2</p>', + isBreakingNews: true, + publishedDate: '2020-05-13T19:52:28.000Z', + articleUrl: 'https://www.ft.com/content/2b665ec7-a88f-3998-8f39-5371f9c791ed', + showShareButtons: true + }, + { + id: 12347, + title: 'Title 3', + bodyHTML: '<p>Post 3</p>', + isBreakingNews: false, + publishedDate: '2020-05-13T20:52:28.000Z', + articleUrl: 'https://www.ft.com/content/2b665ec7-a88f-3998-8f39-5371f9c791ed', + showShareButtons: true + } + ], + ads: { + 1: <Ad />, + 2: <Ad /> + } +} + +export default { + title: 'x-live-blog-wrapper', + parameters: { + escapeHTML: false + } +} + +export const ContentBody = (args) => { + return <LiveBlogWrapper {...args} /> +} + +ContentBody.args = defaultProps diff --git a/components/x-podcast-launchers/.bowerrc b/components/x-podcast-launchers/.bowerrc new file mode 100644 index 000000000..39039a4a1 --- /dev/null +++ b/components/x-podcast-launchers/.bowerrc @@ -0,0 +1,8 @@ +{ + "registry": { + "search": [ + "https://origami-bower-registry.ft.com", + "https://registry.bower.io" + ] + } +} diff --git a/components/x-podcast-launchers/.npmignore b/components/x-podcast-launchers/.npmignore new file mode 100644 index 000000000..a44a9e753 --- /dev/null +++ b/components/x-podcast-launchers/.npmignore @@ -0,0 +1,3 @@ +src/ +stories/ +rollup.js diff --git a/components/x-podcast-launchers/bower.json b/components/x-podcast-launchers/bower.json new file mode 100644 index 000000000..82828bcd4 --- /dev/null +++ b/components/x-podcast-launchers/bower.json @@ -0,0 +1,11 @@ +{ + "name": "@financial-times/x-podcast-launchers", + "description": "", + "main": "dist/PodcastLaunchers.es5.js", + "private": true, + "dependencies": { + "o-typography": "^6.0.1", + "o-forms": "^8.0.0", + "o-buttons": "^6.0.2" + } +} diff --git a/components/x-podcast-launchers/package.json b/components/x-podcast-launchers/package.json new file mode 100644 index 000000000..90c3cf84e --- /dev/null +++ b/components/x-podcast-launchers/package.json @@ -0,0 +1,41 @@ +{ + "name": "@financial-times/x-podcast-launchers", + "version": "0.0.0", + "description": "", + "main": "dist/PodcastLaunchers.cjs.js", + "module": "dist/PodcastLaunchers.esm.js", + "browser": "dist/PodcastLaunchers.es5.js", + "style": "dist/PodcastLaunchers.css", + "scripts": { + "prepare": "bower install && npm run build", + "build": "node rollup.js", + "start": "node rollup.js --watch" + }, + "keywords": [ + "x-dash" + ], + "author": "", + "license": "ISC", + "dependencies": { + "@financial-times/n-concept-ids": "^1.4.0", + "@financial-times/x-engine": "file:../../packages/x-engine", + "@financial-times/x-follow-button": "0.0.12", + "nanoid": "^2.0.3" + }, + "devDependencies": { + "@financial-times/x-rollup": "file:../../packages/x-rollup", + "bower": "^1.8.8", + "node-sass": "^4.9.2" + }, + "repository": { + "type": "git", + "url": "https://github.com/Financial-Times/x-dash.git" + }, + "homepage": "https://github.com/Financial-Times/x-dash/tree/HEAD/components/x-podcastlaunchers", + "engines": { + "node": "12.x" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/components/x-podcast-launchers/readme.md b/components/x-podcast-launchers/readme.md new file mode 100644 index 000000000..29ce03d08 --- /dev/null +++ b/components/x-podcast-launchers/readme.md @@ -0,0 +1,59 @@ +# x-podcast-launchers + +This module allows users to open a podcast series in various podcast apps. The subscribe urls for each podcast app are generated from rss url with config (`/src/app-links.js`). + +No elements are returned when the `conceptId` does not map to a known podcast series, as defined in `src/map-concept-to-acast-series.js`. + +This component also renders a myFT follow button (x-follow-button) for the conceptId provided. This is acts as an onsite way to follow the series should the user's podcast app not be listed. + +![screenshot of x-podcast-launchers](https://user-images.githubusercontent.com/21194161/64718501-3d5eab80-d4be-11e9-9a63-9b37ab1d8069.png) + + +## Installation + +This module is supported on Node 12 and is distributed on npm. + +```bash +npm install --save @financial-times/x-podcast-launchers +``` + +The [`x-engine`][engine] module is used to inject your chosen runtime into the component. Please read the `x-engine` documentation first if you are consuming `x-` components for the first time in your application. + +[engine]: https://github.com/Financial-Times/x-dash/tree/HEAD/packages/x-engine + +## Styling + +The styles required for this components are bundled with it. + +## Usage + +The components provided by this module are all functions that expect a map of [properties](#properties). They can be used with vanilla JavaScript or JSX (If you are not familiar check out [WTF is JSX][jsx-wtf] first). For example if you were writing your application using React you could use the component like this: + +```jsx +import React from 'react'; +import { PodcastLaunchers } from '@financial-times/x-podcast-launchers'; + +// A == B == C +const a = PodcastLaunchers(props); +const b = <PodcastLaunchers {...props} />; +const c = React.createElement(PodcastLaunchers, props); +``` + +```scss +// within your app's sass file +@import "@financial-times/x-podcast-launchers/dist/PodcastLaunchers"; +``` + +All `x-` components are designed to be compatible with a variety of runtimes, not just React. Check out the [`x-engine`][engine] documentation for a list of recommended libraries and frameworks. + +[jsx-wtf]: https://jasonformat.com/wtf-is-jsx/ + +### Properties + +Feature | Type | Required | Notes +----------------------|----------|----------|------------------ +`acastRSSHost` | String | Yes | e.g. 'https://acast.access.com' +`conceptId` | String | Yes | +`renderFollowButton` | Function | No | Optional render prop for the follow button + +Additional props such as the `conceptName` may be required by x-follow-button. Documentation for these is available over in the component's readme. diff --git a/components/x-podcast-launchers/rollup.js b/components/x-podcast-launchers/rollup.js new file mode 100644 index 000000000..2242719a3 --- /dev/null +++ b/components/x-podcast-launchers/rollup.js @@ -0,0 +1,4 @@ +const xRollup = require('@financial-times/x-rollup') +const pkg = require('./package.json') + +xRollup({ input: './src/PodcastLaunchers.jsx', pkg }) diff --git a/components/x-podcast-launchers/src/PodcastLaunchers.jsx b/components/x-podcast-launchers/src/PodcastLaunchers.jsx new file mode 100644 index 000000000..0021d0306 --- /dev/null +++ b/components/x-podcast-launchers/src/PodcastLaunchers.jsx @@ -0,0 +1,91 @@ +import { h, Component } from '@financial-times/x-engine' +import { FollowButton } from '@financial-times/x-follow-button' +import generateAppLinks from './generate-app-links' +import generateRSSUrl from './generate-rss-url' +import acastSeriesIds from './config/series-ids' +import styles from './PodcastLaunchers.scss' +import copyToClipboard from './copy-to-clipboard' + +const rssUrlWrapperInner = [ + styles['o-forms-input--suffix'], + styles['o-forms-input--text'], + styles['o-forms-input'] +].join(' ') + +const noAppWrapperStyles = ['podcast-launchers__no-app-wrapper', styles.noAppWrapper].join(' ') + +function defaultFollowButtonRender(conceptId, conceptName, csrfToken, isFollowed) { + return ( + <FollowButton + conceptId={conceptId} + conceptName={conceptName} + csrfToken={csrfToken} + isFollowed={isFollowed} + /> + ) +} + +class PodcastLaunchers extends Component { + constructor(props) { + super(props) + this.state = { + rssUrl: '' + } + } + + componentDidMount() { + const { conceptId, acastRSSHost, acastAccessToken } = this.props + const acastSeries = acastSeriesIds.get(conceptId) + if (acastSeries) { + this.setState({ + rssUrl: generateRSSUrl(acastRSSHost, acastSeries, acastAccessToken) + }) + } + } + + render() { + const { rssUrl } = this.state + const { conceptId, conceptName, csrfToken, isFollowed, renderFollowButton } = this.props + const followButton = + typeof renderFollowButton === 'function' ? renderFollowButton : defaultFollowButtonRender + + return ( + rssUrl && ( + <div className={styles.container} data-trackable="podcast-launchers"> + <h2 className={styles.headingChooseApp}>Subscribe via your installed podcast app</h2> + <ul className={styles.podcastAppLinksWrapper}> + {generateAppLinks(rssUrl).map(({ name, url, trackingId }) => ( + <li key={name}> + <a href={url} className={styles.podcastAppLink} data-trackable={trackingId}> + {name} + </a> + </li> + ))} + + <li key="Rss Url" className={styles.rssUrlWrapper}> + <span className={rssUrlWrapperInner}> + <input value={rssUrl} type="text" readOnly /> + <button + className={styles.rssUrlCopyButton} + onClick={copyToClipboard} + data-url={rssUrl} + data-trackable="copy-rss" + type="button"> + Copy RSS + </button> + </span> + </li> + </ul> + + <div className={noAppWrapperStyles}> + <h2 className={styles.headingNoApp}>Can’t see your podcast app?</h2> + <p className={styles.textNoApp}>Get updates for new episodes</p> + {followButton(conceptId, conceptName, csrfToken, isFollowed)} + </div> + </div> + ) + ) + } +} + +export { PodcastLaunchers } diff --git a/components/x-podcast-launchers/src/PodcastLaunchers.scss b/components/x-podcast-launchers/src/PodcastLaunchers.scss new file mode 100644 index 000000000..adce29483 --- /dev/null +++ b/components/x-podcast-launchers/src/PodcastLaunchers.scss @@ -0,0 +1,93 @@ +$o-typography-is-silent: true; +@import 'o-typography/main'; + +$o-forms-is-silent: true; +@import 'o-forms/main'; + +$o-buttons-is-silent: true; +@import 'o-buttons/main'; + +:global { + @import "~@financial-times/x-follow-button/dist/FollowButton"; +} + +.container { + width: 100%; +} + +.headingChooseApp { + @include oTypographySans($weight: 'semibold', $scale: 1); + margin: 0 0 12px; +} + +.headingNoApp { + @include oTypographySans($weight: 'semibold', $scale: 0); + grid-area: heading; + margin: 0; +} + +.textNoApp { + @include oTypographySans($scale: 0); + grid-area: text; + margin: 0; +} + +.noAppWrapper { + display: grid; + grid-template: + 'heading button' + 'text button'; + grid-template-columns: max-content auto; + grid-template-rows: auto auto; + align-items: center; + + form { + grid-area: button; + justify-self: end; + } +} + +.podcastAppLinksWrapper { + list-style: none; + padding: 0; + margin: 0 0 24px; +} + +.podcastAppLink { + @include oButtonsContent($opts: ( + 'type': 'primary', + 'size': 'big', + )); + width: 100%; + margin-top: 8px; +} + +.rssUrlWrapper { + @include oForms($opts: (elements:(text), features:(suffix))); + .o-forms-input { + margin-top: 8px; + } +} + +.rssUrlInput { + max-width: none; +} + +.rssUrlCopyButton { + @include oButtonsContent($opts: ( + 'type': 'primary', + 'size': 'big', + ));} + +.rssUrlCopySpan { + user-select: text; + position: absolute; + clip: rect(0 0 0 0); + margin: -1px; + border: 0; + overflow: hidden; + padding: 0; + width: 1px; + height: 1px; + white-space: nowrap; +} diff --git a/components/x-podcast-launchers/src/__tests__/PodcastLaunchers.test.jsx b/components/x-podcast-launchers/src/__tests__/PodcastLaunchers.test.jsx new file mode 100644 index 000000000..d1fd211e1 --- /dev/null +++ b/components/x-podcast-launchers/src/__tests__/PodcastLaunchers.test.jsx @@ -0,0 +1,28 @@ +import { h } from '@financial-times/x-engine' +import { brand } from '@financial-times/n-concept-ids' +import renderer from 'react-test-renderer' +jest.mock('../PodcastLaunchers.scss', () => ({})) +import { PodcastLaunchers } from '../PodcastLaunchers' + +const acastRSSHost = 'https://acast.access' +const acastAccessToken = '123-abc' + +describe('PodcastLaunchers', () => { + it('should hide itself if an RSS URL could not be generated', () => { + expect( + renderer + .create(<PodcastLaunchers conceptId="123-abc" {...{ acastRSSHost, acastAccessToken }} />) + .toJSON() + ).toMatchSnapshot() + }) + + it('should render the app links based on concept Id', () => { + expect( + renderer + .create( + <PodcastLaunchers conceptId={brand.rachmanReviewPodcast} {...{ acastRSSHost, acastAccessToken }} /> + ) + .toJSON() + ).toMatchSnapshot() + }) +}) diff --git a/components/x-podcast-launchers/src/__tests__/__snapshots__/PodcastLaunchers.test.jsx.snap b/components/x-podcast-launchers/src/__tests__/__snapshots__/PodcastLaunchers.test.jsx.snap new file mode 100644 index 000000000..bf594e369 --- /dev/null +++ b/components/x-podcast-launchers/src/__tests__/__snapshots__/PodcastLaunchers.test.jsx.snap @@ -0,0 +1,106 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PodcastLaunchers should hide itself if an RSS URL could not be generated 1`] = `""`; + +exports[`PodcastLaunchers should render the app links based on concept Id 1`] = ` +<div + data-trackable="podcast-launchers" +> + <h2> + Subscribe via your installed podcast app + </h2> + <ul> + <li> + <a + data-trackable="apple-podcasts" + href="podcast://acast.access/rss/therachmanreview/123-abc" + > + Apple Podcasts + </a> + </li> + <li> + <a + data-trackable="overcast" + href="overcast://x-callback-url/add?url=https://acast.access/rss/therachmanreview/123-abc" + > + Overcast + </a> + </li> + <li> + <a + data-trackable="pocket-casts" + href="pktc://subscribe/acast.access/rss/therachmanreview/123-abc" + > + Pocket Casts + </a> + </li> + <li> + <a + data-trackable="podcast-addict" + href="podcastaddict://acast.access/rss/therachmanreview/123-abc" + > + Podcast Addict + </a> + </li> + <li> + <a + data-trackable="acast" + href="acast://subscribe/https://acast.access/rss/therachmanreview/123-abc" + > + Acast + </a> + </li> + <li> + <span + className=" " + > + <input + readOnly={true} + type="text" + value="https://acast.access/rss/therachmanreview/123-abc" + /> + <button + data-trackable="copy-rss" + data-url="https://acast.access/rss/therachmanreview/123-abc" + onClick={[Function]} + type="button" + > + Copy RSS + </button> + </span> + </li> + </ul> + <div + className="podcast-launchers__no-app-wrapper " + > + <h2> + Can’t see your podcast app? + </h2> + <p> + Get updates for new episodes + </p> + <form + action="/__myft/api/core/followed/concept/e0f2acb4-4177-436d-a783-b8c80ec2a6ac?method=put" + data-concept-id="e0f2acb4-4177-436d-a783-b8c80ec2a6ac" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Add undefined to myFT" + aria-pressed="false" + className="main_button__3Mk67" + dangerouslySetInnerHTML={ + Object { + "__html": "Add to myFT", + } + } + data-concept-id="e0f2acb4-4177-436d-a783-b8c80ec2a6ac" + data-trackable="follow" + data-trackable-context-messaging={null} + title="Add undefined to myFT" + type="submit" + /> + </form> + </div> +</div> +`; diff --git a/components/x-podcast-launchers/src/__tests__/__snapshots__/generate-app-links.test.js.snap b/components/x-podcast-launchers/src/__tests__/__snapshots__/generate-app-links.test.js.snap new file mode 100644 index 000000000..01891d182 --- /dev/null +++ b/components/x-podcast-launchers/src/__tests__/__snapshots__/generate-app-links.test.js.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`generate-app-links should generate each app link with the urlencoded RSS url 1`] = ` +Array [ + Object { + "includeProtocol": false, + "name": "Apple Podcasts", + "template": "podcast://{url}", + "trackingId": "apple-podcasts", + "url": "podcast://acast.access/rss/ft-news/&£@1234", + }, + Object { + "includeProtocol": true, + "name": "Overcast", + "template": "overcast://x-callback-url/add?url={url}", + "trackingId": "overcast", + "url": "overcast://x-callback-url/add?url=https://acast.access/rss/ft-news/&£@1234", + }, + Object { + "includeProtocol": false, + "name": "Pocket Casts", + "template": "pktc://subscribe/{url}", + "trackingId": "pocket-casts", + "url": "pktc://subscribe/acast.access/rss/ft-news/&£@1234", + }, + Object { + "includeProtocol": false, + "name": "Podcast Addict", + "template": "podcastaddict://{url}", + "trackingId": "podcast-addict", + "url": "podcastaddict://acast.access/rss/ft-news/&£@1234", + }, + Object { + "includeProtocol": true, + "name": "Acast", + "template": "acast://subscribe/{url}", + "trackingId": "acast", + "url": "acast://subscribe/https://acast.access/rss/ft-news/&£@1234", + }, +] +`; diff --git a/components/x-podcast-launchers/src/__tests__/generate-app-links.test.js b/components/x-podcast-launchers/src/__tests__/generate-app-links.test.js new file mode 100644 index 000000000..b8607a973 --- /dev/null +++ b/components/x-podcast-launchers/src/__tests__/generate-app-links.test.js @@ -0,0 +1,8 @@ +import generateAppLinks from '../generate-app-links' + +describe('generate-app-links', () => { + it('should generate each app link with the urlencoded RSS url', () => { + const rssUrl = 'https://acast.access/rss/ft-news/&£@1234' + expect(generateAppLinks(rssUrl)).toMatchSnapshot() + }) +}) diff --git a/components/x-podcast-launchers/src/__tests__/generate-rss-url.test.js b/components/x-podcast-launchers/src/__tests__/generate-rss-url.test.js new file mode 100644 index 000000000..00fffa151 --- /dev/null +++ b/components/x-podcast-launchers/src/__tests__/generate-rss-url.test.js @@ -0,0 +1,20 @@ +import generateRSSUrl from '../generate-rss-url' + +jest.mock('nanoid') +import nanoid from 'nanoid' + +describe('generate-app-links', () => { + it('returns the acast access url from showId and token', () => { + expect(generateRSSUrl('https://access.acast.cloud', 'ft-news-extra', '123-456')).toEqual( + 'https://access.acast.cloud/rss/ft-news-extra/123-456' + ) + }) + + it('generates a random token if one is not provided', () => { + nanoid.mockImplementation(() => 'abc-123') + + expect(generateRSSUrl('https://access.acast.cloud', 'ft-news-extra')).toMatch( + 'https://access.acast.cloud/rss/ft-news-extra/abc-123' + ) + }) +}) diff --git a/components/x-podcast-launchers/src/config/app-links.js b/components/x-podcast-launchers/src/config/app-links.js new file mode 100644 index 000000000..aa0986eaa --- /dev/null +++ b/components/x-podcast-launchers/src/config/app-links.js @@ -0,0 +1,32 @@ +export default [ + { + name: 'Apple Podcasts', + template: 'podcast://{url}', + includeProtocol: false, + trackingId: 'apple-podcasts' + }, + { + name: 'Overcast', + template: 'overcast://x-callback-url/add?url={url}', + includeProtocol: true, + trackingId: 'overcast' + }, + { + name: 'Pocket Casts', + template: 'pktc://subscribe/{url}', + includeProtocol: false, + trackingId: 'pocket-casts' + }, + { + name: 'Podcast Addict', + template: 'podcastaddict://{url}', + includeProtocol: false, + trackingId: 'podcast-addict' + }, + { + name: 'Acast', + template: 'acast://subscribe/{url}', + includeProtocol: true, + trackingId: 'acast' + } +] diff --git a/components/x-podcast-launchers/src/config/series-ids.js b/components/x-podcast-launchers/src/config/series-ids.js new file mode 100644 index 000000000..9af5e0bb0 --- /dev/null +++ b/components/x-podcast-launchers/src/config/series-ids.js @@ -0,0 +1,3 @@ +import { brand } from '@financial-times/n-concept-ids' + +export default new Map([[brand.rachmanReviewPodcast, 'therachmanreview']]) diff --git a/components/x-podcast-launchers/src/copy-to-clipboard.js b/components/x-podcast-launchers/src/copy-to-clipboard.js new file mode 100644 index 000000000..a8fea460c --- /dev/null +++ b/components/x-podcast-launchers/src/copy-to-clipboard.js @@ -0,0 +1,21 @@ +import styles from './PodcastLaunchers.scss' + +export default function copyToClipboard(event) { + const url = event.target.dataset.url + const containerEl = event.target.parentElement + const rssLink = document.createElement('span') + + rssLink.classList.add(styles.rssUrlCopySpan) + rssLink.appendChild(document.createTextNode(url)) + containerEl.appendChild(rssLink) + + const range = document.createRange() + + window.getSelection().removeAllRanges() + range.selectNode(rssLink) + window.getSelection().addRange(range) + document.execCommand('copy') + + window.getSelection().removeAllRanges() + containerEl.removeChild(rssLink) +} diff --git a/components/x-podcast-launchers/src/generate-app-links.js b/components/x-podcast-launchers/src/generate-app-links.js new file mode 100644 index 000000000..035a2c03f --- /dev/null +++ b/components/x-podcast-launchers/src/generate-app-links.js @@ -0,0 +1,11 @@ +import appLinksConfig from './config/app-links' + +export default function generateAppLinks(rssUrl) { + return appLinksConfig.map((data) => { + const url = data.includeProtocol ? rssUrl : rssUrl.replace(/^https?:\/\//, '') + + return Object.assign({}, data, { + url: data.template.replace(/{url}/, url) + }) + }) +} diff --git a/components/x-podcast-launchers/src/generate-rss-url.js b/components/x-podcast-launchers/src/generate-rss-url.js new file mode 100644 index 000000000..d438d68ac --- /dev/null +++ b/components/x-podcast-launchers/src/generate-rss-url.js @@ -0,0 +1,8 @@ +import nanoid from 'nanoid' + +export default function generateRSSUrl(acastHost, seriesId, token) { + // the API needs any token for now, as it is a private RSS feed. + // If the experiment successful these tokens would eventually be tied to the user’s account with Membership + // ie. https://access.acast.cloud/rss/ft-test/tYPWWHla + return `${acastHost}/rss/${seriesId}/${token || nanoid(10)}` +} diff --git a/components/x-podcast-launchers/storybook/index.jsx b/components/x-podcast-launchers/storybook/index.jsx new file mode 100644 index 000000000..bb706566f --- /dev/null +++ b/components/x-podcast-launchers/storybook/index.jsx @@ -0,0 +1,38 @@ +import { PodcastLaunchers } from '../src/PodcastLaunchers' +import { brand } from '@financial-times/n-concept-ids' +import React from 'react' +import { Helmet } from 'react-helmet' +import BuildService from '../../../.storybook/build-service' + +// Set up basic document styling using the Origami build service +const dependencies = { + 'o-normalise': '^1.6.0', + 'o-typography': '^5.5.0', + 'o-buttons': '^5.16.6', + 'o-forms': '^7.0.0' +} + +export default { + title: 'x-podcast-launchers' +} + +export const Example = (args) => { + return ( + <div className="story-container"> + {dependencies && <BuildService dependencies={dependencies} />} + <Helmet> + <link rel="stylesheet" href={`components/x-podcast-launchers/dist/PodcastLaunchers.css`} /> + </Helmet> + <PodcastLaunchers {...args} /> + </div> + ) +} + +Example.args = { + conceptId: brand.rachmanReviewPodcast, + conceptName: 'Rachman Review', + isFollowed: false, + csrfToken: 'token', + acastRSSHost: 'https://access.acast.com', + acastAccessToken: 'abc-123' +} diff --git a/components/x-privacy-manager/.bowerrc b/components/x-privacy-manager/.bowerrc new file mode 100644 index 000000000..39039a4a1 --- /dev/null +++ b/components/x-privacy-manager/.bowerrc @@ -0,0 +1,8 @@ +{ + "registry": { + "search": [ + "https://origami-bower-registry.ft.com", + "https://registry.bower.io" + ] + } +} diff --git a/components/x-privacy-manager/.npmignore b/components/x-privacy-manager/.npmignore new file mode 100644 index 000000000..ac1317921 --- /dev/null +++ b/components/x-privacy-manager/.npmignore @@ -0,0 +1,3 @@ +src/ +storybook/ +rollup.js diff --git a/components/x-privacy-manager/bower.json b/components/x-privacy-manager/bower.json new file mode 100644 index 000000000..46e4f79d3 --- /dev/null +++ b/components/x-privacy-manager/bower.json @@ -0,0 +1,16 @@ +{ + "name": "@financial-times/x-privacy-manager", + "description": "", + "main": "dist/privacy-manager.cjs.js", + "private": true, + "dependencies": { + "o-buttons": "^6.0.0", + "o-colors": "^5.0.0", + "o-grid": "^5.0.0", + "o-loading": "^4.0.0", + "o-message": "^4.0.0", + "o-normalise": "^2.0.8", + "o-spacing": "2.0.0", + "o-typography": "6.0.0" + } +} \ No newline at end of file diff --git a/components/x-privacy-manager/jsconfig.json b/components/x-privacy-manager/jsconfig.json new file mode 100644 index 000000000..85f3f0af0 --- /dev/null +++ b/components/x-privacy-manager/jsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "outDir": "dist/types", + "target": "es2015", + "module": "commonjs", + "strict": true, + "noImplicitAny": true, + "jsx": "react" + }, + "include": ["src/**/*.js", "src/**/*.jsx", "typings/*.d.ts"], + "exclude": ["node_modules"] +} diff --git a/components/x-privacy-manager/package.json b/components/x-privacy-manager/package.json new file mode 100644 index 000000000..4c382d606 --- /dev/null +++ b/components/x-privacy-manager/package.json @@ -0,0 +1,43 @@ +{ + "name": "@financial-times/x-privacy-manager", + "version": "1.1.0", + "description": "A component to let users give or withhold consent to the use of their data", + "author": "Oliver Turner <oliver.turner@ft.com>", + "license": "ISC", + "keywords": [ + "x-dash" + ], + "main": "dist/privacy-manager.cjs.js", + "module": "dist/privacy-manager.esm.js", + "browser": "dist/privacy-manager.es5.js", + "style": "dist/privacy-manager.css", + "types": "typings/x-privacy-manager.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/Financial-Times/x-dash.git" + }, + "homepage": "https://github.com/Financial-Times/x-dash/tree/HEAD/components/x-privacy-manager", + "engines": { + "node": "12.x" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@financial-times/x-engine": "file:../../packages/x-engine", + "@financial-times/x-interaction": "file:../x-interaction", + "classnames": "2.2.6" + }, + "devDependencies": { + "@financial-times/x-rollup": "file:../../packages/x-rollup", + "@financial-times/x-test-utils": "file:../../packages/x-test-utils", + "bower": "1.8.8", + "fetch-mock-jest": "1.3.0", + "sass": "1.26.5" + }, + "scripts": { + "prepare": "bower install && npm run build", + "build": "node rollup.js", + "start": "node rollup.js --watch" + } +} diff --git a/components/x-privacy-manager/readme.md b/components/x-privacy-manager/readme.md new file mode 100644 index 000000000..cc1a81c8f --- /dev/null +++ b/components/x-privacy-manager/readme.md @@ -0,0 +1,72 @@ +# x-privacy-manager + +This module creates an interface giving users the ability to give or withhold consent to the sale of their data under the provisions of the CCPA (California Consumer Protection Act), as a first step towards the FT's journey towards a Unified Privacy solution. + +It is rendered with Page Kit on FT.com at https://www.ft.com/preferences/privacy-ccpa as part of [`next-control-centre`](https://github.com/Financial-Times/next-control-centre) and rendered directly by the FT App. Additionally, it is intended to be embedded on pages curated by Specialist Titles + +![Privacy Manager UI](docs/ccpa.png) + +## Installation + +This module is supported on Node 12 and is distributed on npm. + +```bash +npm install --save @financial-times/x-privacy-manager +``` + +The [`x-engine`][engine] module is used to inject your chosen runtime into the component. Please read the `x-engine` documentation first if you are consuming `x-` components for the first time in your application. + +[engine]: https://github.com/Financial-Times/x-dash/tree/HEAD/packages/x-engine + + +## Usage + +### Properties + +Feature | Type | Notes +----------------------------|------------|----------------------------------------------- +`consentSource` | string | Name of the consuming app to be included in requests to Consent Proxy (e.g. "next-control-centre") +`consentProxyEndpoints` | object | Dictionary containing already-formed Consent Proxy Endpoints to use (including userId). It must include, at least, `consentProxyEndpoints.createOrUpdateRecord` +`consent` | boolean | (optional) Any existing preference expressed by the user +`referrer` | string | (optional) Used to provide a link back to the referring app's home page +`cookieDomain` | string | (optional) Specify the domain for the cookie set with the response from Consent Proxy (e.g. ".thebanker.com"). Will default to ".ft.com" if not provided +`legislation` | string[] | (optional) An array of the applicable legislation IDs +`onConsentSavedCallbacks` | function[] | (optional) An array of callbacks to invoken after a successful request to Consent Proxy + +A callback registered with `onConsentSavedCallbacks` will be executed with the following signature: +```js +customCallback( + err: null | Error, + { + consent: boolean, + payload: { + formOfWordsId: string, + consentSource: string, + cookieDomain?: string, + data: { + ['behaviouralAds' | 'demographicAds' | 'programmaticAds']: { + onsite: { + status: boolean; + lbi: boolean; + source: string; + fow: string; + } + } + } + } + } +) +``` + +Callbacks are executed on regardless of the success (200 status) or failure of the call to the server, +so we encourage returning early if the value of the error is anything but `null`: + +```js +function setCookie(err, {consent, payload}) { + if(err) return; + + // Store the value of `consent` + const uspString = `1Y${consent ? "N" : "Y"}N`; + document.cookie = `usprivacy=${uspString}; max-age=${60 * 60 * 24 * 365}`; +} +``` diff --git a/components/x-privacy-manager/rollup.js b/components/x-privacy-manager/rollup.js new file mode 100644 index 000000000..e389d4123 --- /dev/null +++ b/components/x-privacy-manager/rollup.js @@ -0,0 +1,4 @@ +const xRollup = require('@financial-times/x-rollup') +const pkg = require('./package.json') + +xRollup({ input: './src/privacy-manager.jsx', pkg }) diff --git a/components/x-privacy-manager/src/__tests__/config.test.jsx b/components/x-privacy-manager/src/__tests__/config.test.jsx new file mode 100644 index 000000000..8c42f3079 --- /dev/null +++ b/components/x-privacy-manager/src/__tests__/config.test.jsx @@ -0,0 +1,48 @@ +const { h } = require('@financial-times/x-engine') +const { mount } = require('@financial-times/x-test-utils/enzyme') + +import { defaultProps } from './helpers' + +import { BasePrivacyManager } from '../privacy-manager' + +describe('Config', () => { + it('renders the default UI', () => { + const subject = mount(<BasePrivacyManager {...defaultProps} />) + const labelTrue = subject.find('label[htmlFor="consent-true"]') + const labelFalse = subject.find('label[htmlFor="consent-false"]') + + expect(labelTrue.text()).toBe('Allow' + 'See personalised adverts') + expect(labelFalse.text()).toBe('Block' + 'Opt out of personalised adverts') + }) + + it('renders custom Button text', () => { + const buttonText = { + allow: { + label: 'Custom label', + text: 'Custom allow text' + }, + submit: { label: 'Custom save' } + } + const props = { ...defaultProps, buttonText } + + const subject = mount(<BasePrivacyManager {...props} />) + const labelTrue = subject.find('[data-trackable="ccpa-advertising-toggle-allow"] + label') + const labelFalse = subject.find('[data-trackable="ccpa-advertising-toggle-block"] + label') + const btnSave = subject.find('[data-trackable="ccpa-consent-block"]') + + expect(labelTrue.text()).toBe('Custom label' + 'Custom allow text') + expect(labelFalse.text()).toBe('Block' + 'Opt out of personalised adverts') + expect(btnSave.text()).toBe('Custom save') + }) + + it('renders legislation-specific data-trackable attrs', () => { + const props = { ...defaultProps, legislationId: 'gdpr' } + const subject = mount(<BasePrivacyManager {...props} />) + + const inputTrue = subject.find('[data-trackable="gdpr-advertising-toggle-allow"] + label') + const inputFalse = subject.find('[data-trackable="gdpr-advertising-toggle-block"] + label') + + expect(inputTrue.text()).toBe('Allow' + 'See personalised adverts') + expect(inputFalse.text()).toBe('Block' + 'Opt out of personalised adverts') + }) +}) diff --git a/components/x-privacy-manager/src/__tests__/helpers.js b/components/x-privacy-manager/src/__tests__/helpers.js new file mode 100644 index 000000000..2a59eed17 --- /dev/null +++ b/components/x-privacy-manager/src/__tests__/helpers.js @@ -0,0 +1,102 @@ +const React = require('react') + +// eslint-disable-next-line no-unused-vars +const { h } = require('@financial-times/x-engine') +const { mount } = require('@financial-times/x-test-utils/enzyme') + +import { PrivacyManager } from '../privacy-manager' + +export const CONSENT_PROXY_HOST = 'https://consent.ft.com' +export const CONSENT_PROXY_ENDPOINT = 'https://consent.ft.com/__consent/consent-record/FTPINK/abcde' + +/** + * + * @param {{ + * setConsentCookie: boolean, + * consent: boolean + * }} + * @returns + */ +export const buildPayload = ({ setConsentCookie, consent }) => { + return { + setConsentCookie, + consentSource: 'consuming-app', + data: { + behaviouralAds: { + onsite: { + fow: 'privacyCCPA/H0IeyQBalorD.6nTqqzhNTKECSgOPJCG', + lbi: true, + source: 'consuming-app', + status: consent + } + }, + demographicAds: { + onsite: { + fow: 'privacyCCPA/H0IeyQBalorD.6nTqqzhNTKECSgOPJCG', + lbi: true, + source: 'consuming-app', + status: consent + } + }, + programmaticAds: { + onsite: { + fow: 'privacyCCPA/H0IeyQBalorD.6nTqqzhNTKECSgOPJCG', + lbi: true, + source: 'consuming-app', + status: consent + } + } + }, + cookieDomain: '.ft.com', + formOfWordsId: 'privacyCCPA' + } +} + +/** @type {XPrivacyManager.BasePrivacyManagerProps} */ +export const defaultProps = { + userId: 'abcde', + legislationId: 'ccpa', + consentSource: 'consuming-app', + consentProxyApiHost: CONSENT_PROXY_HOST, + referrer: 'www.ft.com', + cookieDomain: '.ft.com', + fow: { + id: 'privacyCCPA', + version: 'H0IeyQBalorD.6nTqqzhNTKECSgOPJCG' + }, + actions: { + onConsentChange: jest.fn(() => {}), + sendConsent: jest.fn().mockReturnValue({ _response: { ok: undefined } }) + }, + onConsentSavedCallbacks: [jest.fn(), jest.fn()] +} + +/** + * Configure an instance of PrivacyManager and set up + * - Handlers for submit events + * - Post-submission callbacks + * + * @param {Partial<XPrivacyManager.BasePrivacyManagerProps>} propOverrides + * + * @returns {{ + * subject: ReactWrapper<any, Readonly<{}>, React.Component<{}, {}, any>>; + * callbacks: XPrivacyManager.OnSaveCallback[] | jest.Mock<any, any>[]; + * submitConsent(value: boolean): Promise<void>; + * }} + */ +export function setupPrivacyManager(propOverrides = {}) { + const props = Object.assign({}, defaultProps, propOverrides) + const subject = mount(<PrivacyManager {...props} />) + + return { + subject, + callbacks: props.onConsentSavedCallbacks, + async submitConsent(value) { + await subject.find(`input[value="${value}"]`).first().prop('onChange')(undefined) + await subject.find('form').first().prop('onSubmit')(undefined) + + // Reconcile snapshot with state + subject.update() + } + } +} diff --git a/components/x-privacy-manager/src/__tests__/messaging.test.jsx b/components/x-privacy-manager/src/__tests__/messaging.test.jsx new file mode 100644 index 000000000..538a0901a --- /dev/null +++ b/components/x-privacy-manager/src/__tests__/messaging.test.jsx @@ -0,0 +1,74 @@ +const { h } = require('@financial-times/x-engine') +const { mount } = require('@financial-times/x-test-utils/enzyme') + +import { defaultProps } from './helpers' + +import { BasePrivacyManager } from '../privacy-manager' + +function findMessageComponent(props) { + const subject = mount(<BasePrivacyManager {...props} />) + const messages = subject.find('[data-o-component="o-message"]') + const message = messages.first() + const link = message.find('[data-component="referrer-link"]') + + return { + messages, + message, + link + } +} + +describe('x-privacy-manager', () => { + describe('Messaging', () => { + const messageProps = { + ...defaultProps, + consent: true, + legislation: ['ccpa'], + isLoading: false, + _response: undefined + } + + it('None by default', () => { + const { messages } = findMessageComponent(messageProps) + expect(messages).toHaveLength(0) + }) + + it('While loading', () => { + const { messages, message } = findMessageComponent({ ...messageProps, isLoading: true }) + expect(messages).toHaveLength(1) + expect(message).toHaveClassName('o-message--neutral') + }) + + it('On receiving a response with a status of 200', () => { + const _response = { ok: true, status: 200 } + const { messages, message, link } = findMessageComponent({ ...messageProps, _response }) + + expect(messages).toHaveLength(1) + expect(message).toHaveClassName('o-message--success') + expect(link).toHaveProp('href', 'https://www.ft.com/') + }) + + it('On receiving a response with a non-200 status', () => { + const _response = { ok: false, status: 400 } + const { messages, message, link } = findMessageComponent({ ...messageProps, _response }) + + expect(messages).toHaveLength(1) + expect(message).toHaveClassName('o-message--error') + expect(link).toHaveProp('href', 'https://www.ft.com/') + }) + + it('On receiving any response with referrer undefined', () => { + const _response = { ok: false, status: 400 } + const referrer = undefined + const { messages, message, link } = findMessageComponent({ + ...messageProps, + referrer, + _response + }) + + expect(messages).toHaveLength(1) + expect(message).toHaveClassName('o-message--error') + expect(link).toHaveLength(0) + }) + }) +}) diff --git a/components/x-privacy-manager/src/__tests__/state.test.jsx b/components/x-privacy-manager/src/__tests__/state.test.jsx new file mode 100644 index 000000000..1b6454970 --- /dev/null +++ b/components/x-privacy-manager/src/__tests__/state.test.jsx @@ -0,0 +1,97 @@ +const fetchMock = require('fetch-mock') + +import * as helpers from './helpers' + +function getLastFetchPayload() { + return JSON.parse(fetchMock.lastOptions().body) +} + +describe('x-privacy-manager', () => { + describe('handling consent choices', () => { + beforeEach(() => { + fetchMock.reset() + fetchMock.config.overwriteRoutes = true + const okResponse = { + body: { a: 'b' }, + status: 200 + } + fetchMock.mock(helpers.CONSENT_PROXY_ENDPOINT, okResponse, { delay: 500 }) + }) + + it('handles consecutive changes of consent', async () => { + let expectedPayload + const { subject, callbacks, submitConsent } = helpers.setupPrivacyManager({ consent: true }) + const optInInput = subject.find('[data-trackable="ccpa-advertising-toggle-allow"]').first() + + await submitConsent(false) + + // Check fetch and both callbacks were run with correct `payload` values + expectedPayload = helpers.buildPayload({ setConsentCookie: false, consent: false }) + expect(getLastFetchPayload()).toEqual(expectedPayload) + expect(callbacks[0]).toHaveBeenCalledWith(null, { payload: expectedPayload, consent: false }) + expect(callbacks[1]).toHaveBeenCalledWith(null, { payload: expectedPayload, consent: false }) + + await submitConsent(true) + + // Check fetch and both callbacks were run with correct `payload` values + expectedPayload = helpers.buildPayload({ setConsentCookie: false, consent: true }) + expect(getLastFetchPayload()).toEqual(expectedPayload) + expect(callbacks[0]).toHaveBeenCalledWith(null, { payload: expectedPayload, consent: true }) + expect(callbacks[1]).toHaveBeenCalledWith(null, { payload: expectedPayload, consent: true }) + + // Verify that confimatory nmessage is displayed + const message = subject.find('[data-o-component="o-message"]').first() + const link = message.find('[data-component="referrer-link"]') + expect(message).toHaveClassName('o-message--success') + expect(link).toHaveProp('href', 'https://www.ft.com/') + expect(optInInput).toHaveProp('checked', true) + }) + + it('when provided, passes the cookieDomain prop in the fetch and callback payload', async () => { + const { callbacks, submitConsent } = helpers.setupPrivacyManager({ cookieDomain: '.ft.com' }) + const expectedPayload = { + ...helpers.buildPayload({ setConsentCookie: false, consent: false }), + cookieDomain: '.ft.com' + } + + await submitConsent(false) + + // Check fetch and both callbacks were run with correct `payload` values + expect(getLastFetchPayload()).toEqual(expectedPayload) + expect(callbacks[0]).toHaveBeenCalledWith(null, { payload: expectedPayload, consent: false }) + expect(callbacks[1]).toHaveBeenCalledWith(null, { payload: expectedPayload, consent: false }) + }) + + it('passes error object to callbacks when fetch fails', async () => { + const { callbacks, submitConsent } = helpers.setupPrivacyManager() + const expectedPayload = helpers.buildPayload({ setConsentCookie: false, consent: false }) + + // Override fetch-mock to fail requests + fetchMock.mock(helpers.CONSENT_PROXY_ENDPOINT, { status: 500 }, { delay: 500 }) + + await submitConsent(false) + + // calls fetch with the correct payload + expect(getLastFetchPayload()).toEqual(expectedPayload) + + // Calls both callbacks with an error as first argument + callbacks.forEach((callback) => { + const [errorArgument, resultArgument] = callback.mock.calls.pop() + expect(errorArgument).toBeInstanceOf(Error) + expect(resultArgument).toEqual({ payload: expectedPayload, consent: false }) + }) + }) + + it('Sends legislation-specific values (e.g. setConsentCookie)', async () => { + const expectedPayload = helpers.buildPayload({ setConsentCookie: true, consent: false }) + const { submitConsent } = helpers.setupPrivacyManager({ + legislationId: 'gdpr', + consent: true + }) + + await submitConsent(false) + + expect(getLastFetchPayload()).toEqual(expectedPayload) + }) + }) +}) diff --git a/components/x-privacy-manager/src/__tests__/utils.test.js b/components/x-privacy-manager/src/__tests__/utils.test.js new file mode 100644 index 000000000..306250c23 --- /dev/null +++ b/components/x-privacy-manager/src/__tests__/utils.test.js @@ -0,0 +1,48 @@ +const { getTrackingKeys, getConsentProxyEndpoints } = require('../utils') + +describe('getTrackingKeys', () => { + it('Creates legislation-specific tracking event names', () => { + expect(getTrackingKeys('ccpa')).toEqual({ + 'advertising-toggle-block': 'ccpa-advertising-toggle-block', + 'advertising-toggle-allow': 'ccpa-advertising-toggle-allow', + 'consent-allow': 'ccpa-consent-allow', + 'consent-block': 'ccpa-consent-block' + }) + }) +}) + +describe('getConsentProxyEndpoints', () => { + const params = { + userId: 'abcde', + consentProxyApiHost: 'https://consent.ft.com', + cookieDomain: '.ft.com' + } + + const defaultEndpoint = 'https://consent.ft.com/__consent/consent-record-cookie' + + it('generates endpoints for logged-in users', () => { + expect(getConsentProxyEndpoints(params)).toEqual({ + core: `https://consent.ft.com/__consent/consent-record/FTPINK/abcde`, + enhanced: `https://consent.ft.com/__consent/consent/FTPINK/abcde`, + createOrUpdateRecord: `https://consent.ft.com/__consent/consent-record/FTPINK/abcde` + }) + }) + + it('generates endpoints for logged-out users', () => { + const loggedOutParams = { ...params, userId: undefined } + expect(getConsentProxyEndpoints(loggedOutParams)).toEqual({ + core: defaultEndpoint, + enhanced: defaultEndpoint, + createOrUpdateRecord: defaultEndpoint + }) + }) + + it('generates endpoints for cookie-only circumstances', () => { + const loggedOutParams = { ...params, cookiesOnly: true } + expect(getConsentProxyEndpoints(loggedOutParams)).toEqual({ + core: defaultEndpoint, + enhanced: defaultEndpoint, + createOrUpdateRecord: defaultEndpoint + }) + }) +}) diff --git a/components/x-privacy-manager/src/actions.js b/components/x-privacy-manager/src/actions.js new file mode 100644 index 000000000..3b67172cd --- /dev/null +++ b/components/x-privacy-manager/src/actions.js @@ -0,0 +1,90 @@ +import { withActions } from '@financial-times/x-interaction' + +function onConsentChange(consent) { + return () => ({ consent }) +} + +/** + * Save the users choice via the ConsentProxy + * - consentSource: (e.g. 'next-control-centre') + * - cookieDomain: (e.g. '.thebanker.com') + * + * @param {XPrivacyManager.SendConsentProps} args + * @returns {({ isLoading, consent }: { isLoading: boolean, consent: boolean }) => Promise<{_response: _Response}>} + */ +function sendConsent({ + setConsentCookie, + consentApiUrl, + onConsentSavedCallbacks, + consentSource, + cookieDomain, + fow +}) { + let res + + return async ({ isLoading, consent }) => { + if (isLoading) return + + const categoryPayload = { + onsite: { + status: consent, + lbi: true, + source: consentSource, + fow: `${fow.id}/${fow.version}` + } + } + + const payload = { + setConsentCookie, + formOfWordsId: fow.id, + consentSource, + data: { + behaviouralAds: categoryPayload, + demographicAds: categoryPayload, + programmaticAds: categoryPayload + } + } + + if (cookieDomain) { + // Optionally specify the domain for the cookie to set on the Consent API + payload.cookieDomain = cookieDomain + } + + try { + res = await fetch(consentApiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload), + credentials: 'include' + }) + + // On response call any externally defined handlers following Node's convention: + // 1. Either an error object or `null` as the first argument + // 2. An object containing `consent` and `payload` as the second + // Allows callbacks to decide how to handle a failure scenario + + if (res.ok === false) { + throw new Error(res.statusText || String(res.status)) + } + + for (const fn of onConsentSavedCallbacks) { + fn(null, { consent, payload }) + } + + return { _response: { ok: true } } + } catch (err) { + for (const fn of onConsentSavedCallbacks) { + fn(err, { consent, payload }) + } + + return { _response: { ok: false } } + } + } +} + +export const withCustomActions = withActions(() => ({ + onConsentChange, + sendConsent +})) diff --git a/components/x-privacy-manager/src/components/form.jsx b/components/x-privacy-manager/src/components/form.jsx new file mode 100644 index 000000000..790474c31 --- /dev/null +++ b/components/x-privacy-manager/src/components/form.jsx @@ -0,0 +1,27 @@ +import { h } from '@financial-times/x-engine' +import s from '../privacy-manager.scss' + +/** + * @param {XPrivacyManager.FormProps} args + */ +export const Form = ({ consent, consentApiUrl, sendConsent, trackingKeys, buttonText, children }) => { + /** @type {XPrivacyManager.TrackingKey} */ + const consentAction = consent ? 'consent-allow' : 'consent-block' + const btnTrackingId = trackingKeys[consentAction] + const isDisabled = typeof consent === 'undefined' + + /** @param {React.FormEvent} event */ + const onSubmit = (event) => { + event && event.preventDefault() + return sendConsent() + } + + return ( + <form action={consentApiUrl} onSubmit={onSubmit}> + <div className={s.form__controls}>{children}</div> + <button className={s.form__submit} type="submit" data-trackable={btnTrackingId} disabled={isDisabled}> + {buttonText.submit.label} + </button> + </form> + ) +} diff --git a/components/x-privacy-manager/src/components/messages.jsx b/components/x-privacy-manager/src/components/messages.jsx new file mode 100644 index 000000000..e6fc6a15a --- /dev/null +++ b/components/x-privacy-manager/src/components/messages.jsx @@ -0,0 +1,108 @@ +import { h } from '@financial-times/x-engine' +import s from '../privacy-manager.scss' + +/** + * Provide a way to return to the referrer's homepage + * Potentially a Specialist Title, FT.com or FT App + * + * @param {string} referrer + */ +function renderReferrerLink(referrer) { + if (!referrer) return + + let url + + try { + url = new URL(`https://${referrer}`) + } catch (_) { + // referrer cannot be parsed: omit link + return + } + + return ( + <a href={url.href} data-component="referrer-link" className="o-message__actions__secondary"> + Continue to homepage + </a> + ) +} + +function Message({ cls, children }) { + return ( + <div className={cls} data-o-component="o-message"> + <div className="o-message__container"> + <div className="o-message__content "> + <div className="o-message__content-main">{children}</div> + </div> + </div> + </div> + ) +} + +/** + * + * @param {{ + * success: boolean + * referrer: string + * }} props + */ +export function ResponseMessage({ success, referrer }) { + const statusDict = { + true: { + cls: 'o-message--success', + msg: 'Your settings have been saved on this device. Please apply your preferences to all of the devices you use to access our Sites.' + }, + false: { + cls: 'o-message--error', + msg: 'Your settings could not be saved. Please try again later.' + } + } + + const status = statusDict[success] + const cls = `o-message o-message--alert ${status.cls}` + + return ( + <Message cls={cls}> + {status.msg} {renderReferrerLink(referrer)} + </Message> + ) +} + +export function LoadingMessage() { + const cls = 'o-message o-message--neutral' + const spinnerCls = `o-loading o-loading--dark o-loading--small ${s['v-middle']}` + + return ( + <Message cls={cls}> + <div className={spinnerCls}></div> + <span className={s.loading}>Loading...</span> + </Message> + ) +} + +/** + * @param {boolean} isLoading + * @param {XPrivacyManager._Response} response + * @param {string} referrer + */ +export function renderMessage(isLoading, response, referrer) { + if (isLoading) return <LoadingMessage /> + if (response) return <ResponseMessage success={response.ok} referrer={referrer} /> + return null +} + +/** + * Display a warning to users + * @param {string} [userId] + */ +export function renderLoggedOutWarning(userId, loginUrl) { + if (userId) return null + + const cta = loginUrl ? <a href={loginUrl}>sign into your account</a> : 'sign into your account' + + return ( + <p className={`${s.consent__copy} ${s['consent__copy--cta']}`} data-component="login-cta"> + Please {cta} before submitting your preferences to ensure these changes are applied across all of your + devices + </p> + ) +} diff --git a/components/x-privacy-manager/src/components/radio-btn.jsx b/components/x-privacy-manager/src/components/radio-btn.jsx new file mode 100644 index 000000000..9071b5371 --- /dev/null +++ b/components/x-privacy-manager/src/components/radio-btn.jsx @@ -0,0 +1,46 @@ +import { h } from '@financial-times/x-engine' + +import s from './radio-btn.scss' + +/** + * @param {{ + * name: string, + * type: "allow" | "block", + * checked: boolean, + * trackingKeys: XPrivacyManager.TrackingKeys, + * onChange: (value: boolean) => void, + * }} args + * + * @returns {JSX.Element} + */ +export function RadioBtn({ name, type, checked, trackingKeys, buttonText, onChange }) { + const value = type === 'allow' + const id = `${name}-${value}` + const trackingId = trackingKeys[`advertising-toggle-${type}`] + + return ( + <div className={s.control}> + <input + className={s.input} + id={id} + type="radio" + name={name} + value={value.toString()} + checked={checked} + data-trackable={trackingId} + onChange={() => onChange(value)} + /> + <label htmlFor={id} className={s.label}> + <span className={s.label__text}> + <strong>{buttonText[type].label}</strong> + <span>{buttonText[type].text}</span> + </span> + + <svg className={s.label__icon} viewBox="0 0 36 36" aria-hidden="true" focusable="false"> + <circle className={s.label__icon__outer} cx="18" cy="18" r="16" /> + <circle className={s.label__icon__inner} cx="18" cy="18" r="8" /> + </svg> + </label> + </div> + ) +} diff --git a/components/x-privacy-manager/src/components/radio-btn.scss b/components/x-privacy-manager/src/components/radio-btn.scss new file mode 100644 index 000000000..537d0457a --- /dev/null +++ b/components/x-privacy-manager/src/components/radio-btn.scss @@ -0,0 +1,108 @@ +@import 'o-colors/main'; +@import 'o-grid/main'; +@import 'o-normalise/main'; +@import 'o-spacing/main'; +@import 'o-typography/main'; + +$transitionDuration: 0.1s; + +.input { + @include oNormaliseVisuallyHidden; +} + +.control { + flex: 1; + + & + .control { + margin-top: oSpacingByName(s4); + + @include oGridRespondTo($from: S) { + margin-top: 0; + margin-left: oSpacingByName(s4); + } + } +} + +.label { + transition: background-color, color; + transition-duration: $transitionDuration; + + display: flex; + align-items: center; + + // Ensure that buttons remain same height in cases of content of differing lengths + height: 100%; + + padding: oSpacingByName(s6) oSpacingByName(s4); + border: 2px solid oColorsByName('teal'); + cursor: pointer; + background-color: oColorsByName('white'); + + .input:checked + & { + background-color: oColorsByName('teal'); + color: oColorsByName('white'); + } + + // Since <input> itself is hidden, apply a familiar focus style to the visible <label> + // As adjacent siblings we can reflect the <input>'s focus state here + .input:focus + & { + outline: 2px solid oColorsByName('teal-40'); + outline-offset: 3px; + background-color: oColorsByName('teal-40'); + } + + // Prevent fastclick from blocking events from child elements bubbling up to trigger a change + & > * { + pointer-events: none; + } +} + + +.label__text { + flex: 1; + + margin-right: oSpacingByName(s2); + + & > strong { + display: block; + font-size: 1.2rem; + font-weight: 600; + } + + & > span { + display: block; + margin-top: oSpacingByName(s1); + } +} + +.label__icon { + $icon-size: 28px; + width: $icon-size; + height: $icon-size; +} + +.label__icon__outer { + transition: stroke $transitionDuration; + + stroke: currentColor; + stroke-width: 3px; + fill: transparent; + + .input:not(:checked) + .label:hover & { + stroke: oColorsByName('teal-40'); + } +} + +.label__icon__inner { + transition: fill $transitionDuration; + + fill: transparent; + + .input:not(:checked) + .label:hover & { + fill: oColorsByName('teal-40'); + } + + .input:checked + .label & { + fill: currentColor; + } +} diff --git a/components/x-privacy-manager/src/privacy-manager.jsx b/components/x-privacy-manager/src/privacy-manager.jsx new file mode 100644 index 000000000..497b04843 --- /dev/null +++ b/components/x-privacy-manager/src/privacy-manager.jsx @@ -0,0 +1,94 @@ +import { h } from '@financial-times/x-engine' + +import { renderLoggedOutWarning, renderMessage } from './components/messages' +import { Form } from './components/form' +import { RadioBtn } from './components/radio-btn' +import { withCustomActions } from './actions' +import * as utils from './utils' +import s from './privacy-manager.scss' + +const defaultButtonText = { + allow: { label: 'Allow', text: 'See personalised adverts' }, + block: { label: 'Block', text: 'Opt out of personalised adverts' }, + submit: { label: 'Save' } +} + +/** + * @param {XPrivacyManager.BasePrivacyManagerProps} Props + */ +export function BasePrivacyManager({ + userId, + referrer, + legislationId, + cookiesOnly, + cookieDomain, + fow, + consent, + consentSource, + consentProxyApiHost, + onConsentSavedCallbacks = [], + buttonText = {}, + loginUrl, + actions, + isLoading, + _response = undefined +}) { + // Shallowly merge supplied button labels with defaults + buttonText = { ...defaultButtonText, ...buttonText } + + const consentProxyEndpoints = utils.getConsentProxyEndpoints({ + userId, + consentProxyApiHost, + cookiesOnly, + cookieDomain + }) + const consentApiUrl = consentProxyEndpoints.createOrUpdateRecord + const trackingKeys = utils.getTrackingKeys(legislationId) + const { sendConsent, onConsentChange } = actions + + /** + * @param {"allow"|"block"} type + * @param {boolean} checked + */ + const radioBtnProps = (type, checked) => ({ + name: 'consent', + type, + checked, + trackingKeys, + buttonText, + onChange: onConsentChange + }) + + /** @type {XPrivacyManager.FormProps} */ + const formProps = { + consent, + consentApiUrl, + trackingKeys, + buttonText, + sendConsent: () => { + return sendConsent({ + setConsentCookie: legislationId === 'gdpr', + consentApiUrl, + onConsentSavedCallbacks, + consentSource, + cookieDomain, + fow + }) + } + } + + return ( + <div className={s.consent} data-component="x-privacy-manager"> + {renderLoggedOutWarning(userId, loginUrl)} + <div className={s.messages} aria-live="polite"> + {renderMessage(isLoading, _response, referrer)} + </div> + <Form {...formProps}> + <RadioBtn {...radioBtnProps('allow', consent === true)} /> + <RadioBtn {...radioBtnProps('block', consent === false)} type="block" checked={consent === false} /> + </Form> + </div> + ) +} + +export const PrivacyManager = withCustomActions(BasePrivacyManager) diff --git a/components/x-privacy-manager/src/privacy-manager.scss b/components/x-privacy-manager/src/privacy-manager.scss new file mode 100644 index 000000000..ba9325a28 --- /dev/null +++ b/components/x-privacy-manager/src/privacy-manager.scss @@ -0,0 +1,81 @@ +@import 'o-buttons/main'; +@import 'o-colors/main'; +@import 'o-grid/main'; +@import 'o-spacing/main'; +@import 'o-typography/main'; + +.v-middle { + display: inline-block; + vertical-align: middle; +} + +.centred { + text-align: center; +} + +.loading { + composes: v-middle; + margin-left: oSpacingByName(s4) +} + +.consent { + @include oTypographySans($scale: 0, $line-height: 1.6); + + & * { + box-sizing: border-box; + } +} + +.consent__title { + @include oTypographyHeading($level: 1); +} + +.consent__copy { + & a { + @include oTypographyLink; + } + + & .consent__copy--cta { + padding: 0 oSpacingByName(m12); + font-weight: 600; + text-align: center; + } +} + + +.divider { + margin-top: oSpacingByName(s8); +} + +.form__title { + @include oTypographyHeading($level: 4); + + margin-top: oSpacingByName(s8); +} + +.form__controls { + @include oGridRespondTo($from: S) { + display: flex; + } + + margin-top: oSpacingByName(s6); +} + +.form__submit { + @include oButtonsContent( + $opts: ( + 'type': 'primary', + 'size': 'big' + ) + ); + + display: block; + margin: oSpacingByName(s8) auto 0; + padding: 0 oSpacingByName(m12); + + &:focus-visible { + outline: 2px solid oColorsByName('teal-40'); + outline-offset: 3px; + background-color: oColorsByName('teal-40'); + } +} diff --git a/components/x-privacy-manager/src/utils.js b/components/x-privacy-manager/src/utils.js new file mode 100644 index 000000000..01f760fba --- /dev/null +++ b/components/x-privacy-manager/src/utils.js @@ -0,0 +1,66 @@ +/** @type {XPrivacyManager.TrackingKey[]} */ +const trackingKeys = [ + 'advertising-toggle-block', + 'advertising-toggle-allow', + 'consent-allow', + 'consent-block' +] + +/** + * Create a look-up table legislationId-specific tracking event names + * e.g. { 'advertising-toggle-block': 'gdpr-advertising-toggle-block' } + * + * @param {string} legislationId + * + * @returns {XPrivacyManager.TrackingKeys} + */ +export function getTrackingKeys(legislationId) { + /** @type Record<TrackingKey, string> */ + const dict = {} + for (const key of trackingKeys) { + dict[key] = `${legislationId}-${key}` + } + + return dict +} + +/** + * @param {{ + * userId: string; + * consentProxyApiHost: string; + * cookiesOnly?: boolean; + * cookieDomain?: string; + * }} param + * + * @returns {XPrivacyManager.ConsentProxyEndpoint} + */ +export function getConsentProxyEndpoints({ + userId, + consentProxyApiHost, + cookiesOnly = false, + cookieDomain = '' +}) { + if (cookieDomain.length > 0) { + // Override the domain so that set-cookie headers in consent api responses are respected + consentProxyApiHost = consentProxyApiHost.replace('.ft.com', cookieDomain) + } + + const endpointDefault = `${consentProxyApiHost}/__consent/consent-record-cookie` + + if (userId && !cookiesOnly) { + const endpointCore = `${consentProxyApiHost}/__consent/consent-record/FTPINK/${userId}` + const endpointEnhanced = `${consentProxyApiHost}/__consent/consent/FTPINK/${userId}` + + return { + core: endpointCore, + enhanced: endpointEnhanced, + createOrUpdateRecord: endpointCore + } + } + + return { + core: endpointDefault, + enhanced: endpointDefault, + createOrUpdateRecord: endpointDefault + } +} diff --git a/components/x-privacy-manager/storybook/data.js b/components/x-privacy-manager/storybook/data.js new file mode 100644 index 000000000..b44eb92a5 --- /dev/null +++ b/components/x-privacy-manager/storybook/data.js @@ -0,0 +1,73 @@ +import fetchMock from 'fetch-mock' + +export const CONSENT_API = 'https://mock-consent.ft.com' + +const legislations = ['gdpr', 'ccpa'] + +export const referrers = { + 'ft.com': 'www.ft.com', + 'exec-appointments.com': 'www.exec-appointments.com', + 'fdibenchmark.com': 'www.fdibenchmark.com', + 'fdiintelligence.com': 'www.fdiintelligence.com', + 'fdimarkets.com': 'www.fdimarkets.com', + 'fdireports.com': 'www.fdireports.com', + 'ftadviser.com': 'www.ftadviser.com', + 'ftconfidentialresearch.com': 'www.ftconfidentialresearch.com', + 'globalriskregulator.com': 'www.globalriskregulator.com', + 'investorschronicle.co.uk': 'www.investorschronicle.co.uk', + 'non-execs.com': 'www.non-execs.com', + 'pensions-expert.com': 'www.pensions-expert.com', + 'pwmnet.com': 'www.pwmnet.com', + 'thebanker.com': 'www.thebanker.com', + 'thebankerdatabase.com': 'www.thebankerdatabase.com', + Default: '' +} + +export const defaultArgs = { + userId: 'fakeUserId', + legislationId: 'ccpa', + referrer: 'ft.com', + loginUrl: 'https://www.ft.com/login?location=/', + fow: { + id: 'privacyCCPA', + version: 'H0IeyQBalorD.6nTqqzhNTKECSgOPJCG' + }, + consent: true, + consentSource: 'next-control-centre', + consentProxyApiHost: CONSENT_API, + buttonText: { + allow: { + label: 'Allow', + text: 'See personalised adverts' + }, + block: { + label: 'Block', + text: 'Opt out of personalised adverts' + }, + submit: { + label: 'Save' + } + } +} + +export const defaultArgTypes = { + userId: { + name: 'Authentication', + control: { type: 'select', options: { loggedIn: defaultArgs.userId, loggedOut: undefined } } + }, + legislationId: { control: { type: 'select', options: legislations } }, + referrer: { control: { type: 'select', options: referrers } }, + consent: { control: { type: 'boolean' }, name: 'consent' }, + fow: { disable: true }, + consentSource: { disable: true }, + consentProxyApiHost: { disable: true }, + buttonText: { disable: true } +} + +export const getFetchMock = (status = 200, options = {}) => { + fetchMock.reset() + fetchMock.mock('https://mock-consent.ft.com/__consent/consent-record/FTPINK/fakeUserId', status, { + delay: 1000, + ...options + }) +} diff --git a/components/x-privacy-manager/storybook/index.jsx b/components/x-privacy-manager/storybook/index.jsx new file mode 100644 index 000000000..e9002104b --- /dev/null +++ b/components/x-privacy-manager/storybook/index.jsx @@ -0,0 +1,12 @@ +export { LegislationCCPA } from './stories/legislation-ccpa' +export { LegislationGDPR } from './stories/legislation-gdpr' + +export { ConsentIndeterminate } from './stories/consent-indeterminate' +export { ConsentAccepted } from './stories/consent-accepted' +export { ConsentBlocked } from './stories/consent-blocked' + +export { SaveFailed } from './stories/save-failed' + +export default { + title: 'x-privacy-manager' +} diff --git a/components/x-privacy-manager/storybook/stories/consent-accepted.js b/components/x-privacy-manager/storybook/stories/consent-accepted.js new file mode 100644 index 000000000..dfff5d0f7 --- /dev/null +++ b/components/x-privacy-manager/storybook/stories/consent-accepted.js @@ -0,0 +1,17 @@ +import { StoryContainer } from '../story-container' +import { defaultArgs, defaultArgTypes, getFetchMock } from '../data' + +/** + * @param {XPrivacyManager.PrivacyManagerProps} args + */ +export const ConsentAccepted = (args) => { + getFetchMock(200) + return StoryContainer(args) +} + +ConsentAccepted.storyName = 'Consent: accepted' +ConsentAccepted.args = { + ...defaultArgs, + consent: true +} +ConsentAccepted.argTypes = defaultArgTypes diff --git a/components/x-privacy-manager/storybook/stories/consent-blocked.js b/components/x-privacy-manager/storybook/stories/consent-blocked.js new file mode 100644 index 000000000..91e12e67c --- /dev/null +++ b/components/x-privacy-manager/storybook/stories/consent-blocked.js @@ -0,0 +1,14 @@ +import { StoryContainer } from '../story-container' +import { defaultArgs, defaultArgTypes, getFetchMock } from '../data' + +export const ConsentBlocked = (args) => { + getFetchMock(200) + return StoryContainer(args) +} + +ConsentBlocked.storyName = 'Consent: blocked' +ConsentBlocked.args = { + ...defaultArgs, + consent: false +} +ConsentBlocked.argTypes = defaultArgTypes diff --git a/components/x-privacy-manager/storybook/stories/consent-indeterminate.js b/components/x-privacy-manager/storybook/stories/consent-indeterminate.js new file mode 100644 index 000000000..b538a4284 --- /dev/null +++ b/components/x-privacy-manager/storybook/stories/consent-indeterminate.js @@ -0,0 +1,17 @@ +import { StoryContainer } from '../story-container' +import { defaultArgs, defaultArgTypes, getFetchMock } from '../data' + +/** + * @param {XPrivacyManager.PrivacyManagerProps} args + */ +export const ConsentIndeterminate = (args) => { + getFetchMock(200) + return StoryContainer(args) +} + +ConsentIndeterminate.storyName = 'Consent: indeterminate' +ConsentIndeterminate.args = { + ...defaultArgs, + consent: undefined +} +ConsentIndeterminate.argTypes = defaultArgTypes diff --git a/components/x-privacy-manager/storybook/stories/legislation-ccpa.js b/components/x-privacy-manager/storybook/stories/legislation-ccpa.js new file mode 100644 index 000000000..5c7f6e431 --- /dev/null +++ b/components/x-privacy-manager/storybook/stories/legislation-ccpa.js @@ -0,0 +1,11 @@ +import { StoryContainer } from '../story-container' +import { defaultArgs, defaultArgTypes, getFetchMock } from '../data' + +export const LegislationCCPA = (args) => { + getFetchMock(200) + return StoryContainer(args) +} + +LegislationCCPA.storyName = 'Legislation: CCPA' +LegislationCCPA.args = defaultArgs +LegislationCCPA.argTypes = defaultArgTypes diff --git a/components/x-privacy-manager/storybook/stories/legislation-gdpr.js b/components/x-privacy-manager/storybook/stories/legislation-gdpr.js new file mode 100644 index 000000000..20e523d54 --- /dev/null +++ b/components/x-privacy-manager/storybook/stories/legislation-gdpr.js @@ -0,0 +1,27 @@ +import { StoryContainer } from '../story-container' +import { defaultArgs, defaultArgTypes, getFetchMock } from '../data' + +const args = { + ...defaultArgs, + legislationId: 'gdpr', + consent: undefined, + buttonText: { + allow: { + label: 'Allow', + text: 'See personalised advertising and allow measurement of advertising effectiveness' + }, + block: { + label: 'Block', + text: 'Block personalised advertising and measurement of advertising effectiveness' + } + } +} + +export const LegislationGDPR = (args) => { + getFetchMock(200) + return StoryContainer(args) +} + +LegislationGDPR.storyName = 'Legislation: GDPR' +LegislationGDPR.args = args +LegislationGDPR.argTypes = defaultArgTypes diff --git a/components/x-privacy-manager/storybook/stories/save-failed.js b/components/x-privacy-manager/storybook/stories/save-failed.js new file mode 100644 index 000000000..448942cd9 --- /dev/null +++ b/components/x-privacy-manager/storybook/stories/save-failed.js @@ -0,0 +1,14 @@ +import { StoryContainer } from '../story-container' +import { defaultArgs, defaultArgTypes, getFetchMock } from '../data' + +/** + * @param {XPrivacyManager.PrivacyManagerProps} args + */ +export const SaveFailed = (args) => { + getFetchMock(500) + return StoryContainer(args) +} + +SaveFailed.storyName = 'Save failed' +SaveFailed.args = defaultArgs +SaveFailed.argTypes = defaultArgTypes diff --git a/components/x-privacy-manager/storybook/story-container.jsx b/components/x-privacy-manager/storybook/story-container.jsx new file mode 100644 index 000000000..c359ba518 --- /dev/null +++ b/components/x-privacy-manager/storybook/story-container.jsx @@ -0,0 +1,28 @@ +import React from 'react' +import { Helmet } from 'react-helmet' + +import BuildService from '../../../.storybook/build-service' +import { PrivacyManager } from '../src/privacy-manager' + +const dependencies = { + 'o-loading': '^4.0.0', + 'o-message': '^4.0.0', + 'o-typography': '^6.0.0' +} + +/** + * @param {import("../typings/x-privacy-manager").PrivacyManagerProps} args + */ +export function StoryContainer(args) { + return ( + <div className="story-container"> + {dependencies && <BuildService dependencies={dependencies} />} + <Helmet> + <link rel="stylesheet" href={`components/x-privacy-manager/dist/privacy-manager.css`} /> + </Helmet> + <div style={{ maxWidth: 740, margin: 'auto' }}> + <PrivacyManager {...args} /> + </div> + </div> + ) +} diff --git a/components/x-privacy-manager/typings/internal.d.ts b/components/x-privacy-manager/typings/internal.d.ts new file mode 100644 index 000000000..c72e8a203 --- /dev/null +++ b/components/x-privacy-manager/typings/internal.d.ts @@ -0,0 +1,4 @@ +declare module '*.scss' { + const content: { [className: string]: string } + export default content +} diff --git a/components/x-privacy-manager/typings/x-privacy-manager.d.ts b/components/x-privacy-manager/typings/x-privacy-manager.d.ts new file mode 100644 index 000000000..7c6db29d8 --- /dev/null +++ b/components/x-privacy-manager/typings/x-privacy-manager.d.ts @@ -0,0 +1,101 @@ +import * as React from 'react' + +export type ConsentProxyEndpoint = Record<'core' | 'enhanced' | 'createOrUpdateRecord', string> +export type ConsentProxyEndpoints = Partial<{ [key in keyof ConsentProxyEndpoint]: string }> + +export type TrackingKey = + | 'advertising-toggle-allow' + | 'advertising-toggle-block' + | 'consent-allow' + | 'consent-block' + +export type TrackingKeys = Record<TrackingKey, string> + +export interface CategoryPayload { + onsite: { + status: boolean + lbi: boolean + source: string + fow: string + } +} + +type ConsentType = 'behaviouralAds' | 'demographicAds' | 'programmaticAds' +type ConsentData = Record<ConsentType, CategoryPayload> + +interface ConsentPayload { + formOfWordsId: string + consentSource: string + data: ConsentData +} + +export type OnSaveCallback = (err: null | Error, data: { consent: boolean; payload: ConsentPayload }) => void + +export interface SendConsentProps { + setConsentCookie: boolean + consentApiUrl: string + onConsentSavedCallbacks: OnSaveCallback[] + consentSource: string + cookieDomain: string + fow: FoWConfig +} + +export interface _Response { + ok: boolean + status?: number +} + +export interface Actions { + onConsentChange: () => void + sendConsent: (payload: SendConsentProps) => Promise<{ _response: _Response }> +} + +export interface FoWConfig { + id: string + version: string +} + +export interface ButtonLabel { + label: string + text?: string +} + +export interface ButtonText { + allow: ButtonLabel + block: ButtonLabel + submit: ButtonLabel +} + +export interface PrivacyManagerProps { + referrer?: string + consent?: boolean + cookiesOnly?: boolean + cookieDomain?: string + buttonText?: ButtonText + loginUrl?: string + userId: string + legislationId: string + fow: FoWConfig + consentSource: string + consentProxyApiHost: string + onConsentSavedCallbacks?: OnSaveCallback[] +} + +export interface BasePrivacyManagerProps extends PrivacyManagerProps { + actions: Actions + isLoading?: boolean + _response: _Response +} + +export interface FormProps { + consent: boolean + consentApiUrl: string + sendConsent: Actions['sendConsent'] + trackingKeys: TrackingKeys + buttonText: ButtonText + children: React.ReactElement +} + +export { PrivacyManager } from '../src/privacy-manager' + +export as namespace XPrivacyManager diff --git a/components/x-styling-demo/package.json b/components/x-styling-demo/package.json index bf0444556..79fb2863e 100644 --- a/components/x-styling-demo/package.json +++ b/components/x-styling-demo/package.json @@ -2,13 +2,13 @@ "name": "@financial-times/x-styling-demo", "version": "0.0.0", "description": "", + "source": "src/Button.jsx", "main": "dist/Button.cjs.js", "browser": "dist/Button.es5.js", "module": "dist/Button.esm.js", "style": "dist/Button.css", "private": true, "scripts": { - "prepare": "npm run build", "build": "node rollup.js", "start": "node rollup.js --watch" }, @@ -23,6 +23,6 @@ "classnames": "^2.2.6" }, "engines": { - "node": ">= 6.0.0" + "node": "12.x" } } diff --git a/components/x-styling-demo/rollup.js b/components/x-styling-demo/rollup.js index ffd6fd551..07750437c 100644 --- a/components/x-styling-demo/rollup.js +++ b/components/x-styling-demo/rollup.js @@ -1,4 +1,4 @@ -const xRollup = require('@financial-times/x-rollup'); -const pkg = require('./package.json'); +const xRollup = require('@financial-times/x-rollup') +const pkg = require('./package.json') -xRollup({ input: './src/Button.jsx', pkg }); +xRollup({ input: './src/Button.jsx', pkg }) diff --git a/components/x-styling-demo/src/Button.jsx b/components/x-styling-demo/src/Button.jsx index f1c56e107..fe460605e 100644 --- a/components/x-styling-demo/src/Button.jsx +++ b/components/x-styling-demo/src/Button.jsx @@ -1,13 +1,13 @@ -import { h } from '@financial-times/x-engine'; -import buttonStyles from './Button.css'; -import classNames from 'classnames'; +import { h } from '@financial-times/x-engine' +import buttonStyles from './Button.css' +import classNames from 'classnames' -export const Button = ({large, danger}) => <button - className={classNames( - buttonStyles.button, - { +export const Button = ({ large, danger }) => ( + <button + className={classNames(buttonStyles.button, { [buttonStyles.large]: large, - [buttonStyles.danger]: danger, - } - )} ->Click me!</button>; + [buttonStyles.danger]: danger + })}> + Click me! + </button> +) diff --git a/components/x-styling-demo/stories/index.js b/components/x-styling-demo/stories/index.js deleted file mode 100644 index 7e672112d..000000000 --- a/components/x-styling-demo/stories/index.js +++ /dev/null @@ -1,17 +0,0 @@ -const { Button } = require('../'); - -exports.component = Button; -exports.package = require('../package.json'); -exports.stories = [ - require('./styling'), -]; - -exports.knobs = (data, { boolean }) => ({ - danger() { - return boolean('Danger', data.danger); - }, - - large() { - return boolean('Large', data.large); - } -}); diff --git a/components/x-styling-demo/stories/styling.js b/components/x-styling-demo/stories/styling.js deleted file mode 100644 index 2df396b11..000000000 --- a/components/x-styling-demo/stories/styling.js +++ /dev/null @@ -1,15 +0,0 @@ -exports.title = 'Styling'; - -exports.data = { - danger: false, - large: false, -}; - -exports.knobs = [ - 'danger', - 'large', -]; - -// This reference is only required for hot module loading in development -// <https://webpack.js.org/concepts/hot-module-replacement/> -exports.m = module; diff --git a/components/x-styling-demo/storybook/index.jsx b/components/x-styling-demo/storybook/index.jsx new file mode 100644 index 000000000..d6c8d3949 --- /dev/null +++ b/components/x-styling-demo/storybook/index.jsx @@ -0,0 +1,19 @@ +import { Button } from '../src/Button' +import React from 'react' +import { Helmet } from 'react-helmet' + +export default { + title: 'x-styling-demo' +} + +export const Styling = (args) => { + return ( + <div className="story-container"> + <Helmet> + <link rel="stylesheet" href={`components/@financial-times/x-styling-demo/dist/Button.css`} /> + </Helmet> + <Button {...args} /> + </div> + ) +} +Styling.args = { danger: false, large: false } diff --git a/components/x-teaser-timeline/.bowerrc b/components/x-teaser-timeline/.bowerrc new file mode 100644 index 000000000..b540c6fdb --- /dev/null +++ b/components/x-teaser-timeline/.bowerrc @@ -0,0 +1,8 @@ +{ + "registry": { + "search": [ + "https://origami-bower-registry.ft.com", + "https://registry.bower.io" + ] + } +} \ No newline at end of file diff --git a/components/x-teaser-timeline/__tests__/TeaserTimeline.test.jsx b/components/x-teaser-timeline/__tests__/TeaserTimeline.test.jsx new file mode 100644 index 000000000..c20ee752f --- /dev/null +++ b/components/x-teaser-timeline/__tests__/TeaserTimeline.test.jsx @@ -0,0 +1,194 @@ +const renderer = require('react-test-renderer') +const { h } = require('@financial-times/x-engine') +const { shallow } = require('@financial-times/x-test-utils/enzyme') +const contentItems = require('../storybook/content-items.json') + +const { TeaserTimeline } = require('../') + +describe('x-teaser-timeline', () => { + let props + let tree + + beforeEach(() => { + props = { + items: contentItems, + timezoneOffset: -60, + localTodayDate: '2018-10-17' + } + }) + + describe('given latestItemsTime is set', () => { + beforeEach(() => { + tree = renderer + .create(<TeaserTimeline {...props} latestItemsTime="2018-10-17T12:10:33.000Z" />) + .toJSON() + }) + + it('renders latest, earlier, yesterday and October 15th item groups', () => { + expect(tree).toMatchSnapshot() + }) + }) + + describe('given latestItemsTime is set and results in all today`s articles being "latest"', () => { + let component + + beforeEach(() => { + component = shallow( + <TeaserTimeline {...props} timezoneOffset={0} latestItemsTime="2018-10-17T00:00:00.000Z" /> + ) + }) + + it('does not render the empty "earlier today" group', () => { + expect(component.render().find('section')).toHaveLength(3) + expect(component.render().find('section h2').text().toLowerCase().includes('earlier today')).toBe(false) + }) + }) + + describe('given latestItemsTime is set and results in all today\'s and some of yesterday\'s articles being "latest"', () => { + beforeEach(() => { + tree = renderer + .create( + <TeaserTimeline + {...props} + timezoneOffset={0} + latestItemsTime="2018-10-16T11:59:59.999Z" + latestItemsAgeHours={36} + /> + ) + .toJSON() + }) + + it('renders latest, yesterday and October 15th item groups (no earlier today)', () => { + expect(tree).toMatchSnapshot() + }) + }) + + describe('given latestItemsTime is not set', () => { + beforeEach(() => { + tree = renderer.create(<TeaserTimeline {...props} />).toJSON() + }) + + it('renders earlier, yesterday and October 15th item groups (no latest)', () => { + expect(tree).toMatchSnapshot() + }) + }) + + describe('given latestItemsTime is set but is more than latestItemsAgeHours ago', () => { + beforeEach(() => { + tree = renderer + .create( + <TeaserTimeline {...props} latestItemsTime="2018-10-15T11:59:59.999Z" latestItemsAgeHours={36} /> + ) + .toJSON() + }) + + it('ignores latestItemsTime and renders earlier, yesterday and October 15th item groups (no latest)', () => { + expect(tree).toMatchSnapshot() + }) + }) + + describe('given latestItemsTime is set but is not same date as localTodayDate', () => { + beforeEach(() => { + tree = renderer + .create(<TeaserTimeline {...props} latestItemsTime="2018-10-16T12:10:33.000Z" />) + .toJSON() + }) + + it('ignores latestItemsTime and renders earlier, yesterday and October 15th item groups (no latest)', () => { + expect(tree).toMatchSnapshot() + }) + }) + + describe('showSaveButtons', () => { + describe('given showSaveButtons is not set or is true', () => { + beforeEach(() => { + tree = renderer.create(<TeaserTimeline {...props} />).toJSON() + }) + + it('renders save buttons by default', () => { + expect(tree).toMatchSnapshot() + }) + }) + + describe('given showSaveButtons is set to false', () => { + beforeEach(() => { + tree = renderer.create(<TeaserTimeline {...props} showSaveButtons={false} />).toJSON() + }) + + it('does not render the save buttons', () => { + expect(tree).toMatchSnapshot() + }) + }) + }) + + describe('given no item are provided', () => { + let component + + beforeEach(() => { + delete props.items + component = shallow(<TeaserTimeline {...props} />) + }) + + it('should render nothing', () => { + expect(component.html()).toEqual(null) + }) + }) + + describe('custom slot', () => { + let component + + describe('custom slot content is a string', () => { + describe('without latestArticlesTime set', () => { + beforeEach(() => { + component = shallow( + <TeaserTimeline + {...props} + customSlotContent='<div class="custom-slot">Custom slot content</div>' + customSlotPosition={3} + /> + ) + }) + + it('has custom content in correct position', () => { + expect(component.render().find('.custom-slot')).toHaveLength(1) + expect(component.render().find('li').eq(3).find('.custom-slot')).toHaveLength(1) + }) + }) + + describe('with latestArticlesTime set', () => { + beforeEach(() => { + component = shallow( + <TeaserTimeline + {...props} + customSlotContent='<div class="custom-slot">Custom slot content</div>' + customSlotPosition={2} + latestArticlesTime="2018-10-16T12:10:33.000Z" + /> + ) + }) + + it('has custom content in correct position', () => { + expect(component.render().find('.custom-slot')).toHaveLength(1) + expect(component.render().find('li').eq(2).find('.custom-slot')).toHaveLength(1) + }) + }) + }) + + describe('custom slot content is a node', () => { + beforeEach(() => { + component = shallow( + <TeaserTimeline + {...props} + customSlotContent={<div className="custom-slot">Custom slot content</div>} + customSlotPosition={3} + /> + ) + }) + + it('has custom content in correct position', () => { + expect(component.render().find('.custom-slot')).toHaveLength(1) + expect(component.render().find('li').eq(3).find('.custom-slot')).toHaveLength(1) + }) + }) + }) +}) diff --git a/components/x-teaser-timeline/__tests__/__snapshots__/TeaserTimeline.test.jsx.snap b/components/x-teaser-timeline/__tests__/__snapshots__/TeaserTimeline.test.jsx.snap new file mode 100644 index 000000000..f2cb79fb1 --- /dev/null +++ b/components/x-teaser-timeline/__tests__/__snapshots__/TeaserTimeline.test.jsx.snap @@ -0,0 +1,17021 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`x-teaser-timeline given latestItemsTime is not set renders earlier, yesterday and October 15th item groups (no latest) 1`] = ` +<div> + <section + className="TeaserTimeline_itemGroup__2YuVE" + > + <h2 + className="TeaserTimeline_itemGroup__heading__3KrJD" + > + Earlier Today + </h2> + <ul + className="TeaserTimeline_itemGroup__items__3ZuL6" + > + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="65867e26-d203-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit Briefing" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit-briefing" + > + Brexit Briefing + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/65867e26-d203-11e8-a9f2-7574db66bcd5" + > + Is December looming as the new Brexit deadline? + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/65867e26-d203-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + As Theresa May prepares to meet EU leaders, the prospect looks increasingly likely + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/65867e26-d203-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fc8a8d52e-d20a-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/65867e26-d203-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="65867e26-d203-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Is December looming as the new Brexit deadline? to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="65867e26-d203-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="93614586-d1f1-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/93614586-d1f1-11e8-a9f2-7574db66bcd5" + > + May seeks to overcome deadlock after Brexit setback + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/93614586-d1f1-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + UK and EU consider extending transition deal to defuse dispute over Irish border backstop + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/93614586-d1f1-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F8ba50178-d216-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/93614586-d1f1-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="93614586-d1f1-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Saved to myFT" + aria-pressed={true} + className="ArticleSaveButton_button__2_wUr" + data-content-id="93614586-d1f1-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Saved + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + > + Midsized UK businesses turn sour on Brexit + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Quarterly survey shows more companies now believe Brexit will damage their business than help them + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa3695666-d201-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Midsized UK businesses turn sour on Brexit to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="6582b8ce-d175-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/6582b8ce-d175-11e8-a9f2-7574db66bcd5" + > + Barnier open to extending Brexit transition by a year + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/6582b8ce-d175-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + In return, Theresa May must accept ‘two-tier’ backstop to avoid a hard Irish border + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/6582b8ce-d175-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F9be47d9e-d17a-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/6582b8ce-d175-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="6582b8ce-d175-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Barnier open to extending Brexit transition by a year to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="6582b8ce-d175-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image o-teaser--opinion js-teaser" + data-id="7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + Inside Business + </span> + <a + aria-label="Category: Sarah Gordon" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/sarah-gordon" + > + Sarah Gordon + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + > + UK lets down business with lack of Brexit advice + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Governments should be more concerned about SMEs: if supply chains falter, so will economic growth + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F23529cb0-d140-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save UK lets down business with lack of Brexit advice to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="868cedae-d18a-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Global trade" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/global-trade" + > + Global trade + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/868cedae-d18a-11e8-a9f2-7574db66bcd5" + > + Trump looks to start formal US-UK trade talks + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/868cedae-d18a-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + White House makes London a priority ‘as soon as it is ready’ after Brexit + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/868cedae-d18a-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Ff08f8340-d1a0-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/868cedae-d18a-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="868cedae-d18a-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Trump looks to start formal US-UK trade talks to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="868cedae-d18a-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + </ul> + </section> + <section + className="TeaserTimeline_itemGroup__2YuVE" + > + <h2 + className="TeaserTimeline_itemGroup__heading__3KrJD" + > + Yesterday + </h2> + <ul + className="TeaserTimeline_itemGroup__items__3ZuL6" + > + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="c276c8a2-d159-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: House of Commons UK" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/fa354fe2-b2e0-483a-9267-cffe6ef9083b" + > + House of Commons UK + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/c276c8a2-d159-11e8-a9f2-7574db66bcd5" + > + Bercow faces mounting calls to resign + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/c276c8a2-d159-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Maria Miller says Speaker needs to step down immediately to ‘drive culture change’ + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/c276c8a2-d159-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fece474ca-d151-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/c276c8a2-d159-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="c276c8a2-d159-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Bercow faces mounting calls to resign to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="c276c8a2-d159-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image o-teaser--opinion js-teaser" + data-id="a1566658-d12e-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + The FT View + </span> + <a + aria-label="Category: The editorial board" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/ft-view" + > + The editorial board + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/a1566658-d12e-11e8-a9f2-7574db66bcd5" + > + A culture shift to clear Westminster’s toxic air + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/a1566658-d12e-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Speaker John Bercow should take responsibility — and go now + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/a1566658-d12e-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F5319de7e-d12f-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/a1566658-d12e-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="a1566658-d12e-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save A culture shift to clear Westminster’s toxic air to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="a1566658-d12e-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + > + EU demands UK break Brexit impasse + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + May tells Eurosceptics acceptable deal is in sight as Barnier says ‘Brits need more time’ + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F62fd9e46-d14f-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save EU demands UK break Brexit impasse to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit Briefing" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit-briefing" + > + Brexit Briefing + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + > + Brexit, Scotland and the threat to constitutional unity + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Scottish Tories fear that possible arrangements for Northern Ireland threaten the union itself + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fcb11f55e-d140-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Brexit, Scotland and the threat to constitutional unity to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="d7472b20-d148-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/d7472b20-d148-11e8-a9f2-7574db66bcd5" + > + EU’s Tusk to ask UK for ‘concrete proposals’ to end Brexit impasse + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/d7472b20-d148-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F383a4ea2-d14a-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/d7472b20-d148-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="d7472b20-d148-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save EU’s Tusk to ask UK for ‘concrete proposals’ to end Brexit impasse to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="d7472b20-d148-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Wells Fargo" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/1120e446-6695-4e49-91e6-fd1f7698388e" + > + Wells Fargo + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + > + Wells Fargo applies for licence in France as part of Brexit strategy + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F31d2be9e-d13d-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Wells Fargo applies for licence in France as part of Brexit strategy to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK welfare reform" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/c4c9204f-8335-42c3-9415-c2c8cd971125" + > + UK welfare reform + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + > + Rollout of controversial UK welfare reform faces fresh delay + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Universal credit system not expected to be fully operational until December 2023 + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fd7517de4-d131-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Rollout of controversial UK welfare reform faces fresh delay to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser js-teaser" + data-id="1969887a-d11e-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Pound Sterling" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/466a4700-307f-47cc-83f1-c5f97a172232" + > + Pound Sterling + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/1969887a-d11e-11e8-a9f2-7574db66bcd5" + > + Pound rallies after upbeat wage growth reading + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/1969887a-d11e-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Analysts remain cautious despite optimistic data + </a> + </p> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/1969887a-d11e-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="1969887a-d11e-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Pound rallies after upbeat wage growth reading to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="1969887a-d11e-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK politics & policy" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/world/uk/politics" + > + UK politics & policy + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + > + Problem gambling shake-up set to be brought forward + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + tabIndex={-1} + > + Blow for bookmakers as ministers seek to speed up start of fixed-odds betting terminals limits + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa6afae18-d069-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + className="ArticleSaveButton_root__Utel0" + data-content-id="dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Problem gambling shake-up set to be brought forward to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Technology sector" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/companies/technology" + > + Technology sector + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + > + Crypto exchange Coinbase sets up Brexit contingency + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + US digital start-up to scale up EU operations with new Dublin branch + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F9a6adda6-d07a-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Crypto exchange Coinbase sets up Brexit contingency to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + </ul> + </section> + <section + className="TeaserTimeline_itemGroup__2YuVE" + > + <h2 + className="TeaserTimeline_itemGroup__heading__3KrJD" + > + October 15, 2018 + </h2> + <ul + className="TeaserTimeline_itemGroup__items__3ZuL6" + > + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="2e407a74-d06a-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: World" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/world" + > + World + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/2e407a74-d06a-11e8-a9f2-7574db66bcd5" + > + EU gives UK 24 hour Brexit breathing space + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/2e407a74-d06a-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Theresa May says deal achievable but Britain cannot be kept in backstop indefinitely + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/2e407a74-d06a-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fac0fd098-d075-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/2e407a74-d06a-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="2e407a74-d06a-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save EU gives UK 24 hour Brexit breathing space to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="2e407a74-d06a-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser js-teaser" + data-id="434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + > + EC’s Tusk warns no-deal Brexit ‘is more likely than ever before’ + </a> + </div> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save EC’s Tusk warns no-deal Brexit ‘is more likely than ever before’ to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="fadfb212-d091-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/fadfb212-d091-11e8-a9f2-7574db66bcd5" + > + Barnier plan no solution to Irish border, says Foster + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/fadfb212-d091-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fd0cbda02-cbb7-11e8-8d0b-a6539b949662?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/fadfb212-d091-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="fadfb212-d091-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Barnier plan no solution to Irish border, says Foster to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="fadfb212-d091-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="79939f94-c320-11e8-8d55-54197280d3f7" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + Explainer + </span> + <a + aria-label="Category: Pound Sterling" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/466a4700-307f-47cc-83f1-c5f97a172232" + > + Pound Sterling + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/79939f94-c320-11e8-8d55-54197280d3f7" + > + Pound timeline: from the 1970s to Brexit crunch + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/79939f94-c320-11e8-8d55-54197280d3f7" + tabIndex={-1} + > + Sterling has experienced several periods of volatility in the past 48 years + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/79939f94-c320-11e8-8d55-54197280d3f7" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Faea311c6-c32d-11e8-95b1-d36dfef1b89a?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/79939f94-c320-11e8-8d55-54197280d3f7" + className="ArticleSaveButton_root__Utel0" + data-content-id="79939f94-c320-11e8-8d55-54197280d3f7" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Pound timeline: from the 1970s to Brexit crunch to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="79939f94-c320-11e8-8d55-54197280d3f7" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="bfffa642-d079-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/bfffa642-d079-11e8-a9f2-7574db66bcd5" + > + Brexit talks could drag into December warns Ireland’s Varadkar + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/bfffa642-d079-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Taoiseach says he always believed a deal this month was unlikely + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/bfffa642-d079-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Ff87f782a-d089-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/bfffa642-d079-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="bfffa642-d079-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Brexit talks could drag into December warns Ireland’s Varadkar to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="bfffa642-d079-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="82ae2756-d073-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit Briefing" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit-briefing" + > + Brexit Briefing + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/82ae2756-d073-11e8-a9f2-7574db66bcd5" + > + Crisis or choreography over Brexit? + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/82ae2756-d073-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + While ministers haggle, company bosses press the button on expensive no-deal planning + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/82ae2756-d073-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F31c1c612-d079-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/82ae2756-d073-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="82ae2756-d073-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Crisis or choreography over Brexit? to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="82ae2756-d073-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="a8181102-ce1e-11e8-9fe5-24ad351828ab" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Industrials" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/companies/industrials" + > + Industrials + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/a8181102-ce1e-11e8-9fe5-24ad351828ab" + > + UK shipyards to submit bids to build 5 warships + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/a8181102-ce1e-11e8-9fe5-24ad351828ab" + tabIndex={-1} + > + MoD restarts competition despite industry’s concerns over budget and timing + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/a8181102-ce1e-11e8-9fe5-24ad351828ab" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Ff1191270-ce41-11e8-8d0b-a6539b949662?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/a8181102-ce1e-11e8-9fe5-24ad351828ab" + className="ArticleSaveButton_root__Utel0" + data-content-id="a8181102-ce1e-11e8-9fe5-24ad351828ab" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save UK shipyards to submit bids to build 5 warships to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="a8181102-ce1e-11e8-9fe5-24ad351828ab" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + > + May to address UK parliament on state of Brexit talks + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fc77408fa-d065-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save May to address UK parliament on state of Brexit talks to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK business & economy" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/uk-business-economy" + > + UK business & economy + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + > + Bradford hospital launches AI powered command centre + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + tabIndex={-1} + > + Collaboration with GE Healthcare to create hub monitoring patients to make better use of resources + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F66d87a12-d06f-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + className="ArticleSaveButton_root__Utel0" + data-content-id="ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Bradford hospital launches AI powered command centre to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--opinion js-teaser" + data-id="7c6b9fbb-ad93-34c5-b19b-ea79ffc5f426" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + FT Alphaville + </span> + <a + aria-label="Category: Bryce Elder" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/markets/bryce-elder" + > + Bryce Elder + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="http://ftalphaville.ft.com/marketslive/2018-10-15/" + > + Markets Live: Monday, 15th October 2018 + </a> + </div> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/7c6b9fbb-ad93-34c5-b19b-ea79ffc5f426" + className="ArticleSaveButton_root__Utel0" + data-content-id="7c6b9fbb-ad93-34c5-b19b-ea79ffc5f426" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Markets Live: Monday, 15th October 2018 to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="7c6b9fbb-ad93-34c5-b19b-ea79ffc5f426" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser js-teaser" + data-id="aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK trade" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/uk-trade" + > + UK trade + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + > + Irish deputy PM voices ‘frustration’ at Brexit impasse + </a> + </div> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Irish deputy PM voices ‘frustration’ at Brexit impasse to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + </ul> + </section> +</div> +`; + +exports[`x-teaser-timeline given latestItemsTime is set and results in all today's and some of yesterday's articles being "latest" renders latest, yesterday and October 15th item groups (no earlier today) 1`] = ` +<div> + <section + className="TeaserTimeline_itemGroup__2YuVE" + > + <h2 + className="TeaserTimeline_itemGroup__heading__3KrJD" + > + Latest News + </h2> + <ul + className="TeaserTimeline_itemGroup__items__3ZuL6" + > + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="65867e26-d203-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit Briefing" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit-briefing" + > + Brexit Briefing + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/65867e26-d203-11e8-a9f2-7574db66bcd5" + > + Is December looming as the new Brexit deadline? + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/65867e26-d203-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + As Theresa May prepares to meet EU leaders, the prospect looks increasingly likely + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/65867e26-d203-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fc8a8d52e-d20a-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/65867e26-d203-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="65867e26-d203-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Is December looming as the new Brexit deadline? to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="65867e26-d203-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="93614586-d1f1-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/93614586-d1f1-11e8-a9f2-7574db66bcd5" + > + May seeks to overcome deadlock after Brexit setback + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/93614586-d1f1-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + UK and EU consider extending transition deal to defuse dispute over Irish border backstop + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/93614586-d1f1-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F8ba50178-d216-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/93614586-d1f1-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="93614586-d1f1-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Saved to myFT" + aria-pressed={true} + className="ArticleSaveButton_button__2_wUr" + data-content-id="93614586-d1f1-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Saved + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + > + Midsized UK businesses turn sour on Brexit + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Quarterly survey shows more companies now believe Brexit will damage their business than help them + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa3695666-d201-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Midsized UK businesses turn sour on Brexit to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="6582b8ce-d175-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/6582b8ce-d175-11e8-a9f2-7574db66bcd5" + > + Barnier open to extending Brexit transition by a year + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/6582b8ce-d175-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + In return, Theresa May must accept ‘two-tier’ backstop to avoid a hard Irish border + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/6582b8ce-d175-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F9be47d9e-d17a-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/6582b8ce-d175-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="6582b8ce-d175-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Barnier open to extending Brexit transition by a year to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="6582b8ce-d175-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image o-teaser--opinion js-teaser" + data-id="7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + Inside Business + </span> + <a + aria-label="Category: Sarah Gordon" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/sarah-gordon" + > + Sarah Gordon + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + > + UK lets down business with lack of Brexit advice + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Governments should be more concerned about SMEs: if supply chains falter, so will economic growth + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F23529cb0-d140-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save UK lets down business with lack of Brexit advice to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="868cedae-d18a-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Global trade" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/global-trade" + > + Global trade + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/868cedae-d18a-11e8-a9f2-7574db66bcd5" + > + Trump looks to start formal US-UK trade talks + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/868cedae-d18a-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + White House makes London a priority ‘as soon as it is ready’ after Brexit + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/868cedae-d18a-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Ff08f8340-d1a0-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/868cedae-d18a-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="868cedae-d18a-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Trump looks to start formal US-UK trade talks to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="868cedae-d18a-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="c276c8a2-d159-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: House of Commons UK" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/fa354fe2-b2e0-483a-9267-cffe6ef9083b" + > + House of Commons UK + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/c276c8a2-d159-11e8-a9f2-7574db66bcd5" + > + Bercow faces mounting calls to resign + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/c276c8a2-d159-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Maria Miller says Speaker needs to step down immediately to ‘drive culture change’ + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/c276c8a2-d159-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fece474ca-d151-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/c276c8a2-d159-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="c276c8a2-d159-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Bercow faces mounting calls to resign to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="c276c8a2-d159-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image o-teaser--opinion js-teaser" + data-id="a1566658-d12e-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + The FT View + </span> + <a + aria-label="Category: The editorial board" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/ft-view" + > + The editorial board + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/a1566658-d12e-11e8-a9f2-7574db66bcd5" + > + A culture shift to clear Westminster’s toxic air + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/a1566658-d12e-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Speaker John Bercow should take responsibility — and go now + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/a1566658-d12e-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F5319de7e-d12f-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/a1566658-d12e-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="a1566658-d12e-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save A culture shift to clear Westminster’s toxic air to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="a1566658-d12e-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + > + EU demands UK break Brexit impasse + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + May tells Eurosceptics acceptable deal is in sight as Barnier says ‘Brits need more time’ + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F62fd9e46-d14f-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save EU demands UK break Brexit impasse to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit Briefing" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit-briefing" + > + Brexit Briefing + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + > + Brexit, Scotland and the threat to constitutional unity + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Scottish Tories fear that possible arrangements for Northern Ireland threaten the union itself + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fcb11f55e-d140-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Brexit, Scotland and the threat to constitutional unity to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="d7472b20-d148-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/d7472b20-d148-11e8-a9f2-7574db66bcd5" + > + EU’s Tusk to ask UK for ‘concrete proposals’ to end Brexit impasse + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/d7472b20-d148-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F383a4ea2-d14a-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/d7472b20-d148-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="d7472b20-d148-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save EU’s Tusk to ask UK for ‘concrete proposals’ to end Brexit impasse to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="d7472b20-d148-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Wells Fargo" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/1120e446-6695-4e49-91e6-fd1f7698388e" + > + Wells Fargo + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + > + Wells Fargo applies for licence in France as part of Brexit strategy + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F31d2be9e-d13d-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Wells Fargo applies for licence in France as part of Brexit strategy to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK welfare reform" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/c4c9204f-8335-42c3-9415-c2c8cd971125" + > + UK welfare reform + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + > + Rollout of controversial UK welfare reform faces fresh delay + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Universal credit system not expected to be fully operational until December 2023 + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fd7517de4-d131-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Rollout of controversial UK welfare reform faces fresh delay to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + </ul> + </section> + <section + className="TeaserTimeline_itemGroup__2YuVE" + > + <h2 + className="TeaserTimeline_itemGroup__heading__3KrJD" + > + Yesterday + </h2> + <ul + className="TeaserTimeline_itemGroup__items__3ZuL6" + > + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser js-teaser" + data-id="1969887a-d11e-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Pound Sterling" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/466a4700-307f-47cc-83f1-c5f97a172232" + > + Pound Sterling + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/1969887a-d11e-11e8-a9f2-7574db66bcd5" + > + Pound rallies after upbeat wage growth reading + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/1969887a-d11e-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Analysts remain cautious despite optimistic data + </a> + </p> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/1969887a-d11e-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="1969887a-d11e-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Pound rallies after upbeat wage growth reading to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="1969887a-d11e-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK politics & policy" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/world/uk/politics" + > + UK politics & policy + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + > + Problem gambling shake-up set to be brought forward + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + tabIndex={-1} + > + Blow for bookmakers as ministers seek to speed up start of fixed-odds betting terminals limits + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa6afae18-d069-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + className="ArticleSaveButton_root__Utel0" + data-content-id="dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Problem gambling shake-up set to be brought forward to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + </ul> + </section> + <section + className="TeaserTimeline_itemGroup__2YuVE" + > + <h2 + className="TeaserTimeline_itemGroup__heading__3KrJD" + > + October 15, 2018 + </h2> + <ul + className="TeaserTimeline_itemGroup__items__3ZuL6" + > + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Technology sector" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/companies/technology" + > + Technology sector + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + > + Crypto exchange Coinbase sets up Brexit contingency + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + US digital start-up to scale up EU operations with new Dublin branch + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F9a6adda6-d07a-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Crypto exchange Coinbase sets up Brexit contingency to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="2e407a74-d06a-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: World" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/world" + > + World + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/2e407a74-d06a-11e8-a9f2-7574db66bcd5" + > + EU gives UK 24 hour Brexit breathing space + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/2e407a74-d06a-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Theresa May says deal achievable but Britain cannot be kept in backstop indefinitely + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/2e407a74-d06a-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fac0fd098-d075-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/2e407a74-d06a-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="2e407a74-d06a-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save EU gives UK 24 hour Brexit breathing space to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="2e407a74-d06a-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser js-teaser" + data-id="434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + > + EC’s Tusk warns no-deal Brexit ‘is more likely than ever before’ + </a> + </div> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save EC’s Tusk warns no-deal Brexit ‘is more likely than ever before’ to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="fadfb212-d091-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/fadfb212-d091-11e8-a9f2-7574db66bcd5" + > + Barnier plan no solution to Irish border, says Foster + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/fadfb212-d091-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fd0cbda02-cbb7-11e8-8d0b-a6539b949662?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/fadfb212-d091-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="fadfb212-d091-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Barnier plan no solution to Irish border, says Foster to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="fadfb212-d091-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="79939f94-c320-11e8-8d55-54197280d3f7" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + Explainer + </span> + <a + aria-label="Category: Pound Sterling" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/466a4700-307f-47cc-83f1-c5f97a172232" + > + Pound Sterling + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/79939f94-c320-11e8-8d55-54197280d3f7" + > + Pound timeline: from the 1970s to Brexit crunch + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/79939f94-c320-11e8-8d55-54197280d3f7" + tabIndex={-1} + > + Sterling has experienced several periods of volatility in the past 48 years + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/79939f94-c320-11e8-8d55-54197280d3f7" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Faea311c6-c32d-11e8-95b1-d36dfef1b89a?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/79939f94-c320-11e8-8d55-54197280d3f7" + className="ArticleSaveButton_root__Utel0" + data-content-id="79939f94-c320-11e8-8d55-54197280d3f7" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Pound timeline: from the 1970s to Brexit crunch to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="79939f94-c320-11e8-8d55-54197280d3f7" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="bfffa642-d079-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/bfffa642-d079-11e8-a9f2-7574db66bcd5" + > + Brexit talks could drag into December warns Ireland’s Varadkar + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/bfffa642-d079-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Taoiseach says he always believed a deal this month was unlikely + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/bfffa642-d079-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Ff87f782a-d089-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/bfffa642-d079-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="bfffa642-d079-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Brexit talks could drag into December warns Ireland’s Varadkar to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="bfffa642-d079-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="82ae2756-d073-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit Briefing" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit-briefing" + > + Brexit Briefing + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/82ae2756-d073-11e8-a9f2-7574db66bcd5" + > + Crisis or choreography over Brexit? + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/82ae2756-d073-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + While ministers haggle, company bosses press the button on expensive no-deal planning + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/82ae2756-d073-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F31c1c612-d079-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/82ae2756-d073-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="82ae2756-d073-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Crisis or choreography over Brexit? to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="82ae2756-d073-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="a8181102-ce1e-11e8-9fe5-24ad351828ab" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Industrials" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/companies/industrials" + > + Industrials + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/a8181102-ce1e-11e8-9fe5-24ad351828ab" + > + UK shipyards to submit bids to build 5 warships + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/a8181102-ce1e-11e8-9fe5-24ad351828ab" + tabIndex={-1} + > + MoD restarts competition despite industry’s concerns over budget and timing + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/a8181102-ce1e-11e8-9fe5-24ad351828ab" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Ff1191270-ce41-11e8-8d0b-a6539b949662?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/a8181102-ce1e-11e8-9fe5-24ad351828ab" + className="ArticleSaveButton_root__Utel0" + data-content-id="a8181102-ce1e-11e8-9fe5-24ad351828ab" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save UK shipyards to submit bids to build 5 warships to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="a8181102-ce1e-11e8-9fe5-24ad351828ab" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + > + May to address UK parliament on state of Brexit talks + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fc77408fa-d065-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save May to address UK parliament on state of Brexit talks to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK business & economy" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/uk-business-economy" + > + UK business & economy + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + > + Bradford hospital launches AI powered command centre + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + tabIndex={-1} + > + Collaboration with GE Healthcare to create hub monitoring patients to make better use of resources + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F66d87a12-d06f-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + className="ArticleSaveButton_root__Utel0" + data-content-id="ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Bradford hospital launches AI powered command centre to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--opinion js-teaser" + data-id="7c6b9fbb-ad93-34c5-b19b-ea79ffc5f426" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + FT Alphaville + </span> + <a + aria-label="Category: Bryce Elder" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/markets/bryce-elder" + > + Bryce Elder + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="http://ftalphaville.ft.com/marketslive/2018-10-15/" + > + Markets Live: Monday, 15th October 2018 + </a> + </div> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/7c6b9fbb-ad93-34c5-b19b-ea79ffc5f426" + className="ArticleSaveButton_root__Utel0" + data-content-id="7c6b9fbb-ad93-34c5-b19b-ea79ffc5f426" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Markets Live: Monday, 15th October 2018 to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="7c6b9fbb-ad93-34c5-b19b-ea79ffc5f426" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser js-teaser" + data-id="aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK trade" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/uk-trade" + > + UK trade + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + > + Irish deputy PM voices ‘frustration’ at Brexit impasse + </a> + </div> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Irish deputy PM voices ‘frustration’ at Brexit impasse to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + </ul> + </section> +</div> +`; + +exports[`x-teaser-timeline given latestItemsTime is set but is more than latestItemsAgeHours ago ignores latestItemsTime and renders earlier, yesterday and October 15th item groups (no latest) 1`] = ` +<div> + <section + className="TeaserTimeline_itemGroup__2YuVE" + > + <h2 + className="TeaserTimeline_itemGroup__heading__3KrJD" + > + Earlier Today + </h2> + <ul + className="TeaserTimeline_itemGroup__items__3ZuL6" + > + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="65867e26-d203-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit Briefing" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit-briefing" + > + Brexit Briefing + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/65867e26-d203-11e8-a9f2-7574db66bcd5" + > + Is December looming as the new Brexit deadline? + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/65867e26-d203-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + As Theresa May prepares to meet EU leaders, the prospect looks increasingly likely + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/65867e26-d203-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fc8a8d52e-d20a-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/65867e26-d203-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="65867e26-d203-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Is December looming as the new Brexit deadline? to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="65867e26-d203-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="93614586-d1f1-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/93614586-d1f1-11e8-a9f2-7574db66bcd5" + > + May seeks to overcome deadlock after Brexit setback + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/93614586-d1f1-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + UK and EU consider extending transition deal to defuse dispute over Irish border backstop + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/93614586-d1f1-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F8ba50178-d216-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/93614586-d1f1-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="93614586-d1f1-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Saved to myFT" + aria-pressed={true} + className="ArticleSaveButton_button__2_wUr" + data-content-id="93614586-d1f1-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Saved + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + > + Midsized UK businesses turn sour on Brexit + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Quarterly survey shows more companies now believe Brexit will damage their business than help them + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa3695666-d201-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Midsized UK businesses turn sour on Brexit to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="6582b8ce-d175-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/6582b8ce-d175-11e8-a9f2-7574db66bcd5" + > + Barnier open to extending Brexit transition by a year + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/6582b8ce-d175-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + In return, Theresa May must accept ‘two-tier’ backstop to avoid a hard Irish border + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/6582b8ce-d175-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F9be47d9e-d17a-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/6582b8ce-d175-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="6582b8ce-d175-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Barnier open to extending Brexit transition by a year to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="6582b8ce-d175-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image o-teaser--opinion js-teaser" + data-id="7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + Inside Business + </span> + <a + aria-label="Category: Sarah Gordon" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/sarah-gordon" + > + Sarah Gordon + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + > + UK lets down business with lack of Brexit advice + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Governments should be more concerned about SMEs: if supply chains falter, so will economic growth + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F23529cb0-d140-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save UK lets down business with lack of Brexit advice to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="868cedae-d18a-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Global trade" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/global-trade" + > + Global trade + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/868cedae-d18a-11e8-a9f2-7574db66bcd5" + > + Trump looks to start formal US-UK trade talks + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/868cedae-d18a-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + White House makes London a priority ‘as soon as it is ready’ after Brexit + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/868cedae-d18a-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Ff08f8340-d1a0-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/868cedae-d18a-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="868cedae-d18a-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Trump looks to start formal US-UK trade talks to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="868cedae-d18a-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + </ul> + </section> + <section + className="TeaserTimeline_itemGroup__2YuVE" + > + <h2 + className="TeaserTimeline_itemGroup__heading__3KrJD" + > + Yesterday + </h2> + <ul + className="TeaserTimeline_itemGroup__items__3ZuL6" + > + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="c276c8a2-d159-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: House of Commons UK" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/fa354fe2-b2e0-483a-9267-cffe6ef9083b" + > + House of Commons UK + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/c276c8a2-d159-11e8-a9f2-7574db66bcd5" + > + Bercow faces mounting calls to resign + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/c276c8a2-d159-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Maria Miller says Speaker needs to step down immediately to ‘drive culture change’ + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/c276c8a2-d159-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fece474ca-d151-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/c276c8a2-d159-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="c276c8a2-d159-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Bercow faces mounting calls to resign to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="c276c8a2-d159-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image o-teaser--opinion js-teaser" + data-id="a1566658-d12e-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + The FT View + </span> + <a + aria-label="Category: The editorial board" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/ft-view" + > + The editorial board + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/a1566658-d12e-11e8-a9f2-7574db66bcd5" + > + A culture shift to clear Westminster’s toxic air + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/a1566658-d12e-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Speaker John Bercow should take responsibility — and go now + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/a1566658-d12e-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F5319de7e-d12f-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/a1566658-d12e-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="a1566658-d12e-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save A culture shift to clear Westminster’s toxic air to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="a1566658-d12e-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + > + EU demands UK break Brexit impasse + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + May tells Eurosceptics acceptable deal is in sight as Barnier says ‘Brits need more time’ + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F62fd9e46-d14f-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save EU demands UK break Brexit impasse to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit Briefing" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit-briefing" + > + Brexit Briefing + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + > + Brexit, Scotland and the threat to constitutional unity + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Scottish Tories fear that possible arrangements for Northern Ireland threaten the union itself + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fcb11f55e-d140-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Brexit, Scotland and the threat to constitutional unity to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="d7472b20-d148-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/d7472b20-d148-11e8-a9f2-7574db66bcd5" + > + EU’s Tusk to ask UK for ‘concrete proposals’ to end Brexit impasse + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/d7472b20-d148-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F383a4ea2-d14a-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/d7472b20-d148-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="d7472b20-d148-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save EU’s Tusk to ask UK for ‘concrete proposals’ to end Brexit impasse to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="d7472b20-d148-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Wells Fargo" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/1120e446-6695-4e49-91e6-fd1f7698388e" + > + Wells Fargo + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + > + Wells Fargo applies for licence in France as part of Brexit strategy + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F31d2be9e-d13d-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Wells Fargo applies for licence in France as part of Brexit strategy to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK welfare reform" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/c4c9204f-8335-42c3-9415-c2c8cd971125" + > + UK welfare reform + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + > + Rollout of controversial UK welfare reform faces fresh delay + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Universal credit system not expected to be fully operational until December 2023 + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fd7517de4-d131-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Rollout of controversial UK welfare reform faces fresh delay to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser js-teaser" + data-id="1969887a-d11e-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Pound Sterling" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/466a4700-307f-47cc-83f1-c5f97a172232" + > + Pound Sterling + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/1969887a-d11e-11e8-a9f2-7574db66bcd5" + > + Pound rallies after upbeat wage growth reading + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/1969887a-d11e-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Analysts remain cautious despite optimistic data + </a> + </p> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/1969887a-d11e-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="1969887a-d11e-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Pound rallies after upbeat wage growth reading to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="1969887a-d11e-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK politics & policy" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/world/uk/politics" + > + UK politics & policy + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + > + Problem gambling shake-up set to be brought forward + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + tabIndex={-1} + > + Blow for bookmakers as ministers seek to speed up start of fixed-odds betting terminals limits + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa6afae18-d069-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + className="ArticleSaveButton_root__Utel0" + data-content-id="dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Problem gambling shake-up set to be brought forward to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Technology sector" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/companies/technology" + > + Technology sector + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + > + Crypto exchange Coinbase sets up Brexit contingency + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + US digital start-up to scale up EU operations with new Dublin branch + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F9a6adda6-d07a-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Crypto exchange Coinbase sets up Brexit contingency to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + </ul> + </section> + <section + className="TeaserTimeline_itemGroup__2YuVE" + > + <h2 + className="TeaserTimeline_itemGroup__heading__3KrJD" + > + October 15, 2018 + </h2> + <ul + className="TeaserTimeline_itemGroup__items__3ZuL6" + > + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="2e407a74-d06a-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: World" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/world" + > + World + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/2e407a74-d06a-11e8-a9f2-7574db66bcd5" + > + EU gives UK 24 hour Brexit breathing space + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/2e407a74-d06a-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Theresa May says deal achievable but Britain cannot be kept in backstop indefinitely + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/2e407a74-d06a-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fac0fd098-d075-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/2e407a74-d06a-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="2e407a74-d06a-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save EU gives UK 24 hour Brexit breathing space to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="2e407a74-d06a-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser js-teaser" + data-id="434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + > + EC’s Tusk warns no-deal Brexit ‘is more likely than ever before’ + </a> + </div> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save EC’s Tusk warns no-deal Brexit ‘is more likely than ever before’ to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="fadfb212-d091-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/fadfb212-d091-11e8-a9f2-7574db66bcd5" + > + Barnier plan no solution to Irish border, says Foster + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/fadfb212-d091-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fd0cbda02-cbb7-11e8-8d0b-a6539b949662?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/fadfb212-d091-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="fadfb212-d091-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Barnier plan no solution to Irish border, says Foster to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="fadfb212-d091-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="79939f94-c320-11e8-8d55-54197280d3f7" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + Explainer + </span> + <a + aria-label="Category: Pound Sterling" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/466a4700-307f-47cc-83f1-c5f97a172232" + > + Pound Sterling + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/79939f94-c320-11e8-8d55-54197280d3f7" + > + Pound timeline: from the 1970s to Brexit crunch + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/79939f94-c320-11e8-8d55-54197280d3f7" + tabIndex={-1} + > + Sterling has experienced several periods of volatility in the past 48 years + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/79939f94-c320-11e8-8d55-54197280d3f7" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Faea311c6-c32d-11e8-95b1-d36dfef1b89a?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/79939f94-c320-11e8-8d55-54197280d3f7" + className="ArticleSaveButton_root__Utel0" + data-content-id="79939f94-c320-11e8-8d55-54197280d3f7" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Pound timeline: from the 1970s to Brexit crunch to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="79939f94-c320-11e8-8d55-54197280d3f7" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="bfffa642-d079-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/bfffa642-d079-11e8-a9f2-7574db66bcd5" + > + Brexit talks could drag into December warns Ireland’s Varadkar + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/bfffa642-d079-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Taoiseach says he always believed a deal this month was unlikely + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/bfffa642-d079-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Ff87f782a-d089-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/bfffa642-d079-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="bfffa642-d079-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Brexit talks could drag into December warns Ireland’s Varadkar to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="bfffa642-d079-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="82ae2756-d073-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit Briefing" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit-briefing" + > + Brexit Briefing + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/82ae2756-d073-11e8-a9f2-7574db66bcd5" + > + Crisis or choreography over Brexit? + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/82ae2756-d073-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + While ministers haggle, company bosses press the button on expensive no-deal planning + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/82ae2756-d073-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F31c1c612-d079-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/82ae2756-d073-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="82ae2756-d073-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Crisis or choreography over Brexit? to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="82ae2756-d073-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="a8181102-ce1e-11e8-9fe5-24ad351828ab" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Industrials" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/companies/industrials" + > + Industrials + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/a8181102-ce1e-11e8-9fe5-24ad351828ab" + > + UK shipyards to submit bids to build 5 warships + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/a8181102-ce1e-11e8-9fe5-24ad351828ab" + tabIndex={-1} + > + MoD restarts competition despite industry’s concerns over budget and timing + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/a8181102-ce1e-11e8-9fe5-24ad351828ab" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Ff1191270-ce41-11e8-8d0b-a6539b949662?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/a8181102-ce1e-11e8-9fe5-24ad351828ab" + className="ArticleSaveButton_root__Utel0" + data-content-id="a8181102-ce1e-11e8-9fe5-24ad351828ab" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save UK shipyards to submit bids to build 5 warships to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="a8181102-ce1e-11e8-9fe5-24ad351828ab" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + > + May to address UK parliament on state of Brexit talks + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fc77408fa-d065-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save May to address UK parliament on state of Brexit talks to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK business & economy" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/uk-business-economy" + > + UK business & economy + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + > + Bradford hospital launches AI powered command centre + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + tabIndex={-1} + > + Collaboration with GE Healthcare to create hub monitoring patients to make better use of resources + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F66d87a12-d06f-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + className="ArticleSaveButton_root__Utel0" + data-content-id="ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Bradford hospital launches AI powered command centre to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--opinion js-teaser" + data-id="7c6b9fbb-ad93-34c5-b19b-ea79ffc5f426" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + FT Alphaville + </span> + <a + aria-label="Category: Bryce Elder" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/markets/bryce-elder" + > + Bryce Elder + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="http://ftalphaville.ft.com/marketslive/2018-10-15/" + > + Markets Live: Monday, 15th October 2018 + </a> + </div> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/7c6b9fbb-ad93-34c5-b19b-ea79ffc5f426" + className="ArticleSaveButton_root__Utel0" + data-content-id="7c6b9fbb-ad93-34c5-b19b-ea79ffc5f426" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Markets Live: Monday, 15th October 2018 to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="7c6b9fbb-ad93-34c5-b19b-ea79ffc5f426" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser js-teaser" + data-id="aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK trade" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/uk-trade" + > + UK trade + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + > + Irish deputy PM voices ‘frustration’ at Brexit impasse + </a> + </div> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Irish deputy PM voices ‘frustration’ at Brexit impasse to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + </ul> + </section> +</div> +`; + +exports[`x-teaser-timeline given latestItemsTime is set but is not same date as localTodayDate ignores latestItemsTime and renders earlier, yesterday and October 15th item groups (no latest) 1`] = ` +<div> + <section + className="TeaserTimeline_itemGroup__2YuVE" + > + <h2 + className="TeaserTimeline_itemGroup__heading__3KrJD" + > + Earlier Today + </h2> + <ul + className="TeaserTimeline_itemGroup__items__3ZuL6" + > + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="65867e26-d203-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit Briefing" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit-briefing" + > + Brexit Briefing + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/65867e26-d203-11e8-a9f2-7574db66bcd5" + > + Is December looming as the new Brexit deadline? + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/65867e26-d203-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + As Theresa May prepares to meet EU leaders, the prospect looks increasingly likely + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/65867e26-d203-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fc8a8d52e-d20a-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/65867e26-d203-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="65867e26-d203-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Is December looming as the new Brexit deadline? to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="65867e26-d203-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="93614586-d1f1-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/93614586-d1f1-11e8-a9f2-7574db66bcd5" + > + May seeks to overcome deadlock after Brexit setback + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/93614586-d1f1-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + UK and EU consider extending transition deal to defuse dispute over Irish border backstop + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/93614586-d1f1-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F8ba50178-d216-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/93614586-d1f1-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="93614586-d1f1-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Saved to myFT" + aria-pressed={true} + className="ArticleSaveButton_button__2_wUr" + data-content-id="93614586-d1f1-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Saved + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + > + Midsized UK businesses turn sour on Brexit + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Quarterly survey shows more companies now believe Brexit will damage their business than help them + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa3695666-d201-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Midsized UK businesses turn sour on Brexit to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="6582b8ce-d175-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/6582b8ce-d175-11e8-a9f2-7574db66bcd5" + > + Barnier open to extending Brexit transition by a year + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/6582b8ce-d175-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + In return, Theresa May must accept ‘two-tier’ backstop to avoid a hard Irish border + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/6582b8ce-d175-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F9be47d9e-d17a-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/6582b8ce-d175-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="6582b8ce-d175-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Barnier open to extending Brexit transition by a year to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="6582b8ce-d175-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image o-teaser--opinion js-teaser" + data-id="7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + Inside Business + </span> + <a + aria-label="Category: Sarah Gordon" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/sarah-gordon" + > + Sarah Gordon + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + > + UK lets down business with lack of Brexit advice + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Governments should be more concerned about SMEs: if supply chains falter, so will economic growth + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F23529cb0-d140-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save UK lets down business with lack of Brexit advice to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="868cedae-d18a-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Global trade" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/global-trade" + > + Global trade + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/868cedae-d18a-11e8-a9f2-7574db66bcd5" + > + Trump looks to start formal US-UK trade talks + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/868cedae-d18a-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + White House makes London a priority ‘as soon as it is ready’ after Brexit + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/868cedae-d18a-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Ff08f8340-d1a0-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/868cedae-d18a-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="868cedae-d18a-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Trump looks to start formal US-UK trade talks to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="868cedae-d18a-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + </ul> + </section> + <section + className="TeaserTimeline_itemGroup__2YuVE" + > + <h2 + className="TeaserTimeline_itemGroup__heading__3KrJD" + > + Yesterday + </h2> + <ul + className="TeaserTimeline_itemGroup__items__3ZuL6" + > + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="c276c8a2-d159-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: House of Commons UK" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/fa354fe2-b2e0-483a-9267-cffe6ef9083b" + > + House of Commons UK + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/c276c8a2-d159-11e8-a9f2-7574db66bcd5" + > + Bercow faces mounting calls to resign + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/c276c8a2-d159-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Maria Miller says Speaker needs to step down immediately to ‘drive culture change’ + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/c276c8a2-d159-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fece474ca-d151-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/c276c8a2-d159-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="c276c8a2-d159-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Bercow faces mounting calls to resign to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="c276c8a2-d159-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image o-teaser--opinion js-teaser" + data-id="a1566658-d12e-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + The FT View + </span> + <a + aria-label="Category: The editorial board" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/ft-view" + > + The editorial board + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/a1566658-d12e-11e8-a9f2-7574db66bcd5" + > + A culture shift to clear Westminster’s toxic air + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/a1566658-d12e-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Speaker John Bercow should take responsibility — and go now + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/a1566658-d12e-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F5319de7e-d12f-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/a1566658-d12e-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="a1566658-d12e-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save A culture shift to clear Westminster’s toxic air to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="a1566658-d12e-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + > + EU demands UK break Brexit impasse + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + May tells Eurosceptics acceptable deal is in sight as Barnier says ‘Brits need more time’ + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F62fd9e46-d14f-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save EU demands UK break Brexit impasse to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit Briefing" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit-briefing" + > + Brexit Briefing + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + > + Brexit, Scotland and the threat to constitutional unity + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Scottish Tories fear that possible arrangements for Northern Ireland threaten the union itself + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fcb11f55e-d140-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Brexit, Scotland and the threat to constitutional unity to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="d7472b20-d148-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/d7472b20-d148-11e8-a9f2-7574db66bcd5" + > + EU’s Tusk to ask UK for ‘concrete proposals’ to end Brexit impasse + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/d7472b20-d148-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F383a4ea2-d14a-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/d7472b20-d148-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="d7472b20-d148-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save EU’s Tusk to ask UK for ‘concrete proposals’ to end Brexit impasse to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="d7472b20-d148-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Wells Fargo" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/1120e446-6695-4e49-91e6-fd1f7698388e" + > + Wells Fargo + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + > + Wells Fargo applies for licence in France as part of Brexit strategy + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F31d2be9e-d13d-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Wells Fargo applies for licence in France as part of Brexit strategy to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK welfare reform" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/c4c9204f-8335-42c3-9415-c2c8cd971125" + > + UK welfare reform + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + > + Rollout of controversial UK welfare reform faces fresh delay + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Universal credit system not expected to be fully operational until December 2023 + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fd7517de4-d131-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Rollout of controversial UK welfare reform faces fresh delay to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser js-teaser" + data-id="1969887a-d11e-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Pound Sterling" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/466a4700-307f-47cc-83f1-c5f97a172232" + > + Pound Sterling + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/1969887a-d11e-11e8-a9f2-7574db66bcd5" + > + Pound rallies after upbeat wage growth reading + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/1969887a-d11e-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Analysts remain cautious despite optimistic data + </a> + </p> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/1969887a-d11e-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="1969887a-d11e-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Pound rallies after upbeat wage growth reading to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="1969887a-d11e-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK politics & policy" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/world/uk/politics" + > + UK politics & policy + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + > + Problem gambling shake-up set to be brought forward + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + tabIndex={-1} + > + Blow for bookmakers as ministers seek to speed up start of fixed-odds betting terminals limits + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa6afae18-d069-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + className="ArticleSaveButton_root__Utel0" + data-content-id="dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Problem gambling shake-up set to be brought forward to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Technology sector" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/companies/technology" + > + Technology sector + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + > + Crypto exchange Coinbase sets up Brexit contingency + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + US digital start-up to scale up EU operations with new Dublin branch + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F9a6adda6-d07a-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Crypto exchange Coinbase sets up Brexit contingency to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + </ul> + </section> + <section + className="TeaserTimeline_itemGroup__2YuVE" + > + <h2 + className="TeaserTimeline_itemGroup__heading__3KrJD" + > + October 15, 2018 + </h2> + <ul + className="TeaserTimeline_itemGroup__items__3ZuL6" + > + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="2e407a74-d06a-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: World" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/world" + > + World + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/2e407a74-d06a-11e8-a9f2-7574db66bcd5" + > + EU gives UK 24 hour Brexit breathing space + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/2e407a74-d06a-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Theresa May says deal achievable but Britain cannot be kept in backstop indefinitely + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/2e407a74-d06a-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fac0fd098-d075-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/2e407a74-d06a-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="2e407a74-d06a-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save EU gives UK 24 hour Brexit breathing space to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="2e407a74-d06a-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser js-teaser" + data-id="434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + > + EC’s Tusk warns no-deal Brexit ‘is more likely than ever before’ + </a> + </div> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save EC’s Tusk warns no-deal Brexit ‘is more likely than ever before’ to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="fadfb212-d091-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/fadfb212-d091-11e8-a9f2-7574db66bcd5" + > + Barnier plan no solution to Irish border, says Foster + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/fadfb212-d091-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fd0cbda02-cbb7-11e8-8d0b-a6539b949662?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/fadfb212-d091-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="fadfb212-d091-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Barnier plan no solution to Irish border, says Foster to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="fadfb212-d091-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="79939f94-c320-11e8-8d55-54197280d3f7" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + Explainer + </span> + <a + aria-label="Category: Pound Sterling" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/466a4700-307f-47cc-83f1-c5f97a172232" + > + Pound Sterling + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/79939f94-c320-11e8-8d55-54197280d3f7" + > + Pound timeline: from the 1970s to Brexit crunch + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/79939f94-c320-11e8-8d55-54197280d3f7" + tabIndex={-1} + > + Sterling has experienced several periods of volatility in the past 48 years + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/79939f94-c320-11e8-8d55-54197280d3f7" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Faea311c6-c32d-11e8-95b1-d36dfef1b89a?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/79939f94-c320-11e8-8d55-54197280d3f7" + className="ArticleSaveButton_root__Utel0" + data-content-id="79939f94-c320-11e8-8d55-54197280d3f7" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Pound timeline: from the 1970s to Brexit crunch to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="79939f94-c320-11e8-8d55-54197280d3f7" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="bfffa642-d079-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/bfffa642-d079-11e8-a9f2-7574db66bcd5" + > + Brexit talks could drag into December warns Ireland’s Varadkar + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/bfffa642-d079-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Taoiseach says he always believed a deal this month was unlikely + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/bfffa642-d079-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Ff87f782a-d089-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/bfffa642-d079-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="bfffa642-d079-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Brexit talks could drag into December warns Ireland’s Varadkar to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="bfffa642-d079-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="82ae2756-d073-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit Briefing" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit-briefing" + > + Brexit Briefing + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/82ae2756-d073-11e8-a9f2-7574db66bcd5" + > + Crisis or choreography over Brexit? + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/82ae2756-d073-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + While ministers haggle, company bosses press the button on expensive no-deal planning + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/82ae2756-d073-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F31c1c612-d079-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/82ae2756-d073-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="82ae2756-d073-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Crisis or choreography over Brexit? to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="82ae2756-d073-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="a8181102-ce1e-11e8-9fe5-24ad351828ab" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Industrials" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/companies/industrials" + > + Industrials + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/a8181102-ce1e-11e8-9fe5-24ad351828ab" + > + UK shipyards to submit bids to build 5 warships + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/a8181102-ce1e-11e8-9fe5-24ad351828ab" + tabIndex={-1} + > + MoD restarts competition despite industry’s concerns over budget and timing + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/a8181102-ce1e-11e8-9fe5-24ad351828ab" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Ff1191270-ce41-11e8-8d0b-a6539b949662?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/a8181102-ce1e-11e8-9fe5-24ad351828ab" + className="ArticleSaveButton_root__Utel0" + data-content-id="a8181102-ce1e-11e8-9fe5-24ad351828ab" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save UK shipyards to submit bids to build 5 warships to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="a8181102-ce1e-11e8-9fe5-24ad351828ab" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + > + May to address UK parliament on state of Brexit talks + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fc77408fa-d065-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save May to address UK parliament on state of Brexit talks to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK business & economy" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/uk-business-economy" + > + UK business & economy + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + > + Bradford hospital launches AI powered command centre + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + tabIndex={-1} + > + Collaboration with GE Healthcare to create hub monitoring patients to make better use of resources + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F66d87a12-d06f-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + className="ArticleSaveButton_root__Utel0" + data-content-id="ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Bradford hospital launches AI powered command centre to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--opinion js-teaser" + data-id="7c6b9fbb-ad93-34c5-b19b-ea79ffc5f426" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + FT Alphaville + </span> + <a + aria-label="Category: Bryce Elder" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/markets/bryce-elder" + > + Bryce Elder + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="http://ftalphaville.ft.com/marketslive/2018-10-15/" + > + Markets Live: Monday, 15th October 2018 + </a> + </div> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/7c6b9fbb-ad93-34c5-b19b-ea79ffc5f426" + className="ArticleSaveButton_root__Utel0" + data-content-id="7c6b9fbb-ad93-34c5-b19b-ea79ffc5f426" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Markets Live: Monday, 15th October 2018 to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="7c6b9fbb-ad93-34c5-b19b-ea79ffc5f426" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser js-teaser" + data-id="aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK trade" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/uk-trade" + > + UK trade + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + > + Irish deputy PM voices ‘frustration’ at Brexit impasse + </a> + </div> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Irish deputy PM voices ‘frustration’ at Brexit impasse to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + </ul> + </section> +</div> +`; + +exports[`x-teaser-timeline given latestItemsTime is set renders latest, earlier, yesterday and October 15th item groups 1`] = ` +<div> + <section + className="TeaserTimeline_itemGroup__2YuVE" + > + <h2 + className="TeaserTimeline_itemGroup__heading__3KrJD" + > + Latest News + </h2> + <ul + className="TeaserTimeline_itemGroup__items__3ZuL6" + > + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="65867e26-d203-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit Briefing" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit-briefing" + > + Brexit Briefing + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/65867e26-d203-11e8-a9f2-7574db66bcd5" + > + Is December looming as the new Brexit deadline? + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/65867e26-d203-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + As Theresa May prepares to meet EU leaders, the prospect looks increasingly likely + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/65867e26-d203-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fc8a8d52e-d20a-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/65867e26-d203-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="65867e26-d203-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Is December looming as the new Brexit deadline? to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="65867e26-d203-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="93614586-d1f1-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/93614586-d1f1-11e8-a9f2-7574db66bcd5" + > + May seeks to overcome deadlock after Brexit setback + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/93614586-d1f1-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + UK and EU consider extending transition deal to defuse dispute over Irish border backstop + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/93614586-d1f1-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F8ba50178-d216-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/93614586-d1f1-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="93614586-d1f1-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Saved to myFT" + aria-pressed={true} + className="ArticleSaveButton_button__2_wUr" + data-content-id="93614586-d1f1-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Saved + </button> + </form> + </div> + </li> + </ul> + </section> + <section + className="TeaserTimeline_itemGroup__2YuVE" + > + <h2 + className="TeaserTimeline_itemGroup__heading__3KrJD" + > + Earlier Today + </h2> + <ul + className="TeaserTimeline_itemGroup__items__3ZuL6" + > + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + > + Midsized UK businesses turn sour on Brexit + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Quarterly survey shows more companies now believe Brexit will damage their business than help them + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa3695666-d201-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Midsized UK businesses turn sour on Brexit to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="6582b8ce-d175-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/6582b8ce-d175-11e8-a9f2-7574db66bcd5" + > + Barnier open to extending Brexit transition by a year + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/6582b8ce-d175-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + In return, Theresa May must accept ‘two-tier’ backstop to avoid a hard Irish border + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/6582b8ce-d175-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F9be47d9e-d17a-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/6582b8ce-d175-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="6582b8ce-d175-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Barnier open to extending Brexit transition by a year to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="6582b8ce-d175-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image o-teaser--opinion js-teaser" + data-id="7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + Inside Business + </span> + <a + aria-label="Category: Sarah Gordon" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/sarah-gordon" + > + Sarah Gordon + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + > + UK lets down business with lack of Brexit advice + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Governments should be more concerned about SMEs: if supply chains falter, so will economic growth + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F23529cb0-d140-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save UK lets down business with lack of Brexit advice to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="868cedae-d18a-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Global trade" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/global-trade" + > + Global trade + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/868cedae-d18a-11e8-a9f2-7574db66bcd5" + > + Trump looks to start formal US-UK trade talks + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/868cedae-d18a-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + White House makes London a priority ‘as soon as it is ready’ after Brexit + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/868cedae-d18a-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Ff08f8340-d1a0-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/868cedae-d18a-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="868cedae-d18a-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Trump looks to start formal US-UK trade talks to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="868cedae-d18a-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + </ul> + </section> + <section + className="TeaserTimeline_itemGroup__2YuVE" + > + <h2 + className="TeaserTimeline_itemGroup__heading__3KrJD" + > + Yesterday + </h2> + <ul + className="TeaserTimeline_itemGroup__items__3ZuL6" + > + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="c276c8a2-d159-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: House of Commons UK" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/fa354fe2-b2e0-483a-9267-cffe6ef9083b" + > + House of Commons UK + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/c276c8a2-d159-11e8-a9f2-7574db66bcd5" + > + Bercow faces mounting calls to resign + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/c276c8a2-d159-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Maria Miller says Speaker needs to step down immediately to ‘drive culture change’ + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/c276c8a2-d159-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fece474ca-d151-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/c276c8a2-d159-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="c276c8a2-d159-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Bercow faces mounting calls to resign to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="c276c8a2-d159-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image o-teaser--opinion js-teaser" + data-id="a1566658-d12e-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + The FT View + </span> + <a + aria-label="Category: The editorial board" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/ft-view" + > + The editorial board + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/a1566658-d12e-11e8-a9f2-7574db66bcd5" + > + A culture shift to clear Westminster’s toxic air + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/a1566658-d12e-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Speaker John Bercow should take responsibility — and go now + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/a1566658-d12e-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F5319de7e-d12f-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/a1566658-d12e-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="a1566658-d12e-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save A culture shift to clear Westminster’s toxic air to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="a1566658-d12e-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + > + EU demands UK break Brexit impasse + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + May tells Eurosceptics acceptable deal is in sight as Barnier says ‘Brits need more time’ + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F62fd9e46-d14f-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save EU demands UK break Brexit impasse to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit Briefing" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit-briefing" + > + Brexit Briefing + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + > + Brexit, Scotland and the threat to constitutional unity + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Scottish Tories fear that possible arrangements for Northern Ireland threaten the union itself + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fcb11f55e-d140-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Brexit, Scotland and the threat to constitutional unity to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="d7472b20-d148-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/d7472b20-d148-11e8-a9f2-7574db66bcd5" + > + EU’s Tusk to ask UK for ‘concrete proposals’ to end Brexit impasse + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/d7472b20-d148-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F383a4ea2-d14a-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/d7472b20-d148-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="d7472b20-d148-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save EU’s Tusk to ask UK for ‘concrete proposals’ to end Brexit impasse to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="d7472b20-d148-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Wells Fargo" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/1120e446-6695-4e49-91e6-fd1f7698388e" + > + Wells Fargo + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + > + Wells Fargo applies for licence in France as part of Brexit strategy + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F31d2be9e-d13d-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Wells Fargo applies for licence in France as part of Brexit strategy to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK welfare reform" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/c4c9204f-8335-42c3-9415-c2c8cd971125" + > + UK welfare reform + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + > + Rollout of controversial UK welfare reform faces fresh delay + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Universal credit system not expected to be fully operational until December 2023 + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fd7517de4-d131-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Rollout of controversial UK welfare reform faces fresh delay to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser js-teaser" + data-id="1969887a-d11e-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Pound Sterling" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/466a4700-307f-47cc-83f1-c5f97a172232" + > + Pound Sterling + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/1969887a-d11e-11e8-a9f2-7574db66bcd5" + > + Pound rallies after upbeat wage growth reading + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/1969887a-d11e-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Analysts remain cautious despite optimistic data + </a> + </p> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/1969887a-d11e-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="1969887a-d11e-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Pound rallies after upbeat wage growth reading to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="1969887a-d11e-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK politics & policy" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/world/uk/politics" + > + UK politics & policy + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + > + Problem gambling shake-up set to be brought forward + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + tabIndex={-1} + > + Blow for bookmakers as ministers seek to speed up start of fixed-odds betting terminals limits + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa6afae18-d069-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + className="ArticleSaveButton_root__Utel0" + data-content-id="dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Problem gambling shake-up set to be brought forward to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Technology sector" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/companies/technology" + > + Technology sector + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + > + Crypto exchange Coinbase sets up Brexit contingency + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + US digital start-up to scale up EU operations with new Dublin branch + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F9a6adda6-d07a-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Crypto exchange Coinbase sets up Brexit contingency to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + </ul> + </section> + <section + className="TeaserTimeline_itemGroup__2YuVE" + > + <h2 + className="TeaserTimeline_itemGroup__heading__3KrJD" + > + October 15, 2018 + </h2> + <ul + className="TeaserTimeline_itemGroup__items__3ZuL6" + > + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="2e407a74-d06a-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: World" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/world" + > + World + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/2e407a74-d06a-11e8-a9f2-7574db66bcd5" + > + EU gives UK 24 hour Brexit breathing space + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/2e407a74-d06a-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Theresa May says deal achievable but Britain cannot be kept in backstop indefinitely + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/2e407a74-d06a-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fac0fd098-d075-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/2e407a74-d06a-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="2e407a74-d06a-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save EU gives UK 24 hour Brexit breathing space to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="2e407a74-d06a-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser js-teaser" + data-id="434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + > + EC’s Tusk warns no-deal Brexit ‘is more likely than ever before’ + </a> + </div> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save EC’s Tusk warns no-deal Brexit ‘is more likely than ever before’ to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="fadfb212-d091-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/fadfb212-d091-11e8-a9f2-7574db66bcd5" + > + Barnier plan no solution to Irish border, says Foster + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/fadfb212-d091-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fd0cbda02-cbb7-11e8-8d0b-a6539b949662?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/fadfb212-d091-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="fadfb212-d091-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Barnier plan no solution to Irish border, says Foster to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="fadfb212-d091-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="79939f94-c320-11e8-8d55-54197280d3f7" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + Explainer + </span> + <a + aria-label="Category: Pound Sterling" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/466a4700-307f-47cc-83f1-c5f97a172232" + > + Pound Sterling + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/79939f94-c320-11e8-8d55-54197280d3f7" + > + Pound timeline: from the 1970s to Brexit crunch + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/79939f94-c320-11e8-8d55-54197280d3f7" + tabIndex={-1} + > + Sterling has experienced several periods of volatility in the past 48 years + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/79939f94-c320-11e8-8d55-54197280d3f7" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Faea311c6-c32d-11e8-95b1-d36dfef1b89a?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/79939f94-c320-11e8-8d55-54197280d3f7" + className="ArticleSaveButton_root__Utel0" + data-content-id="79939f94-c320-11e8-8d55-54197280d3f7" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Pound timeline: from the 1970s to Brexit crunch to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="79939f94-c320-11e8-8d55-54197280d3f7" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="bfffa642-d079-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/bfffa642-d079-11e8-a9f2-7574db66bcd5" + > + Brexit talks could drag into December warns Ireland’s Varadkar + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/bfffa642-d079-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Taoiseach says he always believed a deal this month was unlikely + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/bfffa642-d079-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Ff87f782a-d089-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/bfffa642-d079-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="bfffa642-d079-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Brexit talks could drag into December warns Ireland’s Varadkar to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="bfffa642-d079-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="82ae2756-d073-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit Briefing" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit-briefing" + > + Brexit Briefing + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/82ae2756-d073-11e8-a9f2-7574db66bcd5" + > + Crisis or choreography over Brexit? + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/82ae2756-d073-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + While ministers haggle, company bosses press the button on expensive no-deal planning + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/82ae2756-d073-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F31c1c612-d079-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/82ae2756-d073-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="82ae2756-d073-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Crisis or choreography over Brexit? to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="82ae2756-d073-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="a8181102-ce1e-11e8-9fe5-24ad351828ab" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Industrials" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/companies/industrials" + > + Industrials + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/a8181102-ce1e-11e8-9fe5-24ad351828ab" + > + UK shipyards to submit bids to build 5 warships + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/a8181102-ce1e-11e8-9fe5-24ad351828ab" + tabIndex={-1} + > + MoD restarts competition despite industry’s concerns over budget and timing + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/a8181102-ce1e-11e8-9fe5-24ad351828ab" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Ff1191270-ce41-11e8-8d0b-a6539b949662?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/a8181102-ce1e-11e8-9fe5-24ad351828ab" + className="ArticleSaveButton_root__Utel0" + data-content-id="a8181102-ce1e-11e8-9fe5-24ad351828ab" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save UK shipyards to submit bids to build 5 warships to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="a8181102-ce1e-11e8-9fe5-24ad351828ab" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + > + May to address UK parliament on state of Brexit talks + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fc77408fa-d065-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save May to address UK parliament on state of Brexit talks to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK business & economy" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/uk-business-economy" + > + UK business & economy + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + > + Bradford hospital launches AI powered command centre + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + tabIndex={-1} + > + Collaboration with GE Healthcare to create hub monitoring patients to make better use of resources + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F66d87a12-d06f-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + className="ArticleSaveButton_root__Utel0" + data-content-id="ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Bradford hospital launches AI powered command centre to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--opinion js-teaser" + data-id="7c6b9fbb-ad93-34c5-b19b-ea79ffc5f426" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + FT Alphaville + </span> + <a + aria-label="Category: Bryce Elder" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/markets/bryce-elder" + > + Bryce Elder + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="http://ftalphaville.ft.com/marketslive/2018-10-15/" + > + Markets Live: Monday, 15th October 2018 + </a> + </div> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/7c6b9fbb-ad93-34c5-b19b-ea79ffc5f426" + className="ArticleSaveButton_root__Utel0" + data-content-id="7c6b9fbb-ad93-34c5-b19b-ea79ffc5f426" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Markets Live: Monday, 15th October 2018 to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="7c6b9fbb-ad93-34c5-b19b-ea79ffc5f426" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser js-teaser" + data-id="aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK trade" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/uk-trade" + > + UK trade + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + > + Irish deputy PM voices ‘frustration’ at Brexit impasse + </a> + </div> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Irish deputy PM voices ‘frustration’ at Brexit impasse to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + </ul> + </section> +</div> +`; + +exports[`x-teaser-timeline showSaveButtons given showSaveButtons is not set or is true renders save buttons by default 1`] = ` +<div> + <section + className="TeaserTimeline_itemGroup__2YuVE" + > + <h2 + className="TeaserTimeline_itemGroup__heading__3KrJD" + > + Earlier Today + </h2> + <ul + className="TeaserTimeline_itemGroup__items__3ZuL6" + > + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="65867e26-d203-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit Briefing" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit-briefing" + > + Brexit Briefing + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/65867e26-d203-11e8-a9f2-7574db66bcd5" + > + Is December looming as the new Brexit deadline? + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/65867e26-d203-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + As Theresa May prepares to meet EU leaders, the prospect looks increasingly likely + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/65867e26-d203-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fc8a8d52e-d20a-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/65867e26-d203-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="65867e26-d203-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Is December looming as the new Brexit deadline? to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="65867e26-d203-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="93614586-d1f1-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/93614586-d1f1-11e8-a9f2-7574db66bcd5" + > + May seeks to overcome deadlock after Brexit setback + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/93614586-d1f1-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + UK and EU consider extending transition deal to defuse dispute over Irish border backstop + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/93614586-d1f1-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F8ba50178-d216-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/93614586-d1f1-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="93614586-d1f1-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Saved to myFT" + aria-pressed={true} + className="ArticleSaveButton_button__2_wUr" + data-content-id="93614586-d1f1-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Saved + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + > + Midsized UK businesses turn sour on Brexit + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Quarterly survey shows more companies now believe Brexit will damage their business than help them + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa3695666-d201-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Midsized UK businesses turn sour on Brexit to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="6582b8ce-d175-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/6582b8ce-d175-11e8-a9f2-7574db66bcd5" + > + Barnier open to extending Brexit transition by a year + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/6582b8ce-d175-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + In return, Theresa May must accept ‘two-tier’ backstop to avoid a hard Irish border + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/6582b8ce-d175-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F9be47d9e-d17a-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/6582b8ce-d175-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="6582b8ce-d175-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Barnier open to extending Brexit transition by a year to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="6582b8ce-d175-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image o-teaser--opinion js-teaser" + data-id="7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + Inside Business + </span> + <a + aria-label="Category: Sarah Gordon" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/sarah-gordon" + > + Sarah Gordon + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + > + UK lets down business with lack of Brexit advice + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Governments should be more concerned about SMEs: if supply chains falter, so will economic growth + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F23529cb0-d140-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save UK lets down business with lack of Brexit advice to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="868cedae-d18a-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Global trade" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/global-trade" + > + Global trade + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/868cedae-d18a-11e8-a9f2-7574db66bcd5" + > + Trump looks to start formal US-UK trade talks + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/868cedae-d18a-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + White House makes London a priority ‘as soon as it is ready’ after Brexit + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/868cedae-d18a-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Ff08f8340-d1a0-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/868cedae-d18a-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="868cedae-d18a-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Trump looks to start formal US-UK trade talks to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="868cedae-d18a-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + </ul> + </section> + <section + className="TeaserTimeline_itemGroup__2YuVE" + > + <h2 + className="TeaserTimeline_itemGroup__heading__3KrJD" + > + Yesterday + </h2> + <ul + className="TeaserTimeline_itemGroup__items__3ZuL6" + > + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="c276c8a2-d159-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: House of Commons UK" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/fa354fe2-b2e0-483a-9267-cffe6ef9083b" + > + House of Commons UK + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/c276c8a2-d159-11e8-a9f2-7574db66bcd5" + > + Bercow faces mounting calls to resign + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/c276c8a2-d159-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Maria Miller says Speaker needs to step down immediately to ‘drive culture change’ + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/c276c8a2-d159-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fece474ca-d151-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/c276c8a2-d159-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="c276c8a2-d159-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Bercow faces mounting calls to resign to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="c276c8a2-d159-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image o-teaser--opinion js-teaser" + data-id="a1566658-d12e-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + The FT View + </span> + <a + aria-label="Category: The editorial board" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/ft-view" + > + The editorial board + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/a1566658-d12e-11e8-a9f2-7574db66bcd5" + > + A culture shift to clear Westminster’s toxic air + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/a1566658-d12e-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Speaker John Bercow should take responsibility — and go now + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/a1566658-d12e-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F5319de7e-d12f-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/a1566658-d12e-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="a1566658-d12e-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save A culture shift to clear Westminster’s toxic air to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="a1566658-d12e-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + > + EU demands UK break Brexit impasse + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + May tells Eurosceptics acceptable deal is in sight as Barnier says ‘Brits need more time’ + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F62fd9e46-d14f-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save EU demands UK break Brexit impasse to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit Briefing" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit-briefing" + > + Brexit Briefing + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + > + Brexit, Scotland and the threat to constitutional unity + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Scottish Tories fear that possible arrangements for Northern Ireland threaten the union itself + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fcb11f55e-d140-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Brexit, Scotland and the threat to constitutional unity to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="d7472b20-d148-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/d7472b20-d148-11e8-a9f2-7574db66bcd5" + > + EU’s Tusk to ask UK for ‘concrete proposals’ to end Brexit impasse + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/d7472b20-d148-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F383a4ea2-d14a-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/d7472b20-d148-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="d7472b20-d148-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save EU’s Tusk to ask UK for ‘concrete proposals’ to end Brexit impasse to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="d7472b20-d148-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Wells Fargo" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/1120e446-6695-4e49-91e6-fd1f7698388e" + > + Wells Fargo + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + > + Wells Fargo applies for licence in France as part of Brexit strategy + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F31d2be9e-d13d-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Wells Fargo applies for licence in France as part of Brexit strategy to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK welfare reform" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/c4c9204f-8335-42c3-9415-c2c8cd971125" + > + UK welfare reform + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + > + Rollout of controversial UK welfare reform faces fresh delay + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Universal credit system not expected to be fully operational until December 2023 + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fd7517de4-d131-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Rollout of controversial UK welfare reform faces fresh delay to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser js-teaser" + data-id="1969887a-d11e-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Pound Sterling" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/466a4700-307f-47cc-83f1-c5f97a172232" + > + Pound Sterling + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/1969887a-d11e-11e8-a9f2-7574db66bcd5" + > + Pound rallies after upbeat wage growth reading + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/1969887a-d11e-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Analysts remain cautious despite optimistic data + </a> + </p> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/1969887a-d11e-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="1969887a-d11e-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Pound rallies after upbeat wage growth reading to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="1969887a-d11e-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK politics & policy" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/world/uk/politics" + > + UK politics & policy + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + > + Problem gambling shake-up set to be brought forward + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + tabIndex={-1} + > + Blow for bookmakers as ministers seek to speed up start of fixed-odds betting terminals limits + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa6afae18-d069-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + className="ArticleSaveButton_root__Utel0" + data-content-id="dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Problem gambling shake-up set to be brought forward to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Technology sector" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/companies/technology" + > + Technology sector + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + > + Crypto exchange Coinbase sets up Brexit contingency + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + US digital start-up to scale up EU operations with new Dublin branch + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F9a6adda6-d07a-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Crypto exchange Coinbase sets up Brexit contingency to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + </ul> + </section> + <section + className="TeaserTimeline_itemGroup__2YuVE" + > + <h2 + className="TeaserTimeline_itemGroup__heading__3KrJD" + > + October 15, 2018 + </h2> + <ul + className="TeaserTimeline_itemGroup__items__3ZuL6" + > + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="2e407a74-d06a-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: World" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/world" + > + World + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/2e407a74-d06a-11e8-a9f2-7574db66bcd5" + > + EU gives UK 24 hour Brexit breathing space + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/2e407a74-d06a-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Theresa May says deal achievable but Britain cannot be kept in backstop indefinitely + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/2e407a74-d06a-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fac0fd098-d075-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/2e407a74-d06a-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="2e407a74-d06a-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save EU gives UK 24 hour Brexit breathing space to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="2e407a74-d06a-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser js-teaser" + data-id="434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + > + EC’s Tusk warns no-deal Brexit ‘is more likely than ever before’ + </a> + </div> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save EC’s Tusk warns no-deal Brexit ‘is more likely than ever before’ to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="fadfb212-d091-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/fadfb212-d091-11e8-a9f2-7574db66bcd5" + > + Barnier plan no solution to Irish border, says Foster + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/fadfb212-d091-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fd0cbda02-cbb7-11e8-8d0b-a6539b949662?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/fadfb212-d091-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="fadfb212-d091-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Barnier plan no solution to Irish border, says Foster to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="fadfb212-d091-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="79939f94-c320-11e8-8d55-54197280d3f7" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + Explainer + </span> + <a + aria-label="Category: Pound Sterling" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/466a4700-307f-47cc-83f1-c5f97a172232" + > + Pound Sterling + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/79939f94-c320-11e8-8d55-54197280d3f7" + > + Pound timeline: from the 1970s to Brexit crunch + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/79939f94-c320-11e8-8d55-54197280d3f7" + tabIndex={-1} + > + Sterling has experienced several periods of volatility in the past 48 years + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/79939f94-c320-11e8-8d55-54197280d3f7" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Faea311c6-c32d-11e8-95b1-d36dfef1b89a?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/79939f94-c320-11e8-8d55-54197280d3f7" + className="ArticleSaveButton_root__Utel0" + data-content-id="79939f94-c320-11e8-8d55-54197280d3f7" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Pound timeline: from the 1970s to Brexit crunch to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="79939f94-c320-11e8-8d55-54197280d3f7" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="bfffa642-d079-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/bfffa642-d079-11e8-a9f2-7574db66bcd5" + > + Brexit talks could drag into December warns Ireland’s Varadkar + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/bfffa642-d079-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Taoiseach says he always believed a deal this month was unlikely + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/bfffa642-d079-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Ff87f782a-d089-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/bfffa642-d079-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="bfffa642-d079-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Brexit talks could drag into December warns Ireland’s Varadkar to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="bfffa642-d079-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="82ae2756-d073-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit Briefing" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit-briefing" + > + Brexit Briefing + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/82ae2756-d073-11e8-a9f2-7574db66bcd5" + > + Crisis or choreography over Brexit? + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/82ae2756-d073-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + While ministers haggle, company bosses press the button on expensive no-deal planning + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/82ae2756-d073-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F31c1c612-d079-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/82ae2756-d073-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="82ae2756-d073-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Crisis or choreography over Brexit? to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="82ae2756-d073-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="a8181102-ce1e-11e8-9fe5-24ad351828ab" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Industrials" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/companies/industrials" + > + Industrials + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/a8181102-ce1e-11e8-9fe5-24ad351828ab" + > + UK shipyards to submit bids to build 5 warships + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/a8181102-ce1e-11e8-9fe5-24ad351828ab" + tabIndex={-1} + > + MoD restarts competition despite industry’s concerns over budget and timing + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/a8181102-ce1e-11e8-9fe5-24ad351828ab" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Ff1191270-ce41-11e8-8d0b-a6539b949662?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/a8181102-ce1e-11e8-9fe5-24ad351828ab" + className="ArticleSaveButton_root__Utel0" + data-content-id="a8181102-ce1e-11e8-9fe5-24ad351828ab" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save UK shipyards to submit bids to build 5 warships to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="a8181102-ce1e-11e8-9fe5-24ad351828ab" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + > + May to address UK parliament on state of Brexit talks + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fc77408fa-d065-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save May to address UK parliament on state of Brexit talks to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK business & economy" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/uk-business-economy" + > + UK business & economy + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + > + Bradford hospital launches AI powered command centre + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + tabIndex={-1} + > + Collaboration with GE Healthcare to create hub monitoring patients to make better use of resources + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F66d87a12-d06f-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + className="ArticleSaveButton_root__Utel0" + data-content-id="ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Bradford hospital launches AI powered command centre to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--opinion js-teaser" + data-id="7c6b9fbb-ad93-34c5-b19b-ea79ffc5f426" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + FT Alphaville + </span> + <a + aria-label="Category: Bryce Elder" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/markets/bryce-elder" + > + Bryce Elder + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="http://ftalphaville.ft.com/marketslive/2018-10-15/" + > + Markets Live: Monday, 15th October 2018 + </a> + </div> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/7c6b9fbb-ad93-34c5-b19b-ea79ffc5f426" + className="ArticleSaveButton_root__Utel0" + data-content-id="7c6b9fbb-ad93-34c5-b19b-ea79ffc5f426" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Markets Live: Monday, 15th October 2018 to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="7c6b9fbb-ad93-34c5-b19b-ea79ffc5f426" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser js-teaser" + data-id="aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK trade" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/uk-trade" + > + UK trade + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + > + Irish deputy PM voices ‘frustration’ at Brexit impasse + </a> + </div> + </div> + </div> + <div + className="TeaserTimeline_itemActions__1ao7c" + > + <form + action="/myft/save/aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + className="ArticleSaveButton_root__Utel0" + data-content-id="aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + method="GET" + onSubmit={[Function]} + > + <button + aria-label="Save Irish deputy PM voices ‘frustration’ at Brexit impasse to myFT for later" + aria-pressed={false} + className="ArticleSaveButton_button__2_wUr" + data-content-id="aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + data-trackable="save-for-later" + type="submit" + > + <span + className="ArticleSaveButton_icon__-7Con" + /> + Save + </button> + </form> + </div> + </li> + </ul> + </section> +</div> +`; + +exports[`x-teaser-timeline showSaveButtons given showSaveButtons is set to false does not render the save buttons 1`] = ` +<div> + <section + className="TeaserTimeline_itemGroup__2YuVE" + > + <h2 + className="TeaserTimeline_itemGroup__heading__3KrJD" + > + Earlier Today + </h2> + <ul + className="TeaserTimeline_itemGroup__items__3ZuL6" + > + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="65867e26-d203-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit Briefing" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit-briefing" + > + Brexit Briefing + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/65867e26-d203-11e8-a9f2-7574db66bcd5" + > + Is December looming as the new Brexit deadline? + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/65867e26-d203-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + As Theresa May prepares to meet EU leaders, the prospect looks increasingly likely + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/65867e26-d203-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fc8a8d52e-d20a-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="93614586-d1f1-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/93614586-d1f1-11e8-a9f2-7574db66bcd5" + > + May seeks to overcome deadlock after Brexit setback + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/93614586-d1f1-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + UK and EU consider extending transition deal to defuse dispute over Irish border backstop + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/93614586-d1f1-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F8ba50178-d216-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + > + Midsized UK businesses turn sour on Brexit + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Quarterly survey shows more companies now believe Brexit will damage their business than help them + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/d4e80114-d1ee-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa3695666-d201-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="6582b8ce-d175-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/6582b8ce-d175-11e8-a9f2-7574db66bcd5" + > + Barnier open to extending Brexit transition by a year + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/6582b8ce-d175-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + In return, Theresa May must accept ‘two-tier’ backstop to avoid a hard Irish border + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/6582b8ce-d175-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F9be47d9e-d17a-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image o-teaser--opinion js-teaser" + data-id="7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + Inside Business + </span> + <a + aria-label="Category: Sarah Gordon" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/sarah-gordon" + > + Sarah Gordon + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + > + UK lets down business with lack of Brexit advice + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Governments should be more concerned about SMEs: if supply chains falter, so will economic growth + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/7ab52d68-d11a-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F23529cb0-d140-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="868cedae-d18a-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Global trade" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/global-trade" + > + Global trade + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/868cedae-d18a-11e8-a9f2-7574db66bcd5" + > + Trump looks to start formal US-UK trade talks + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/868cedae-d18a-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + White House makes London a priority ‘as soon as it is ready’ after Brexit + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/868cedae-d18a-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Ff08f8340-d1a0-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + </li> + </ul> + </section> + <section + className="TeaserTimeline_itemGroup__2YuVE" + > + <h2 + className="TeaserTimeline_itemGroup__heading__3KrJD" + > + Yesterday + </h2> + <ul + className="TeaserTimeline_itemGroup__items__3ZuL6" + > + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="c276c8a2-d159-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: House of Commons UK" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/fa354fe2-b2e0-483a-9267-cffe6ef9083b" + > + House of Commons UK + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/c276c8a2-d159-11e8-a9f2-7574db66bcd5" + > + Bercow faces mounting calls to resign + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/c276c8a2-d159-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Maria Miller says Speaker needs to step down immediately to ‘drive culture change’ + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/c276c8a2-d159-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fece474ca-d151-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image o-teaser--opinion js-teaser" + data-id="a1566658-d12e-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + The FT View + </span> + <a + aria-label="Category: The editorial board" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/ft-view" + > + The editorial board + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/a1566658-d12e-11e8-a9f2-7574db66bcd5" + > + A culture shift to clear Westminster’s toxic air + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/a1566658-d12e-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Speaker John Bercow should take responsibility — and go now + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/a1566658-d12e-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F5319de7e-d12f-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + > + EU demands UK break Brexit impasse + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + May tells Eurosceptics acceptable deal is in sight as Barnier says ‘Brits need more time’ + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/f9f69a2c-d141-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F62fd9e46-d14f-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit Briefing" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit-briefing" + > + Brexit Briefing + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + > + Brexit, Scotland and the threat to constitutional unity + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Scottish Tories fear that possible arrangements for Northern Ireland threaten the union itself + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/eb6062c2-d13c-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fcb11f55e-d140-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="d7472b20-d148-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/d7472b20-d148-11e8-a9f2-7574db66bcd5" + > + EU’s Tusk to ask UK for ‘concrete proposals’ to end Brexit impasse + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/d7472b20-d148-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F383a4ea2-d14a-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Wells Fargo" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/1120e446-6695-4e49-91e6-fd1f7698388e" + > + Wells Fargo + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + > + Wells Fargo applies for licence in France as part of Brexit strategy + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/10ee3a20-d13b-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F31d2be9e-d13d-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK welfare reform" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/c4c9204f-8335-42c3-9415-c2c8cd971125" + > + UK welfare reform + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + > + Rollout of controversial UK welfare reform faces fresh delay + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Universal credit system not expected to be fully operational until December 2023 + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fd7517de4-d131-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser js-teaser" + data-id="1969887a-d11e-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Pound Sterling" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/466a4700-307f-47cc-83f1-c5f97a172232" + > + Pound Sterling + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/1969887a-d11e-11e8-a9f2-7574db66bcd5" + > + Pound rallies after upbeat wage growth reading + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/1969887a-d11e-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Analysts remain cautious despite optimistic data + </a> + </p> + </div> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK politics & policy" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/world/uk/politics" + > + UK politics & policy + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + > + Problem gambling shake-up set to be brought forward + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + tabIndex={-1} + > + Blow for bookmakers as ministers seek to speed up start of fixed-odds betting terminals limits + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa6afae18-d069-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Technology sector" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/companies/technology" + > + Technology sector + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + > + Crypto exchange Coinbase sets up Brexit contingency + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + US digital start-up to scale up EU operations with new Dublin branch + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F9a6adda6-d07a-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + </li> + </ul> + </section> + <section + className="TeaserTimeline_itemGroup__2YuVE" + > + <h2 + className="TeaserTimeline_itemGroup__heading__3KrJD" + > + October 15, 2018 + </h2> + <ul + className="TeaserTimeline_itemGroup__items__3ZuL6" + > + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="2e407a74-d06a-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: World" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/world" + > + World + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/2e407a74-d06a-11e8-a9f2-7574db66bcd5" + > + EU gives UK 24 hour Brexit breathing space + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/2e407a74-d06a-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Theresa May says deal achievable but Britain cannot be kept in backstop indefinitely + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/2e407a74-d06a-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fac0fd098-d075-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser js-teaser" + data-id="434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/434dc8e2-d09f-11e8-a9f2-7574db66bcd5" + > + EC’s Tusk warns no-deal Brexit ‘is more likely than ever before’ + </a> + </div> + </div> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="fadfb212-d091-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/fadfb212-d091-11e8-a9f2-7574db66bcd5" + > + Barnier plan no solution to Irish border, says Foster + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/fadfb212-d091-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fd0cbda02-cbb7-11e8-8d0b-a6539b949662?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="79939f94-c320-11e8-8d55-54197280d3f7" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + Explainer + </span> + <a + aria-label="Category: Pound Sterling" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/stream/466a4700-307f-47cc-83f1-c5f97a172232" + > + Pound Sterling + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/79939f94-c320-11e8-8d55-54197280d3f7" + > + Pound timeline: from the 1970s to Brexit crunch + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/79939f94-c320-11e8-8d55-54197280d3f7" + tabIndex={-1} + > + Sterling has experienced several periods of volatility in the past 48 years + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/79939f94-c320-11e8-8d55-54197280d3f7" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Faea311c6-c32d-11e8-95b1-d36dfef1b89a?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="bfffa642-d079-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/bfffa642-d079-11e8-a9f2-7574db66bcd5" + > + Brexit talks could drag into December warns Ireland’s Varadkar + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/bfffa642-d079-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + Taoiseach says he always believed a deal this month was unlikely + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/bfffa642-d079-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Ff87f782a-d089-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="82ae2756-d073-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit Briefing" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit-briefing" + > + Brexit Briefing + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/82ae2756-d073-11e8-a9f2-7574db66bcd5" + > + Crisis or choreography over Brexit? + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/82ae2756-d073-11e8-a9f2-7574db66bcd5" + tabIndex={-1} + > + While ministers haggle, company bosses press the button on expensive no-deal planning + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/82ae2756-d073-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F31c1c612-d079-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="a8181102-ce1e-11e8-9fe5-24ad351828ab" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Industrials" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/companies/industrials" + > + Industrials + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/a8181102-ce1e-11e8-9fe5-24ad351828ab" + > + UK shipyards to submit bids to build 5 warships + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/a8181102-ce1e-11e8-9fe5-24ad351828ab" + tabIndex={-1} + > + MoD restarts competition despite industry’s concerns over budget and timing + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/a8181102-ce1e-11e8-9fe5-24ad351828ab" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Ff1191270-ce41-11e8-8d0b-a6539b949662?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Brexit" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/brexit" + > + Brexit + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + > + May to address UK parliament on state of Brexit talks + </a> + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/9b3b6d2e-d064-11e8-a9f2-7574db66bcd5" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fc77408fa-d065-11e8-a9f2-7574db66bcd5?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--has-image js-teaser" + data-id="ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK business & economy" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/uk-business-economy" + > + UK business & economy + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + > + Bradford hospital launches AI powered command centre + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="/content/ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + tabIndex={-1} + > + Collaboration with GE Healthcare to create hub monitoring patients to make better use of resources + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="/content/ed665ed8-cdfd-11e8-9fe5-24ad351828ab" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F66d87a12-d06f-11e8-9a3c-5d5eac8f1ab4?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser o-teaser--opinion js-teaser" + data-id="7c6b9fbb-ad93-34c5-b19b-ea79ffc5f426" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + FT Alphaville + </span> + <a + aria-label="Category: Bryce Elder" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/markets/bryce-elder" + > + Bryce Elder + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="http://ftalphaville.ft.com/marketslive/2018-10-15/" + > + Markets Live: Monday, 15th October 2018 + </a> + </div> + </div> + </div> + </li> + <li + className="TeaserTimeline_item__1s6ow" + > + <div + className="o-teaser o-teaser--article o-teaser--small o-teaser--timeline-teaser js-teaser" + data-id="aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + > + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: UK trade" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="/uk-trade" + > + UK trade + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="/content/aa9b9bda-d056-11e8-a9f2-7574db66bcd5" + > + Irish deputy PM voices ‘frustration’ at Brexit impasse + </a> + </div> + </div> + </div> + </li> + </ul> + </section> +</div> +`; diff --git a/components/x-teaser-timeline/__tests__/lib/transform.test.js b/components/x-teaser-timeline/__tests__/lib/transform.test.js new file mode 100644 index 000000000..813b13be0 --- /dev/null +++ b/components/x-teaser-timeline/__tests__/lib/transform.test.js @@ -0,0 +1,376 @@ +import { buildModel } from '../../src/lib/transform' + +const items = [ + { + id: '01f0b004-36b9-11ea-a6d3-9a26f8c3cba4', + title: 'Europeans step up pressure on Iran over nuclear deal', + publishedDate: '2020-01-14T11:10:26.000Z' + }, + { + id: '01eaf2fc-36ac-11ea-a6d3-9a26f8c3cba4', + title: 'Iran’s judiciary threatens to expel UK ambassador', + publishedDate: '2020-01-14T10:23:14.000Z' + }, + { + id: 'dcac61ea-361c-11ea-a6d3-9a26f8c3cba4', + title: 'Iran’s regime loses the battle for public opinion', + publishedDate: '2020-01-14T10:00:27.000Z' + }, + { + id: 'bf0752ee-3685-11ea-a6d3-9a26f8c3cba4', + title: 'Justin Trudeau partly blames US for Iran plane crash', + publishedDate: '2020-01-14T08:15:05.000Z' + }, + { + id: '1d1527fa-356c-11ea-a6d3-9a26f8c3cba4', + title: 'Biden and Sanders reprise Iraq war fight in 2020 race', + publishedDate: '2020-01-13T11:00:26.000Z' + }, + { + id: '6524b530-355b-11ea-a6d3-9a26f8c3cba4', + title: 'Esper ‘didn’t see’ specific evidence of embassy threat', + publishedDate: '2020-01-12T18:36:39.000Z' + }, + { + id: '86df67f6-3524-11ea-a6d3-9a26f8c3cba4', + title: 'Lies over air crash shake Iran’s trust in its rulers', + publishedDate: '2020-01-12T16:29:11.000Z' + }, + { + id: '37084c8c-3508-11ea-a6d3-9a26f8c3cba4', + title: 'Iran questions Revolutionary Guard over downing of airliner', + publishedDate: '2020-01-12T09:51:13.000Z' + }, + { + id: 'b931cc22-3073-11ea-a329-0bcf87a328f2', + title: 'What next for oil as US-Iran tensions simmer?', + publishedDate: '2020-01-12T09:00:26.000Z' + }, + { + id: '0e76e39a-3428-11ea-9703-eea0cae3f0de', + title: 'Iran admits it shot down Ukrainian jet', + publishedDate: '2020-01-12T05:35:29.000Z' + } +] + +const groupedItems = [ + { + date: '2020-01-14', + title: 'Earlier Today', + items: [ + { + articleIndex: 0, + localisedLastUpdated: '2020-01-14T11:10:26.000+00:00', + id: '01f0b004-36b9-11ea-a6d3-9a26f8c3cba4', + title: 'Europeans step up pressure on Iran over nuclear deal', + publishedDate: '2020-01-14T11:10:26.000Z' + }, + { + articleIndex: 1, + localisedLastUpdated: '2020-01-14T10:23:14.000+00:00', + id: '01eaf2fc-36ac-11ea-a6d3-9a26f8c3cba4', + title: 'Iran’s judiciary threatens to expel UK ambassador', + publishedDate: '2020-01-14T10:23:14.000Z' + }, + { + articleIndex: 2, + localisedLastUpdated: '2020-01-14T10:00:27.000+00:00', + id: 'dcac61ea-361c-11ea-a6d3-9a26f8c3cba4', + title: 'Iran’s regime loses the battle for public opinion', + publishedDate: '2020-01-14T10:00:27.000Z' + }, + { + articleIndex: 3, + localisedLastUpdated: '2020-01-14T08:15:05.000+00:00', + id: 'bf0752ee-3685-11ea-a6d3-9a26f8c3cba4', + title: 'Justin Trudeau partly blames US for Iran plane crash', + publishedDate: '2020-01-14T08:15:05.000Z' + } + ] + }, + { + date: '2020-01-13', + title: 'Yesterday', + items: [ + { + articleIndex: 4, + localisedLastUpdated: '2020-01-13T11:00:26.000+00:00', + id: '1d1527fa-356c-11ea-a6d3-9a26f8c3cba4', + title: 'Biden and Sanders reprise Iraq war fight in 2020 race', + publishedDate: '2020-01-13T11:00:26.000Z' + } + ] + }, + { + date: '2020-01-12', + title: 'January 12, 2020', + items: [ + { + articleIndex: 5, + localisedLastUpdated: '2020-01-12T18:36:39.000+00:00', + id: '6524b530-355b-11ea-a6d3-9a26f8c3cba4', + title: 'Esper ‘didn’t see’ specific evidence of embassy threat', + publishedDate: '2020-01-12T18:36:39.000Z' + }, + { + articleIndex: 6, + localisedLastUpdated: '2020-01-12T16:29:11.000+00:00', + id: '86df67f6-3524-11ea-a6d3-9a26f8c3cba4', + title: 'Lies over air crash shake Iran’s trust in its rulers', + publishedDate: '2020-01-12T16:29:11.000Z' + }, + { + articleIndex: 7, + localisedLastUpdated: '2020-01-12T09:51:13.000+00:00', + id: '37084c8c-3508-11ea-a6d3-9a26f8c3cba4', + title: 'Iran questions Revolutionary Guard over downing of airliner', + publishedDate: '2020-01-12T09:51:13.000Z' + }, + { + articleIndex: 8, + localisedLastUpdated: '2020-01-12T09:00:26.000+00:00', + id: 'b931cc22-3073-11ea-a329-0bcf87a328f2', + title: 'What next for oil as US-Iran tensions simmer?', + publishedDate: '2020-01-12T09:00:26.000Z' + }, + { + articleIndex: 9, + localisedLastUpdated: '2020-01-12T05:35:29.000+00:00', + id: '0e76e39a-3428-11ea-9703-eea0cae3f0de', + title: 'Iran admits it shot down Ukrainian jet', + publishedDate: '2020-01-12T05:35:29.000Z' + } + ] + } +] + +describe('buildModel', () => { + describe('without custom slot content', () => { + test('correctly builds model', () => { + const result = buildModel({ items, timezoneOffset: 0, localTodayDate: '2020-01-14' }) + expect(result).toEqual(groupedItems) + }) + }) + + describe('with latestItemsTime today', () => { + test("correctly builds model with today's group split", () => { + const result = buildModel({ + items, + timezoneOffset: 0, + localTodayDate: '2020-01-14', + latestItemsTime: '2020-01-14T10:00:00+00:00' + }) + expect(result).toEqual([ + { + date: 'today-latest', + title: 'Latest News', + items: [groupedItems[0].items[0], groupedItems[0].items[1], groupedItems[0].items[2]] + }, + { + ...groupedItems[0], + date: 'today-earlier', + items: [groupedItems[0].items[3]] + }, + groupedItems[1], + groupedItems[2] + ]) + }) + }) + + describe('with latestItemsTime less than latestItemsAgeHours ago', () => { + test('correctly builds model with latest items split out', () => { + const result = buildModel({ + items, + timezoneOffset: 0, + localTodayDate: '2020-01-14', + latestItemsTime: '2020-01-13T10:00:00+00:00', + latestItemsAgeHours: 36 + }) + expect(result.length).toBe(2) // latest news, 2020-01-12 + expect(result[0].date).toBe('today-latest') + expect(result[1].date).toBe('2020-01-12') + + expect(result[0].title).toBe('Latest News') + expect(result[1].title).toBe('January 12, 2020') + + expect(result[0].items).toEqual([ + groupedItems[0].items[0], + groupedItems[0].items[1], + groupedItems[0].items[2], + groupedItems[0].items[3], + groupedItems[1].items[0] + ]) + expect(result[1]).toEqual(groupedItems[2]) + }) + }) + + describe('with latestItemsTime over latestItemsAgeHours ago', () => { + test('builds model without latest items', () => { + const result = buildModel({ + items, + timezoneOffset: 0, + localTodayDate: '2020-01-14', + latestItemsTime: '2020-01-12T11:59:59+00:00', + latestItemsAgeHours: 36 + }) + expect(result).toEqual(groupedItems) + }) + }) + + describe('with custom slot content', () => { + test('returns correct model for custom slot in middle of first group', () => { + const result = buildModel({ + items, + timezoneOffset: 0, + localTodayDate: '2020-01-14', + customSlotContent: { foo: 1 }, + customSlotPosition: 2 + }) + expect(result).toEqual([ + { + ...groupedItems[0], + items: [ + groupedItems[0].items[0], + groupedItems[0].items[1], + { foo: 1 }, + groupedItems[0].items[2], + groupedItems[0].items[3] + ] + }, + groupedItems[1], + groupedItems[2] + ]) + }) + test('returns correct model for custom slot at end of second group', () => { + const result = buildModel({ + items, + timezoneOffset: 0, + localTodayDate: '2020-01-14', + customSlotContent: { foo: 1 }, + customSlotPosition: 5 + }) + expect(result).toEqual([ + groupedItems[0], + { + ...groupedItems[1], + items: [groupedItems[1].items[0], { foo: 1 }] + }, + groupedItems[2] + ]) + }) + test('returns correct model for custom slot off end of all groups', () => { + const result = buildModel({ + items, + timezoneOffset: 0, + localTodayDate: '2020-01-14', + customSlotContent: { foo: 1 }, + customSlotPosition: 10 + }) + expect(result).toEqual([ + groupedItems[0], + groupedItems[1], + { + ...groupedItems[2], + items: [...groupedItems[2].items, { foo: 1 }] + } + ]) + }) + test('returns correct model for custom slot in position 0', () => { + const result = buildModel({ + items, + timezoneOffset: 0, + localTodayDate: '2020-01-14', + customSlotContent: { foo: 1 }, + customSlotPosition: 0 + }) + expect(result).toEqual([ + { + ...groupedItems[0], + items: [ + { foo: 1 }, + groupedItems[0].items[0], + groupedItems[0].items[1], + groupedItems[0].items[2], + groupedItems[0].items[3] + ] + }, + groupedItems[1], + groupedItems[2] + ]) + }) + test('returns correct model for multiple custom slots', () => { + const result = buildModel({ + items, + timezoneOffset: 0, + localTodayDate: '2020-01-14', + customSlotContent: [{ foo: 1 }, { bar: 2 }], + customSlotPosition: [0, 3] + }) + expect(result).toEqual([ + { + ...groupedItems[0], + items: [ + { foo: 1 }, + groupedItems[0].items[0], + groupedItems[0].items[1], + groupedItems[0].items[2], + { bar: 2 }, + groupedItems[0].items[3] + ] + }, + groupedItems[1], + groupedItems[2] + ]) + }) + test('returns correct model for a non-zero custom slot', () => { + const result = buildModel({ + items, + timezoneOffset: 0, + localTodayDate: '2020-01-14', + customSlotContent: [{ foo: 1 }, { bar: 2 }], + customSlotPosition: [2, 3] + }) + expect(result).toEqual([ + { + ...groupedItems[0], + items: [ + groupedItems[0].items[0], + groupedItems[0].items[1], + { foo: 1 }, + groupedItems[0].items[2], + { bar: 2 }, + groupedItems[0].items[3] + ] + }, + groupedItems[1], + groupedItems[2] + ]) + }) + test('returns correct model for multiple custom slots off end of all groups', () => { + const result = buildModel({ + items, + timezoneOffset: 0, + localTodayDate: '2020-01-14', + customSlotContent: [{ foo: 1 }, { bar: 2 }], + customSlotPosition: [0, 10] + }) + expect(result).toEqual([ + { + ...groupedItems[0], + items: [ + { foo: 1 }, + groupedItems[0].items[0], + groupedItems[0].items[1], + groupedItems[0].items[2], + groupedItems[0].items[3] + ] + }, + groupedItems[1], + { + ...groupedItems[2], + items: [...groupedItems[2].items, { bar: 2 }] + } + ]) + }) + }) +}) diff --git a/components/x-teaser-timeline/bower.json b/components/x-teaser-timeline/bower.json new file mode 100644 index 000000000..02cefaf5c --- /dev/null +++ b/components/x-teaser-timeline/bower.json @@ -0,0 +1,10 @@ +{ + "name": "x-teaser-timeline", + "private": true, + "main": "dist/TeaserTimeline.es5.js", + "dependencies": { + "o-typography": "^6.4.5", + "o-colors": "^5.3.0", + "o-grid": "^5.2.9" + } +} diff --git a/components/x-teaser-timeline/package.json b/components/x-teaser-timeline/package.json new file mode 100644 index 000000000..4df7346fc --- /dev/null +++ b/components/x-teaser-timeline/package.json @@ -0,0 +1,43 @@ +{ + "name": "@financial-times/x-teaser-timeline", + "version": "0.0.0", + "description": "Display a list of teasers grouped by day and/or last visit time", + "main": "dist/TeaserTimeline.cjs.js", + "module": "dist/TeaserTimeline.esm.js", + "browser": "dist/TeaserTimeline.es5.js", + "style": "dist/TeaserTimeline.css", + "scripts": { + "prepare": "bower install && npm run build", + "build": "node rollup.js", + "start": "node rollup.js --watch" + }, + "keywords": [ + "x-dash" + ], + "author": "", + "license": "ISC", + "dependencies": { + "@financial-times/x-article-save-button": "0.0.11", + "@financial-times/x-engine": "file:../../packages/x-engine", + "@financial-times/x-teaser": "file:../x-teaser", + "classnames": "^2.2.6", + "date-fns": "^1.29.0" + }, + "devDependencies": { + "@financial-times/x-rollup": "file:../../packages/x-rollup", + "@financial-times/x-test-utils": "file:../../packages/x-test-utils", + "node-sass": "^4.9.2", + "bower": "^1.7.9" + }, + "repository": { + "type": "git", + "url": "https://github.com/Financial-Times/x-dash.git" + }, + "homepage": "https://github.com/Financial-Times/x-dash/tree/HEAD/components/x-teaser-timeline", + "engines": { + "node": "12.x" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/components/x-teaser-timeline/readme.md b/components/x-teaser-timeline/readme.md new file mode 100644 index 000000000..ed763b980 --- /dev/null +++ b/components/x-teaser-timeline/readme.md @@ -0,0 +1,73 @@ +# x-teaser-timeline + +This component renders a list of articles in reverse chronological order, grouped by day, according to the user's timezone. +It will optionally group today's articles into "latest" and "earlier" too. + +## Installation + +This module is supported on Node 12 and is distributed on npm. + +```bash +npm install --save @financial-times/x-teaser-timeline +``` + +The [`x-engine`][engine] module is used to inject your chosen runtime into the component. Please read the `x-engine` documentation first if you are consuming `x-` components for the first time in your application. + +[engine]: https://github.com/Financial-Times/x-dash/tree/HEAD/packages/x-engine + +## Other dependencies + +[o-teaser](https://registry.origami.ft.com/components/o-teaser) styles will need to be imported by the consumer of this component. + +If selectively importing o-teaser's styles via scss, then you will need the following: + +```scss +$o-teaser-is-silent: true; +@import 'o-teaser/main'; +@include oTeaser(('default', 'images', 'timestamp'), ('small')); +``` + +See the [x-teaser](https://github.com/Financial-Times/x-dash/tree/HEAD/components/x-teaser) documentation. + +## Usage + +The components provided by this module are all functions that expect a map of [properties](#properties). They can be used with vanilla JavaScript or JSX (If you are not familiar check out [WTF is JSX][jsx-wtf] first). For example if you were writing your application using React you could use the component like this: + +```jsx +import React from 'react'; +import { TeaserTimeline } from '@financial-times/x-teaser-timeline'; + +// A == B == C +const a = TeaserTimeline(props); +const b = <TeaserTimeline {...props} />; +const c = React.createElement(TeaserTimeline, props); +``` + +All `x-` components are designed to be compatible with a variety of runtimes, not just React. Check out the [`x-engine`][engine] documentation for a list of recommended libraries and frameworks. + +[jsx-wtf]: https://jasonformat.com/wtf-is-jsx/ + +### Properties + +Feature | Type | Notes +---------------------|-----------------|---------------------------- +`items` | Array | (Mandatory) Array of objects, in Teaser format, representating content items to render. The items should be in newest-first order. +`timezoneOffset` | Number | (Defaults using runtime clock) Minutes to offset item publish times in order to display in user's timezone. Negative means ahead of UTC. +`localTodayDate` | String | (Defaults using runtime clock) ISO format YYYY-MM-DD representating today's date in the user's timezone. +`latestItemsTime` | String | ISO time (HH:mm:ss). If provided, will be used in combination with `localTodayDate` to render today's items into separate "Latest" and "Earlier" groups. +`showSaveButtons` | Boolean | (Default to true). Option to hide x-article-save-buttons if they are not needed. Those buttons will get their saved/unsaved state from a `saved` property of the content item. +`customSlotContent` | String or Array | Content to insert at `customSlotPosition`. +`customSlotPosition` | Number or Array | (Default is 2). Where to insert `customSlotContent`. The custom content will be inserted after the item at this position number. If this position is greater than the number items to render, then it will be inserted last. +`csrfToken` | String | A CSRF token that will be used by the save buttons (if shown). +`latestItemsAgeHours`| Number | (Optional). If provided, used to calculate a cutoff time before which no article will count as "latest", regardless of the value of `latestItemsTime`. If omitted, articles before midnight this morning will not count as "latest". + +Example: + +```jsx +<TeaserTimeline + items={items} + timezoneOffset="-60" + localTodayDate="2018-10-30" + latestItemsTime="11:52:30" +/> +``` diff --git a/components/x-teaser-timeline/rollup.js b/components/x-teaser-timeline/rollup.js new file mode 100644 index 000000000..0483c1c2e --- /dev/null +++ b/components/x-teaser-timeline/rollup.js @@ -0,0 +1,4 @@ +const xRollup = require('@financial-times/x-rollup') +const pkg = require('./package.json') + +xRollup({ input: './src/TeaserTimeline.jsx', pkg }) diff --git a/components/x-teaser-timeline/src/TeaserTimeline.jsx b/components/x-teaser-timeline/src/TeaserTimeline.jsx new file mode 100644 index 000000000..676e3bc68 --- /dev/null +++ b/components/x-teaser-timeline/src/TeaserTimeline.jsx @@ -0,0 +1,72 @@ +import { h } from '@financial-times/x-engine' +import { ArticleSaveButton } from '@financial-times/x-article-save-button' +import { Teaser, presets } from '@financial-times/x-teaser' +import { buildModel } from './lib/transform' +import { getDateOnly } from './lib/date' +import styles from './TeaserTimeline.scss' +import classNames from 'classnames' + +const TeaserTimeline = (props) => { + const now = new Date() + const { + csrfToken = null, + showSaveButtons = true, + customSlotContent, + customSlotPosition = 2, + items, + timezoneOffset = now.getTimezoneOffset(), + localTodayDate = getDateOnly(now.toISOString()), + latestItemsTime, + latestItemsAgeHours + } = props + + const itemGroups = buildModel({ + items, + customSlotContent, + customSlotPosition, + timezoneOffset, + localTodayDate, + latestItemsTime, + latestItemsAgeHours + }) + + return ( + itemGroups.length > 0 && ( + <div> + {itemGroups.map((group) => ( + <section key={group.date} className={classNames(styles.itemGroup)}> + <h2 className={classNames(styles.itemGroup__heading)}>{group.title}</h2> + <ul className={classNames(styles.itemGroup__items)}> + {group.items.map((item) => { + if (item.id) { + return ( + <li key={item.id} className={styles.item}> + <Teaser {...item} {...presets.SmallHeavy} modifiers="timeline-teaser" /> + {showSaveButtons && ( + <div className={classNames(styles.itemActions)}> + <ArticleSaveButton + id={`${item.id}-save-button`} + contentId={item.id} + contentTitle={item.title} + csrfToken={csrfToken} + saved={item.saved || false} + /> + </div> + )} + </li> + ) + } else if (typeof item === 'string') { + return <li key="custom-slot" dangerouslySetInnerHTML={{ __html: item }} /> + } + + return <li key="custom-slot">{item}</li> + })} + </ul> + </section> + ))} + </div> + ) + ) +} + +export { TeaserTimeline } diff --git a/components/x-teaser-timeline/src/TeaserTimeline.scss b/components/x-teaser-timeline/src/TeaserTimeline.scss new file mode 100644 index 000000000..c98f7d02b --- /dev/null +++ b/components/x-teaser-timeline/src/TeaserTimeline.scss @@ -0,0 +1,63 @@ +@import 'o-colors/main'; +@import 'o-grid/main'; +@import 'o-typography/main'; + +/* There is a risk that this chunk will get duplicated if you include the save button component directly*/ +:global { + @import "~@financial-times/x-article-save-button/dist/ArticleSaveButton"; +} + +.itemGroup { + border-top: 4px solid #000; + + @include oGridRespondTo($from: M) { + display: grid; + grid-gap: 0 20px; + grid-template-columns: 1fr 3fr; + grid-template-areas: "heading articles"; + + } +} + +.itemGroup__heading { + @include oTypographySans($scale: 1, $weight: 'bold'); + margin-top: oSpacingByName('s2'); + margin-bottom: oSpacingByName('s2'); + + @include oGridRespondTo($from: M) { + grid-area: heading; + -ms-grid-row: 1; + -ms-grid-column: 1; + } +} + +.itemGroup__items { + list-style-type: none; + padding: 0; + margin-top: oSpacingByName('s2'); + + @include oGridRespondTo($from: M) { + grid-area: articles; + -ms-grid-row: 1; + -ms-grid-column: 3; + } +} + +.item { + display: flex; + justify-content: space-between; + border-bottom: 1px solid oColorsByName('black-20'); + margin-bottom: oSpacingByName('s2'); + + :global { + .o-teaser--timeline-teaser { + border-bottom: 0; + padding-bottom: 0; + } + } +} + +.itemActions { + flex: 0 1 auto; + padding-left: 10px; +} diff --git a/components/x-teaser-timeline/src/lib/date.js b/components/x-teaser-timeline/src/lib/date.js new file mode 100644 index 000000000..70bf95598 --- /dev/null +++ b/components/x-teaser-timeline/src/lib/date.js @@ -0,0 +1,51 @@ +import { differenceInCalendarDays, format, isAfter, subMinutes } from 'date-fns' + +/** + * Takes a UTC ISO date/time and turns it into a ISO date for a particular timezone + * @param {string} isoDate A UTC ISO date, e.g. '2018-07-19T12:00:00.000Z' + * @param {number} timezoneOffset Minutes ahead (negative) or behind UTC + * @return {string} A localised ISO date, e.g. '2018-07-19T00:30:00.000+01:00' for UTC+1 + */ +export const getLocalisedISODate = (isoDate, timezoneOffset) => { + const dateWithoutTimezone = subMinutes(isoDate, timezoneOffset).toISOString().substring(0, 23) + const future = timezoneOffset <= 0 + const offsetMinutes = Math.abs(timezoneOffset) + const hours = Math.floor(offsetMinutes / 60) + const minutes = offsetMinutes % 60 + const pad = (n) => String(n).padStart(2, '0') + + return `${dateWithoutTimezone}${future ? '+' : '-'}${pad(hours)}:${pad(minutes)}` +} + +export const getTitleForItemGroup = (localDate, localTodayDate) => { + if (localDate === 'today-latest') { + return 'Latest News' + } + + if (localDate === 'today-earlier' || localDate === localTodayDate) { + return 'Earlier Today' + } + + if (differenceInCalendarDays(localTodayDate, localDate) === 1) { + return 'Yesterday' + } + + return format(localDate, 'MMMM D, YYYY') +} + +export const splitLatestEarlier = (items, splitDate) => { + const latestItems = [] + const earlierItems = [] + + items.forEach((item) => { + if (isAfter(item.localisedLastUpdated, splitDate)) { + latestItems.push(item) + } else { + earlierItems.push(item) + } + }) + + return { latestItems, earlierItems } +} + +export const getDateOnly = (date) => date.substr(0, 10) diff --git a/components/x-teaser-timeline/src/lib/transform.js b/components/x-teaser-timeline/src/lib/transform.js new file mode 100644 index 000000000..cb6cd3c3c --- /dev/null +++ b/components/x-teaser-timeline/src/lib/transform.js @@ -0,0 +1,200 @@ +import { getLocalisedISODate, getTitleForItemGroup, getDateOnly } from './date' + +const groupItemsByLocalisedDate = ( + indexOffset, + replaceLocalDate, + localTodayDateTime, + items, + timezoneOffset +) => { + const itemsByLocalisedDate = {} + + items.forEach((item, index) => { + const localDateTime = getLocalisedISODate(item.publishedDate, timezoneOffset) + const localDate = getDateOnly(localDateTime) + + if (!itemsByLocalisedDate.hasOwnProperty(localDate)) { + itemsByLocalisedDate[localDate] = [] + } + + item.localisedLastUpdated = localDateTime + itemsByLocalisedDate[localDate].push({ articleIndex: indexOffset + index, ...item }) + }) + + const localTodayDate = localTodayDateTime && getDateOnly(localTodayDateTime) + return Object.entries(itemsByLocalisedDate).map(([localDate, items]) => ({ + date: replaceLocalDate && localDate === localTodayDate ? 'today-earlier' : localDate, + items + })) +} + +const splitLatestItems = (items, localTodayDate, latestItemsTime) => { + const latestNews = [] + const remainingItems = [] + + items.forEach((item, index) => { + // These are ISO date strings so string comparison works when comparing them if they are in the same timezone + if (latestItemsTime && item.publishedDate > latestItemsTime) { + latestNews.push({ articleIndex: index, ...item }) + } else { + remainingItems.push(item) + } + }) + + return [latestNews, remainingItems] +} + +const addItemGroupTitles = (itemGroups, localTodayDate) => { + return itemGroups.map((group) => { + group.title = getTitleForItemGroup(group.date, localTodayDate) + + return group + }) +} + +const isTimeWithinAgeRange = (time, localTodayDate, ageRangeHours) => + time && new Date(localTodayDate) - new Date(time) < ageRangeHours * 60 * 60 * 1000 + +const isTimeWithinToday = (time, localTodayDate) => time && getDateOnly(localTodayDate) === getDateOnly(time) + +/** + * Determines whether a "Latest News" section can be shown in the timeline. + * + * A "Latest News" section is allowed if `latestItemsTime` is specified and lies within the + * permitted time range. This time age range defaults to "today", but can be overridden using + * `latestItemsAgeRange`. + * + * @param {string} localTodayDate Today's date in client timezone. ISO Date string format. + * @param {string} latestItemsTime Cutoff time for items to be treated as "Latest News". ISO Date string format. + * @param {number} latestItemsAgeRange Maximum age allowed for items in "Latest News". Hours. + * @returns {boolean} true if a "Latest News" section can be shown. + */ +const isLatestNewsSectionAllowed = (localTodayDate, latestItemsTime, latestItemsAgeRange) => + latestItemsAgeRange + ? isTimeWithinAgeRange(latestItemsTime, localTodayDate, latestItemsAgeRange) + : isTimeWithinToday(latestItemsTime, localTodayDate) + +/** + * Groups items (articles) by date + * + * Takes an array of article items and groups them into sections by date. + * Gives the groups presentable titles, e.g. "Earlier Today", and "Yesterday". + * Will include a "Latest News" group if allowed by `latestItemsTime` and `latestItemsAgeRange`. + * + * @param {Item[]} items An array of news articles. + * @param {number} timezoneOffset Minutes ahead (negative) or behind UTC + * @param {string} localTodayDate Today's date in client timezone. ISO Date string format. + * @param {string} latestItemsTime Cutoff time for items to be treated as "Latest News". ISO Date string format. + * @param {number} latestItemsAgeRange Maximum age allowed for items in "Latest News". Hours. + * @returns An array of group objects, each containing the group's title, date and items. + */ +const getItemGroups = ({ + items, + timezoneOffset, + localTodayDate, + latestItemsTime, + latestItemsAgeHours: latestItemsAgeRange +}) => { + if (!items || !Array.isArray(items) || items.length === 0) { + return [] + } + + const sortedItems = [...items].sort((a, b) => (a.publishedDate > b.publishedDate ? -1 : 1)) + + const includeLatesNewsSection = isLatestNewsSectionAllowed( + localTodayDate, + latestItemsTime, + latestItemsAgeRange + ) + + const [latestItems, remainingItems] = includeLatesNewsSection + ? splitLatestItems(sortedItems, localTodayDate, latestItemsTime) + : [[], sortedItems] + + let itemGroups = groupItemsByLocalisedDate( + latestItems.length, + includeLatesNewsSection, + localTodayDate, + remainingItems, + timezoneOffset + ) + + if (latestItems.length > 0) { + itemGroups = [ + { + date: 'today-latest', + items: latestItems + }, + ...itemGroups + ] + } + + return addItemGroupTitles(itemGroups, localTodayDate) +} + +const getGroupAndIndex = (groups, position) => { + if (position > 0) { + const group = groups.findIndex((g) => g.items.some((item) => item.articleIndex === position - 1)) + const index = groups[group].items.findIndex((item) => item.articleIndex === position - 1) + + return { + group: group, + index: index + 1 + } + } + + return { + group: 0, + index: 0 + } +} + +const interleaveAllSlotsWithCustomSlots = ( + customSlotContentArray, + customSlotPositionArray, + itemGroups, + items +) => { + for (const [index, slotContent] of customSlotContentArray.entries()) { + const insertPosition = Math.min(customSlotPositionArray[index], items.length + index) + const insert = getGroupAndIndex(itemGroups, insertPosition) + const copyOfItems = [...itemGroups[insert.group].items] + + copyOfItems.splice(insert.index, 0, slotContent) + itemGroups[insert.group].items = copyOfItems + } + return itemGroups +} + +export const buildModel = ({ + items, + customSlotContent, + customSlotPosition, + timezoneOffset, + localTodayDate, + latestItemsTime, + latestItemsAgeHours +}) => { + let itemGroups = getItemGroups({ + items, + timezoneOffset, + localTodayDate, + latestItemsTime, + latestItemsAgeHours + }) + + if (itemGroups.length > 0 && customSlotContent) { + const customSlotContentArray = Array.isArray(customSlotContent) ? customSlotContent : [customSlotContent] + const customSlotPositionArray = Array.isArray(customSlotPosition) + ? customSlotPosition + : [customSlotPosition] + + itemGroups = interleaveAllSlotsWithCustomSlots( + customSlotContentArray, + customSlotPositionArray, + itemGroups, + items + ) + } + return itemGroups +} diff --git a/components/x-teaser-timeline/storybook/content-items.json b/components/x-teaser-timeline/storybook/content-items.json new file mode 100644 index 000000000..d198bb1a8 --- /dev/null +++ b/components/x-teaser-timeline/storybook/content-items.json @@ -0,0 +1,1041 @@ +[ + { + "id": "65867e26-d203-11e8-a9f2-7574db66bcd5", + "url": "https://www.ft.com/content/65867e26-d203-11e8-a9f2-7574db66bcd5", + "relativeUrl": "/content/65867e26-d203-11e8-a9f2-7574db66bcd5", + "type": "article", + "indicators": { + "isColumn": false, + "isOpinion": false, + "isScoop": false, + "isExclusive": false, + "isEditorsChoice": false, + "accessLevel": "subscribed" + }, + "metaPrefixText": null, + "metaSuffixText": null, + "metaLink": { + "id": "464cc2f2-395e-4c36-bb29-01727fc95558", + "predicate": "http://www.ft.com/ontology/classification/isClassifiedBy", + "prefLabel": "Brexit Briefing", + "type": "BRAND", + "url": "https://www.ft.com/brexit-briefing", + "relativeUrl": "/brexit-briefing" + }, + "metaAltLink": { + "id": "19b95057-4614-45fb-9306-4d54049354db", + "predicate": "http://www.ft.com/ontology/hasDisplayTag", + "prefLabel": "Brexit", + "type": "TOPIC", + "url": "https://www.ft.com/brexit", + "relativeUrl": "/brexit", + "isDisplayTag": true + }, + "title": "Is December looming as the new Brexit deadline?", + "standfirst": "As Theresa May prepares to meet EU leaders, the prospect looks increasingly likely", + "altStandfirst": null, + "publishedDate": "2018-10-17T13:00:26.000Z", + "firstPublishedDate": "2018-10-17T13:00:26.000Z", + "image": { + "url": "http://prod-upp-image-read.ft.com/c8a8d52e-d20a-11e8-9a3c-5d5eac8f1ab4", + "width": 2048, + "height": 1152 + }, + "headshot": null, + "parentTheme": null + }, + { + "id": "93614586-d1f1-11e8-a9f2-7574db66bcd5", + "url": "https://www.ft.com/content/93614586-d1f1-11e8-a9f2-7574db66bcd5", + "relativeUrl": "/content/93614586-d1f1-11e8-a9f2-7574db66bcd5", + "type": "article", + "indicators": { + "isColumn": false, + "isOpinion": false, + "isScoop": false, + "isExclusive": false, + "isEditorsChoice": false, + "accessLevel": "subscribed" + }, + "metaPrefixText": null, + "metaSuffixText": null, + "metaLink": { + "id": "19b95057-4614-45fb-9306-4d54049354db", + "predicate": "http://www.ft.com/ontology/hasDisplayTag", + "prefLabel": "Brexit", + "type": "TOPIC", + "url": "https://www.ft.com/brexit", + "relativeUrl": "/brexit", + "isDisplayTag": true + }, + "metaAltLink": null, + "title": "May seeks to overcome deadlock after Brexit setback", + "standfirst": "UK and EU consider extending transition deal to defuse dispute over Irish border backstop", + "altStandfirst": null, + "publishedDate": "2018-10-17T12:35:36.000Z", + "firstPublishedDate": "2018-10-17T12:35:36.000Z", + "image": { + "url": "http://prod-upp-image-read.ft.com/8ba50178-d216-11e8-9a3c-5d5eac8f1ab4", + "width": 2048, + "height": 1152 + }, + "headshot": null, + "parentTheme": null, + "saved": true + }, + { + "id": "d4e80114-d1ee-11e8-a9f2-7574db66bcd5", + "url": "https://www.ft.com/content/d4e80114-d1ee-11e8-a9f2-7574db66bcd5", + "relativeUrl": "/content/d4e80114-d1ee-11e8-a9f2-7574db66bcd5", + "type": "article", + "indicators": { + "isColumn": false, + "isOpinion": false, + "isScoop": false, + "isExclusive": false, + "isEditorsChoice": false, + "accessLevel": "subscribed" + }, + "metaPrefixText": null, + "metaSuffixText": null, + "metaLink": { + "id": "19b95057-4614-45fb-9306-4d54049354db", + "predicate": "http://www.ft.com/ontology/hasDisplayTag", + "prefLabel": "Brexit", + "type": "TOPIC", + "url": "https://www.ft.com/brexit", + "relativeUrl": "/brexit", + "isDisplayTag": true + }, + "metaAltLink": null, + "title": "Midsized UK businesses turn sour on Brexit", + "standfirst": "Quarterly survey shows more companies now believe Brexit will damage their business than help them", + "altStandfirst": null, + "publishedDate": "2018-10-17T12:00:33.000Z", + "firstPublishedDate": "2018-10-17T12:00:33.000Z", + "image": { + "url": "http://prod-upp-image-read.ft.com/a3695666-d201-11e8-9a3c-5d5eac8f1ab4", + "width": 2048, + "height": 1152 + }, + "headshot": null, + "parentTheme": null + }, + { + "id": "6582b8ce-d175-11e8-a9f2-7574db66bcd5", + "url": "https://www.ft.com/content/6582b8ce-d175-11e8-a9f2-7574db66bcd5", + "relativeUrl": "/content/6582b8ce-d175-11e8-a9f2-7574db66bcd5", + "type": "article", + "indicators": { + "isColumn": false, + "isOpinion": false, + "isScoop": false, + "isExclusive": false, + "isEditorsChoice": false, + "accessLevel": "subscribed" + }, + "metaPrefixText": null, + "metaSuffixText": null, + "metaLink": { + "id": "19b95057-4614-45fb-9306-4d54049354db", + "predicate": "http://www.ft.com/ontology/hasDisplayTag", + "prefLabel": "Brexit", + "type": "TOPIC", + "url": "https://www.ft.com/brexit", + "relativeUrl": "/brexit", + "isDisplayTag": true + }, + "metaAltLink": null, + "title": "Barnier open to extending Brexit transition by a year", + "standfirst": "In return, Theresa May must accept ‘two-tier’ backstop to avoid a hard Irish border", + "altStandfirst": null, + "publishedDate": "2018-10-17T09:06:19.000Z", + "firstPublishedDate": "2018-10-16T19:45:43.000Z", + "image": { + "url": "http://prod-upp-image-read.ft.com/9be47d9e-d17a-11e8-9a3c-5d5eac8f1ab4", + "width": 2048, + "height": 1152 + }, + "headshot": null, + "parentTheme": null + }, + { + "id": "7ab52d68-d11a-11e8-a9f2-7574db66bcd5", + "url": "https://www.ft.com/content/7ab52d68-d11a-11e8-a9f2-7574db66bcd5", + "relativeUrl": "/content/7ab52d68-d11a-11e8-a9f2-7574db66bcd5", + "type": "article", + "indicators": { + "isColumn": true, + "isOpinion": true, + "isScoop": false, + "isExclusive": false, + "isEditorsChoice": false, + "accessLevel": "subscribed" + }, + "metaPrefixText": "Inside Business", + "metaSuffixText": null, + "metaLink": { + "id": "3f97b34f-8fa3-43fd-8326-d8b0fde2a1f3", + "predicate": "http://www.ft.com/ontology/annotation/hasAuthor", + "prefLabel": "Sarah Gordon", + "type": "PERSON", + "attributes": [ + { + "key": "headshot", + "value": "fthead-v1:sarah-gordon" + } + ], + "url": "https://www.ft.com/sarah-gordon", + "relativeUrl": "/sarah-gordon" + }, + "metaAltLink": { + "id": "19b95057-4614-45fb-9306-4d54049354db", + "predicate": "http://www.ft.com/ontology/hasDisplayTag", + "prefLabel": "Brexit", + "type": "TOPIC", + "url": "https://www.ft.com/brexit", + "relativeUrl": "/brexit", + "isDisplayTag": true + }, + "title": "UK lets down business with lack of Brexit advice", + "standfirst": "Governments should be more concerned about SMEs: if supply chains falter, so will economic growth", + "altStandfirst": null, + "publishedDate": "2018-10-17T04:01:53.000Z", + "firstPublishedDate": "2018-10-17T04:01:53.000Z", + "image": { + "url": "http://prod-upp-image-read.ft.com/23529cb0-d140-11e8-9a3c-5d5eac8f1ab4", + "width": 2048, + "height": 1152 + }, + "headshot": "fthead-v1:sarah-gordon", + "parentTheme": null + }, + { + "id": "868cedae-d18a-11e8-a9f2-7574db66bcd5", + "url": "https://www.ft.com/content/868cedae-d18a-11e8-a9f2-7574db66bcd5", + "relativeUrl": "/content/868cedae-d18a-11e8-a9f2-7574db66bcd5", + "type": "article", + "indicators": { + "isColumn": false, + "isOpinion": false, + "isScoop": false, + "isExclusive": false, + "isEditorsChoice": false, + "accessLevel": "subscribed" + }, + "metaPrefixText": null, + "metaSuffixText": null, + "metaLink": { + "id": "243243d9-de4b-4869-909b-fab711125624", + "predicate": "http://www.ft.com/ontology/hasDisplayTag", + "prefLabel": "Global trade", + "type": "TOPIC", + "url": "https://www.ft.com/global-trade", + "relativeUrl": "/global-trade", + "isDisplayTag": true + }, + "metaAltLink": null, + "title": "Trump looks to start formal US-UK trade talks", + "standfirst": "White House makes London a priority ‘as soon as it is ready’ after Brexit", + "altStandfirst": null, + "publishedDate": "2018-10-16T23:31:16.000Z", + "firstPublishedDate": "2018-10-16T23:31:16.000Z", + "image": { + "url": "http://prod-upp-image-read.ft.com/f08f8340-d1a0-11e8-a9f2-7574db66bcd5", + "width": 2048, + "height": 1152 + }, + "headshot": null, + "parentTheme": null + }, + { + "id": "c276c8a2-d159-11e8-a9f2-7574db66bcd5", + "url": "https://www.ft.com/content/c276c8a2-d159-11e8-a9f2-7574db66bcd5", + "relativeUrl": "/content/c276c8a2-d159-11e8-a9f2-7574db66bcd5", + "type": "article", + "indicators": { + "isColumn": false, + "isOpinion": false, + "isScoop": false, + "isExclusive": false, + "isEditorsChoice": false, + "accessLevel": "subscribed" + }, + "metaPrefixText": null, + "metaSuffixText": null, + "metaLink": { + "id": "fa354fe2-b2e0-483a-9267-cffe6ef9083b", + "predicate": "http://www.ft.com/ontology/hasDisplayTag", + "prefLabel": "House of Commons UK", + "type": "ORGANISATION", + "url": "https://www.ft.com/stream/fa354fe2-b2e0-483a-9267-cffe6ef9083b", + "relativeUrl": "/stream/fa354fe2-b2e0-483a-9267-cffe6ef9083b", + "isDisplayTag": true + }, + "metaAltLink": null, + "title": "Bercow faces mounting calls to resign", + "standfirst": "Maria Miller says Speaker needs to step down immediately to ‘drive culture change’", + "altStandfirst": null, + "publishedDate": "2018-10-16T18:13:59.000Z", + "firstPublishedDate": "2018-10-16T17:12:31.000Z", + "image": { + "url": "http://prod-upp-image-read.ft.com/ece474ca-d151-11e8-9a3c-5d5eac8f1ab4", + "width": 2048, + "height": 1152 + }, + "headshot": null, + "parentTheme": null + }, + { + "id": "a1566658-d12e-11e8-a9f2-7574db66bcd5", + "url": "https://www.ft.com/content/a1566658-d12e-11e8-a9f2-7574db66bcd5", + "relativeUrl": "/content/a1566658-d12e-11e8-a9f2-7574db66bcd5", + "type": "article", + "indicators": { + "isColumn": true, + "isOpinion": true, + "isScoop": false, + "isExclusive": false, + "isEditorsChoice": false, + "accessLevel": "subscribed" + }, + "metaPrefixText": "The FT View", + "metaSuffixText": null, + "metaLink": { + "id": "741cc381-76a8-382b-a621-fc8eca53d69f", + "predicate": "http://www.ft.com/ontology/annotation/hasAuthor", + "prefLabel": "The editorial board", + "type": "PERSON", + "url": "https://www.ft.com/ft-view", + "relativeUrl": "/ft-view" + }, + "metaAltLink": { + "id": "fa354fe2-b2e0-483a-9267-cffe6ef9083b", + "predicate": "http://www.ft.com/ontology/hasDisplayTag", + "prefLabel": "House of Commons UK", + "type": "ORGANISATION", + "url": "https://www.ft.com/stream/fa354fe2-b2e0-483a-9267-cffe6ef9083b", + "relativeUrl": "/stream/fa354fe2-b2e0-483a-9267-cffe6ef9083b", + "isDisplayTag": true + }, + "title": "A culture shift to clear Westminster’s toxic air", + "standfirst": "Speaker John Bercow should take responsibility — and go now", + "altStandfirst": null, + "publishedDate": "2018-10-16T17:36:20.000Z", + "firstPublishedDate": "2018-10-16T17:36:20.000Z", + "image": { + "url": "http://prod-upp-image-read.ft.com/5319de7e-d12f-11e8-9a3c-5d5eac8f1ab4", + "width": 2048, + "height": 1152 + }, + "parentTheme": null + }, + { + "id": "f9f69a2c-d141-11e8-a9f2-7574db66bcd5", + "url": "https://www.ft.com/content/f9f69a2c-d141-11e8-a9f2-7574db66bcd5", + "relativeUrl": "/content/f9f69a2c-d141-11e8-a9f2-7574db66bcd5", + "type": "article", + "indicators": { + "isColumn": false, + "isOpinion": false, + "isScoop": false, + "isExclusive": false, + "isEditorsChoice": false, + "accessLevel": "subscribed" + }, + "metaPrefixText": null, + "metaSuffixText": null, + "metaLink": { + "id": "19b95057-4614-45fb-9306-4d54049354db", + "predicate": "http://www.ft.com/ontology/hasDisplayTag", + "prefLabel": "Brexit", + "type": "TOPIC", + "url": "https://www.ft.com/brexit", + "relativeUrl": "/brexit", + "isDisplayTag": true + }, + "metaAltLink": null, + "title": "EU demands UK break Brexit impasse", + "standfirst": "May tells Eurosceptics acceptable deal is in sight as Barnier says ‘Brits need more time’", + "altStandfirst": null, + "publishedDate": "2018-10-16T17:23:30.000Z", + "firstPublishedDate": "2018-10-16T13:07:03.000Z", + "image": { + "url": "http://prod-upp-image-read.ft.com/62fd9e46-d14f-11e8-9a3c-5d5eac8f1ab4", + "width": 2048, + "height": 1152 + }, + "headshot": null, + "parentTheme": null + }, + { + "id": "eb6062c2-d13c-11e8-a9f2-7574db66bcd5", + "url": "https://www.ft.com/content/eb6062c2-d13c-11e8-a9f2-7574db66bcd5", + "relativeUrl": "/content/eb6062c2-d13c-11e8-a9f2-7574db66bcd5", + "type": "article", + "indicators": { + "isColumn": false, + "isOpinion": false, + "isScoop": false, + "isExclusive": false, + "isEditorsChoice": false, + "accessLevel": "subscribed" + }, + "metaPrefixText": null, + "metaSuffixText": null, + "metaLink": { + "id": "464cc2f2-395e-4c36-bb29-01727fc95558", + "predicate": "http://www.ft.com/ontology/classification/isClassifiedBy", + "prefLabel": "Brexit Briefing", + "type": "BRAND", + "url": "https://www.ft.com/brexit-briefing", + "relativeUrl": "/brexit-briefing" + }, + "metaAltLink": { + "id": "19b95057-4614-45fb-9306-4d54049354db", + "predicate": "http://www.ft.com/ontology/hasDisplayTag", + "prefLabel": "Brexit", + "type": "TOPIC", + "url": "https://www.ft.com/brexit", + "relativeUrl": "/brexit", + "isDisplayTag": true + }, + "title": "Brexit, Scotland and the threat to constitutional unity", + "standfirst": "Scottish Tories fear that possible arrangements for Northern Ireland threaten the union itself", + "altStandfirst": null, + "publishedDate": "2018-10-16T13:54:09.000Z", + "firstPublishedDate": "2018-10-16T13:54:09.000Z", + "image": { + "url": "http://prod-upp-image-read.ft.com/cb11f55e-d140-11e8-9a3c-5d5eac8f1ab4", + "width": 2048, + "height": 1152 + }, + "headshot": null, + "parentTheme": null + }, + { + "id": "d7472b20-d148-11e8-a9f2-7574db66bcd5", + "url": "https://www.ft.com/content/d7472b20-d148-11e8-a9f2-7574db66bcd5", + "relativeUrl": "/content/d7472b20-d148-11e8-a9f2-7574db66bcd5", + "type": "article", + "indicators": { + "isColumn": false, + "isOpinion": false, + "isScoop": false, + "isExclusive": false, + "isEditorsChoice": false, + "accessLevel": "subscribed" + }, + "metaPrefixText": null, + "metaSuffixText": null, + "metaLink": { + "id": "19b95057-4614-45fb-9306-4d54049354db", + "predicate": "http://www.ft.com/ontology/hasDisplayTag", + "prefLabel": "Brexit", + "type": "TOPIC", + "url": "https://www.ft.com/brexit", + "relativeUrl": "/brexit", + "isDisplayTag": true + }, + "metaAltLink": null, + "title": "EU’s Tusk to ask UK for ‘concrete proposals’ to end Brexit impasse", + "altStandfirst": null, + "publishedDate": "2018-10-16T13:49:36.000Z", + "firstPublishedDate": "2018-10-16T13:49:36.000Z", + "image": { + "url": "http://prod-upp-image-read.ft.com/383a4ea2-d14a-11e8-a9f2-7574db66bcd5", + "width": 2048, + "height": 1152 + }, + "headshot": null, + "parentTheme": null + }, + { + "id": "10ee3a20-d13b-11e8-a9f2-7574db66bcd5", + "url": "https://www.ft.com/content/10ee3a20-d13b-11e8-a9f2-7574db66bcd5", + "relativeUrl": "/content/10ee3a20-d13b-11e8-a9f2-7574db66bcd5", + "type": "article", + "indicators": { + "isColumn": false, + "isOpinion": false, + "isScoop": false, + "isExclusive": false, + "isEditorsChoice": false, + "accessLevel": "subscribed" + }, + "metaPrefixText": null, + "metaSuffixText": null, + "metaLink": { + "FIGI": "BBG000BWQFY7", + "id": "1120e446-6695-4e49-91e6-fd1f7698388e", + "predicate": "http://www.ft.com/ontology/hasDisplayTag", + "prefLabel": "Wells Fargo", + "type": "ORGANISATION", + "url": "https://www.ft.com/stream/1120e446-6695-4e49-91e6-fd1f7698388e", + "relativeUrl": "/stream/1120e446-6695-4e49-91e6-fd1f7698388e", + "isDisplayTag": true + }, + "metaAltLink": null, + "title": "Wells Fargo applies for licence in France as part of Brexit strategy", + "altStandfirst": null, + "publishedDate": "2018-10-16T12:16:17.000Z", + "firstPublishedDate": "2018-10-16T12:16:17.000Z", + "image": { + "url": "http://prod-upp-image-read.ft.com/31d2be9e-d13d-11e8-a9f2-7574db66bcd5", + "width": 2048, + "height": 1152 + }, + "headshot": null, + "parentTheme": null + }, + { + "id": "6bd7f80e-d11d-11e8-a9f2-7574db66bcd5", + "url": "https://www.ft.com/content/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5", + "relativeUrl": "/content/6bd7f80e-d11d-11e8-a9f2-7574db66bcd5", + "type": "article", + "indicators": { + "isColumn": false, + "isOpinion": false, + "isScoop": false, + "isExclusive": false, + "isEditorsChoice": false, + "accessLevel": "subscribed" + }, + "metaPrefixText": null, + "metaSuffixText": null, + "metaLink": { + "id": "c4c9204f-8335-42c3-9415-c2c8cd971125", + "predicate": "http://www.ft.com/ontology/hasDisplayTag", + "prefLabel": "UK welfare reform", + "type": "TOPIC", + "url": "https://www.ft.com/stream/c4c9204f-8335-42c3-9415-c2c8cd971125", + "relativeUrl": "/stream/c4c9204f-8335-42c3-9415-c2c8cd971125", + "isDisplayTag": true + }, + "metaAltLink": null, + "title": "Rollout of controversial UK welfare reform faces fresh delay", + "standfirst": "Universal credit system not expected to be fully operational until December 2023", + "altStandfirst": null, + "publishedDate": "2018-10-16T12:03:13.000Z", + "firstPublishedDate": "2018-10-16T12:03:13.000Z", + "image": { + "url": "http://prod-upp-image-read.ft.com/d7517de4-d131-11e8-9a3c-5d5eac8f1ab4", + "width": 2048, + "height": 1152 + }, + "headshot": null, + "parentTheme": null + }, + { + "id": "1969887a-d11e-11e8-a9f2-7574db66bcd5", + "url": "https://www.ft.com/content/1969887a-d11e-11e8-a9f2-7574db66bcd5", + "relativeUrl": "/content/1969887a-d11e-11e8-a9f2-7574db66bcd5", + "type": "article", + "indicators": { + "isColumn": false, + "isOpinion": false, + "isScoop": false, + "isExclusive": false, + "isEditorsChoice": false, + "accessLevel": "subscribed" + }, + "metaPrefixText": null, + "metaSuffixText": null, + "metaLink": { + "id": "466a4700-307f-47cc-83f1-c5f97a172232", + "predicate": "http://www.ft.com/ontology/hasDisplayTag", + "prefLabel": "Pound Sterling", + "type": "TOPIC", + "url": "https://www.ft.com/stream/466a4700-307f-47cc-83f1-c5f97a172232", + "relativeUrl": "/stream/466a4700-307f-47cc-83f1-c5f97a172232", + "isDisplayTag": true + }, + "metaAltLink": null, + "title": "Pound rallies after upbeat wage growth reading", + "standfirst": "Analysts remain cautious despite optimistic data", + "altStandfirst": null, + "publishedDate": "2018-10-16T10:46:19.000Z", + "firstPublishedDate": "2018-10-16T09:18:12.000Z", + "image": {}, + "headshot": null, + "parentTheme": null + }, + { + "id": "dab8bfd2-ce3f-11e8-9fe5-24ad351828ab", + "url": "https://www.ft.com/content/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab", + "relativeUrl": "/content/dab8bfd2-ce3f-11e8-9fe5-24ad351828ab", + "type": "article", + "indicators": { + "isColumn": false, + "isOpinion": false, + "isScoop": false, + "isExclusive": false, + "isEditorsChoice": false, + "accessLevel": "subscribed" + }, + "metaPrefixText": null, + "metaSuffixText": null, + "metaLink": { + "id": "9c0f3107-a34f-4319-a6a6-3101ac88b50d", + "predicate": "http://www.ft.com/ontology/hasDisplayTag", + "prefLabel": "UK politics & policy", + "type": "TOPIC", + "url": "https://www.ft.com/world/uk/politics", + "relativeUrl": "/world/uk/politics", + "isDisplayTag": true + }, + "metaAltLink": null, + "title": "Problem gambling shake-up set to be brought forward", + "standfirst": "Blow for bookmakers as ministers seek to speed up start of fixed-odds betting terminals limits", + "altStandfirst": null, + "publishedDate": "2018-10-16T03:00:59.000Z", + "firstPublishedDate": "2018-10-16T03:00:59.000Z", + "image": { + "url": "http://prod-upp-image-read.ft.com/a6afae18-d069-11e8-9a3c-5d5eac8f1ab4", + "width": 2048, + "height": 1152 + }, + "headshot": null, + "parentTheme": null + }, + { + "id": "d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5", + "url": "https://www.ft.com/content/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5", + "relativeUrl": "/content/d5ebbb7e-d07b-11e8-a9f2-7574db66bcd5", + "type": "article", + "indicators": { + "isColumn": false, + "isOpinion": false, + "isScoop": false, + "isExclusive": false, + "isEditorsChoice": false, + "accessLevel": "subscribed" + }, + "metaPrefixText": null, + "metaSuffixText": null, + "metaLink": { + "id": "6b32f2c1-da43-4e19-80b9-8aef4ab640d7", + "predicate": "http://www.ft.com/ontology/annotation/about", + "prefLabel": "Technology sector", + "type": "TOPIC", + "url": "https://www.ft.com/companies/technology", + "relativeUrl": "/companies/technology" + }, + "metaAltLink": null, + "title": "Crypto exchange Coinbase sets up Brexit contingency", + "standfirst": "US digital start-up to scale up EU operations with new Dublin branch", + "altStandfirst": null, + "publishedDate": "2018-10-15T23:01:36.000Z", + "firstPublishedDate": "2018-10-15T23:01:36.000Z", + "image": { + "url": "http://prod-upp-image-read.ft.com/9a6adda6-d07a-11e8-9a3c-5d5eac8f1ab4", + "width": 2048, + "height": 1152 + }, + "headshot": null, + "parentTheme": null + }, + { + "id": "2e407a74-d06a-11e8-a9f2-7574db66bcd5", + "url": "https://www.ft.com/content/2e407a74-d06a-11e8-a9f2-7574db66bcd5", + "relativeUrl": "/content/2e407a74-d06a-11e8-a9f2-7574db66bcd5", + "type": "article", + "indicators": { + "isColumn": false, + "isOpinion": false, + "isScoop": false, + "isExclusive": false, + "isEditorsChoice": false, + "accessLevel": "subscribed" + }, + "metaPrefixText": null, + "metaSuffixText": null, + "metaLink": { + "id": "82645c31-4426-4ef5-99c9-9df6e0940c00", + "predicate": "http://www.ft.com/ontology/annotation/about", + "prefLabel": "World", + "type": "TOPIC", + "url": "https://www.ft.com/world", + "relativeUrl": "/world" + }, + "metaAltLink": null, + "title": "EU gives UK 24 hour Brexit breathing space", + "standfirst": "Theresa May says deal achievable but Britain cannot be kept in backstop indefinitely", + "altStandfirst": null, + "publishedDate": "2018-10-15T18:13:59.000Z", + "firstPublishedDate": "2018-10-15T12:08:01.000Z", + "image": { + "url": "http://prod-upp-image-read.ft.com/ac0fd098-d075-11e8-9a3c-5d5eac8f1ab4", + "width": 2048, + "height": 1152 + }, + "headshot": null, + "parentTheme": null + }, + { + "id": "434dc8e2-d09f-11e8-a9f2-7574db66bcd5", + "url": "https://www.ft.com/content/434dc8e2-d09f-11e8-a9f2-7574db66bcd5", + "relativeUrl": "/content/434dc8e2-d09f-11e8-a9f2-7574db66bcd5", + "type": "article", + "indicators": { + "isColumn": false, + "isOpinion": false, + "isScoop": false, + "isExclusive": false, + "isEditorsChoice": false, + "accessLevel": "subscribed" + }, + "metaPrefixText": null, + "metaSuffixText": null, + "metaLink": { + "id": "19b95057-4614-45fb-9306-4d54049354db", + "predicate": "http://www.ft.com/ontology/annotation/about", + "prefLabel": "Brexit", + "type": "TOPIC", + "url": "https://www.ft.com/brexit", + "relativeUrl": "/brexit" + }, + "metaAltLink": null, + "title": "EC’s Tusk warns no-deal Brexit ‘is more likely than ever before’", + "altStandfirst": null, + "publishedDate": "2018-10-15T17:30:48.000Z", + "firstPublishedDate": "2018-10-15T17:30:48.000Z", + "image": {}, + "headshot": null, + "parentTheme": null + }, + { + "id": "fadfb212-d091-11e8-a9f2-7574db66bcd5", + "url": "https://www.ft.com/content/fadfb212-d091-11e8-a9f2-7574db66bcd5", + "relativeUrl": "/content/fadfb212-d091-11e8-a9f2-7574db66bcd5", + "type": "article", + "indicators": { + "isColumn": false, + "isOpinion": false, + "isScoop": false, + "isExclusive": false, + "isEditorsChoice": false, + "accessLevel": "subscribed" + }, + "metaPrefixText": null, + "metaSuffixText": null, + "metaLink": { + "id": "19b95057-4614-45fb-9306-4d54049354db", + "predicate": "http://www.ft.com/ontology/annotation/about", + "prefLabel": "Brexit", + "type": "TOPIC", + "url": "https://www.ft.com/brexit", + "relativeUrl": "/brexit" + }, + "metaAltLink": null, + "title": "Barnier plan no solution to Irish border, says Foster", + "altStandfirst": null, + "publishedDate": "2018-10-15T16:06:36.000Z", + "firstPublishedDate": "2018-10-15T16:06:36.000Z", + "image": { + "url": "http://prod-upp-image-read.ft.com/d0cbda02-cbb7-11e8-8d0b-a6539b949662", + "width": 2048, + "height": 1152 + }, + "headshot": null, + "parentTheme": null + }, + { + "id": "79939f94-c320-11e8-8d55-54197280d3f7", + "url": "https://www.ft.com/content/79939f94-c320-11e8-8d55-54197280d3f7", + "relativeUrl": "/content/79939f94-c320-11e8-8d55-54197280d3f7", + "type": "article", + "indicators": { + "isColumn": false, + "isOpinion": false, + "isScoop": false, + "isExclusive": false, + "isEditorsChoice": false, + "accessLevel": "subscribed" + }, + "metaPrefixText": "Explainer", + "metaSuffixText": null, + "metaLink": { + "id": "466a4700-307f-47cc-83f1-c5f97a172232", + "predicate": "http://www.ft.com/ontology/annotation/about", + "prefLabel": "Pound Sterling", + "type": "TOPIC", + "url": "https://www.ft.com/stream/466a4700-307f-47cc-83f1-c5f97a172232", + "relativeUrl": "/stream/466a4700-307f-47cc-83f1-c5f97a172232" + }, + "metaAltLink": null, + "title": "Pound timeline: from the 1970s to Brexit crunch", + "standfirst": "Sterling has experienced several periods of volatility in the past 48 years", + "altStandfirst": null, + "publishedDate": "2018-10-15T13:44:20.000Z", + "firstPublishedDate": "2018-10-15T13:44:20.000Z", + "image": { + "url": "http://prod-upp-image-read.ft.com/aea311c6-c32d-11e8-95b1-d36dfef1b89a", + "width": 2048, + "height": 1152 + }, + "headshot": null, + "parentTheme": null + }, + { + "id": "bfffa642-d079-11e8-a9f2-7574db66bcd5", + "url": "https://www.ft.com/content/bfffa642-d079-11e8-a9f2-7574db66bcd5", + "relativeUrl": "/content/bfffa642-d079-11e8-a9f2-7574db66bcd5", + "type": "article", + "indicators": { + "isColumn": false, + "isOpinion": false, + "isScoop": false, + "isExclusive": false, + "isEditorsChoice": false, + "accessLevel": "subscribed" + }, + "metaPrefixText": null, + "metaSuffixText": null, + "metaLink": { + "id": "19b95057-4614-45fb-9306-4d54049354db", + "predicate": "http://www.ft.com/ontology/annotation/about", + "prefLabel": "Brexit", + "type": "TOPIC", + "url": "https://www.ft.com/brexit", + "relativeUrl": "/brexit" + }, + "metaAltLink": null, + "title": "Brexit talks could drag into December warns Ireland’s Varadkar", + "standfirst": "Taoiseach says he always believed a deal this month was unlikely", + "altStandfirst": null, + "publishedDate": "2018-10-15T13:05:06.000Z", + "firstPublishedDate": "2018-10-15T13:05:06.000Z", + "image": { + "url": "http://prod-upp-image-read.ft.com/f87f782a-d089-11e8-9a3c-5d5eac8f1ab4", + "width": 2048, + "height": 1152 + }, + "headshot": null, + "parentTheme": null + }, + { + "id": "82ae2756-d073-11e8-a9f2-7574db66bcd5", + "url": "https://www.ft.com/content/82ae2756-d073-11e8-a9f2-7574db66bcd5", + "relativeUrl": "/content/82ae2756-d073-11e8-a9f2-7574db66bcd5", + "type": "article", + "indicators": { + "isColumn": false, + "isOpinion": false, + "isScoop": false, + "isExclusive": false, + "isEditorsChoice": false, + "accessLevel": "subscribed" + }, + "metaPrefixText": null, + "metaSuffixText": null, + "metaLink": { + "id": "464cc2f2-395e-4c36-bb29-01727fc95558", + "predicate": "http://www.ft.com/ontology/classification/isClassifiedBy", + "prefLabel": "Brexit Briefing", + "type": "BRAND", + "url": "https://www.ft.com/brexit-briefing", + "relativeUrl": "/brexit-briefing" + }, + "metaAltLink": { + "id": "d7253b94-b248-4022-af1e-e99c15d0c1b6", + "predicate": "http://www.ft.com/ontology/annotation/about", + "prefLabel": "UK trade", + "type": "TOPIC", + "url": "https://www.ft.com/uk-trade", + "relativeUrl": "/uk-trade" + }, + "title": "Crisis or choreography over Brexit?", + "standfirst": "While ministers haggle, company bosses press the button on expensive no-deal planning", + "altStandfirst": null, + "publishedDate": "2018-10-15T13:04:49.000Z", + "firstPublishedDate": "2018-10-15T13:04:49.000Z", + "image": { + "url": "http://prod-upp-image-read.ft.com/31c1c612-d079-11e8-9a3c-5d5eac8f1ab4", + "width": 2048, + "height": 1152 + }, + "headshot": null, + "parentTheme": null + }, + { + "id": "a8181102-ce1e-11e8-9fe5-24ad351828ab", + "url": "https://www.ft.com/content/a8181102-ce1e-11e8-9fe5-24ad351828ab", + "relativeUrl": "/content/a8181102-ce1e-11e8-9fe5-24ad351828ab", + "type": "article", + "indicators": { + "isColumn": false, + "isOpinion": false, + "isScoop": false, + "isExclusive": false, + "isEditorsChoice": false, + "accessLevel": "subscribed" + }, + "metaPrefixText": null, + "metaSuffixText": null, + "metaLink": { + "id": "d1252624-8645-438b-b521-9244aebdc99e", + "predicate": "http://www.ft.com/ontology/annotation/about", + "prefLabel": "Industrials", + "type": "TOPIC", + "url": "https://www.ft.com/companies/industrials", + "relativeUrl": "/companies/industrials" + }, + "metaAltLink": null, + "title": "UK shipyards to submit bids to build 5 warships", + "standfirst": "MoD restarts competition despite industry’s concerns over budget and timing", + "altStandfirst": null, + "publishedDate": "2018-10-15T11:02:36.000Z", + "firstPublishedDate": "2018-10-15T11:02:36.000Z", + "image": { + "url": "http://prod-upp-image-read.ft.com/f1191270-ce41-11e8-8d0b-a6539b949662", + "width": 2048, + "height": 1152 + }, + "headshot": null, + "parentTheme": null + }, + { + "id": "9b3b6d2e-d064-11e8-a9f2-7574db66bcd5", + "url": "https://www.ft.com/content/9b3b6d2e-d064-11e8-a9f2-7574db66bcd5", + "relativeUrl": "/content/9b3b6d2e-d064-11e8-a9f2-7574db66bcd5", + "type": "article", + "indicators": { + "isColumn": false, + "isOpinion": false, + "isScoop": false, + "isExclusive": false, + "isEditorsChoice": false, + "accessLevel": "subscribed" + }, + "metaPrefixText": null, + "metaSuffixText": null, + "metaLink": { + "id": "19b95057-4614-45fb-9306-4d54049354db", + "predicate": "http://www.ft.com/ontology/annotation/about", + "prefLabel": "Brexit", + "type": "TOPIC", + "url": "https://www.ft.com/brexit", + "relativeUrl": "/brexit" + }, + "metaAltLink": null, + "title": "May to address UK parliament on state of Brexit talks", + "altStandfirst": null, + "publishedDate": "2018-10-15T10:41:21.000Z", + "firstPublishedDate": "2018-10-15T10:41:21.000Z", + "image": { + "url": "http://prod-upp-image-read.ft.com/c77408fa-d065-11e8-a9f2-7574db66bcd5", + "width": 2048, + "height": 1152 + }, + "headshot": null, + "parentTheme": null + }, + { + "id": "ed665ed8-cdfd-11e8-9fe5-24ad351828ab", + "url": "https://www.ft.com/content/ed665ed8-cdfd-11e8-9fe5-24ad351828ab", + "relativeUrl": "/content/ed665ed8-cdfd-11e8-9fe5-24ad351828ab", + "type": "article", + "indicators": { + "isColumn": false, + "isOpinion": false, + "isScoop": false, + "isExclusive": false, + "isEditorsChoice": false, + "accessLevel": "subscribed" + }, + "metaPrefixText": null, + "metaSuffixText": null, + "metaLink": { + "id": "6cc4c4ed-ef61-4c71-a5bb-f454fb5010a8", + "predicate": "http://www.ft.com/ontology/classification/isPrimarilyClassifiedBy", + "prefLabel": "UK business & economy", + "type": "TOPIC", + "url": "https://www.ft.com/uk-business-economy", + "relativeUrl": "/uk-business-economy" + }, + "metaAltLink": null, + "title": "Bradford hospital launches AI powered command centre", + "standfirst": "Collaboration with GE Healthcare to create hub monitoring patients to make better use of resources", + "altStandfirst": null, + "publishedDate": "2018-10-15T10:01:47.000Z", + "firstPublishedDate": "2018-10-15T10:01:47.000Z", + "image": { + "url": "http://prod-upp-image-read.ft.com/66d87a12-d06f-11e8-9a3c-5d5eac8f1ab4", + "width": 2048, + "height": 1152 + }, + "headshot": null, + "parentTheme": null + }, + { + "id": "7c6b9fbb-ad93-34c5-b19b-ea79ffc5f426", + "url": "http://ftalphaville.ft.com/marketslive/2018-10-15/", + "relativeUrl": "http://ftalphaville.ft.com/marketslive/2018-10-15/", + "type": "article", + "indicators": { + "isColumn": true, + "isOpinion": true, + "isScoop": false, + "isExclusive": false, + "isEditorsChoice": false, + "accessLevel": "registered" + }, + "metaPrefixText": "FT Alphaville", + "metaSuffixText": null, + "metaLink": { + "id": "602c702b-31fe-4116-a203-747c7c737eb7", + "predicate": "http://www.ft.com/ontology/annotation/hasAuthor", + "prefLabel": "Bryce Elder", + "type": "PERSON", + "url": "https://www.ft.com/markets/bryce-elder", + "relativeUrl": "/markets/bryce-elder" + }, + "metaAltLink": { + "id": "c91b1fad-1097-468b-be82-9a8ff717d54c", + "predicate": "http://www.ft.com/ontology/classification/isPrimarilyClassifiedBy", + "prefLabel": "Markets", + "type": "TOPIC", + "url": "https://www.ft.com/markets", + "relativeUrl": "/markets" + }, + "title": "Markets Live: Monday, 15th October 2018", + "altStandfirst": null, + "publishedDate": "2018-10-15T10:01:26.000Z", + "firstPublishedDate": "2018-10-15T10:01:26.000Z", + "image": {}, + "parentTheme": null + }, + { + "id": "aa9b9bda-d056-11e8-a9f2-7574db66bcd5", + "url": "https://www.ft.com/content/aa9b9bda-d056-11e8-a9f2-7574db66bcd5", + "relativeUrl": "/content/aa9b9bda-d056-11e8-a9f2-7574db66bcd5", + "type": "article", + "indicators": { + "isColumn": false, + "isOpinion": false, + "isScoop": false, + "isExclusive": false, + "isEditorsChoice": false, + "accessLevel": "subscribed" + }, + "metaPrefixText": null, + "metaSuffixText": null, + "metaLink": { + "id": "d7253b94-b248-4022-af1e-e99c15d0c1b6", + "predicate": "http://www.ft.com/ontology/annotation/about", + "prefLabel": "UK trade", + "type": "TOPIC", + "url": "https://www.ft.com/uk-trade", + "relativeUrl": "/uk-trade" + }, + "metaAltLink": null, + "title": "Irish deputy PM voices ‘frustration’ at Brexit impasse", + "altStandfirst": null, + "publishedDate": "2018-10-15T08:50:32.000Z", + "firstPublishedDate": "2018-10-15T08:50:32.000Z", + "image": {}, + "headshot": null, + "parentTheme": null + } +] diff --git a/components/x-teaser-timeline/storybook/index.jsx b/components/x-teaser-timeline/storybook/index.jsx new file mode 100644 index 000000000..70e5fca40 --- /dev/null +++ b/components/x-teaser-timeline/storybook/index.jsx @@ -0,0 +1,30 @@ +import React from 'react' +import { TeaserTimeline } from '../src/TeaserTimeline' +import { Helmet } from 'react-helmet' +import BuildService from '../../../.storybook/build-service' +const { args, argTypes } = require('./timeline') + +const dependencies = { + 'o-normalise': '^1.6.0', + 'o-typography': '^5.5.0', + 'o-teaser': '^2.3.1' +} + +export default { + title: 'x-teaser-timeline' +} + +export const Timeline = (args) => { + return ( + <div className="story-container"> + {dependencies && <BuildService dependencies={dependencies} />} + <Helmet> + <link rel="stylesheet" href={`components/x-teaser-timeline/dist/TeaserTimeline.css`} /> + </Helmet> + <TeaserTimeline {...args} /> + </div> + ) +} + +Timeline.args = args +Timeline.argTypes = argTypes diff --git a/components/x-teaser-timeline/storybook/timeline.js b/components/x-teaser-timeline/storybook/timeline.js new file mode 100644 index 000000000..83ee08b1d --- /dev/null +++ b/components/x-teaser-timeline/storybook/timeline.js @@ -0,0 +1,21 @@ +exports.args = { + items: require('./content-items.json'), + timezoneOffset: -60, + localTodayDate: '2018-10-17', + latestItemsTime: '2018-10-17T12:10:33.000Z', + customSlotContent: 'Custom slot content', + customSlotPosition: 3 +} + +exports.argTypes = { + latestItemsTime: { + control: { type: 'select', options: { None: '', '2018-10-17T12:10:33.000Z': '2018-10-17T12:10:33.000Z' } } + }, + customSlotContent: { + control: { type: 'select', options: { None: '', Something: '---Custom slot content---' } } + } +} + +// This reference is only required for hot module loading in development +// <https://webpack.js.org/concepts/hot-module-replacement/> +exports.m = module diff --git a/components/x-teaser/.npmignore b/components/x-teaser/.npmignore index a31002f9a..714c4a0a3 100644 --- a/components/x-teaser/.npmignore +++ b/components/x-teaser/.npmignore @@ -1,3 +1,2 @@ -src/ stories/ rollup.config.js diff --git a/components/x-teaser/Props.d.ts b/components/x-teaser/Props.d.ts index 12851df89..6d1e9de91 100644 --- a/components/x-teaser/Props.d.ts +++ b/components/x-teaser/Props.d.ts @@ -1,106 +1,121 @@ -export type ContentType = 'article' | 'video' | 'podcast' | 'audio' | 'package' | 'liveblog' | 'promoted-content' | 'paid-post'; +export type ContentType = + | 'article' + | 'video' + | 'podcast' + | 'audio' + | 'package' + | 'liveblog' + | 'promoted-content' + | 'paid-post' /** Strings must be a parseable format, e.g. ISO 8601 */ -export type DateLike = Date | string | number; +export type DateLike = Date | string | number -export type Layout = 'small' | 'large' | 'hero' | 'top-story'; +export type Layout = 'small' | 'large' | 'hero' | 'top-story' -export type Theme = 'extra-article'; +export type Theme = 'extra-article' -export type Modifier = 'stacked' | 'centre' | 'stretched' | 'opinion-background' | 'landscape' | 'big-story' | string; +export type Modifier = + | 'stacked' + | 'centre' + | 'stretched' + | 'opinion-background' + | 'landscape' + | 'big-story' + | string -export type ImageSize = 'XS' | 'Small' | 'Medium' | 'Large' | 'XL' | 'XXL'; +export type ImageSize = 'XS' | 'Small' | 'Medium' | 'Large' | 'XL' | 'XXL' export interface Features { - showMeta?: boolean; - showTitle?: boolean; - showStandfirst?: boolean; - showStatus?: boolean; - showImage?: boolean; - showHeadshot?: boolean; - showVideo?: boolean; - showRelatedLinks?: boolean; - showCustomSlot?: boolean; + showMeta?: boolean + showTitle?: boolean + showStandfirst?: boolean + showStatus?: boolean + showImage?: boolean + showHeadshot?: boolean + showVideo?: boolean + showRelatedLinks?: boolean + showCustomSlot?: boolean } export interface General { - id: string; - url?: string; - /** Preferred to url if available */ - relativeUrl?: string; - type: ContentType; - indicators: Indicators; + id: string + url?: string + /** Preferred to url if available */ + relativeUrl?: string + type: ContentType + indicators: Indicators } export interface Meta { - /** Usually a brand, or a genre, or content type */ - metaPrefixText?: string; - metaSuffixText?: string; - metaLink?: MetaLink; - /** Fallback used if the parentId is the same as the display concept */ - metaAltLink?: MetaLink; - /** Promoted content type */ - promotedPrefixText?: string; - promotedSuffixText?: string; + /** Usually a brand, or a genre, or content type */ + metaPrefixText?: string + metaSuffixText?: string + metaLink?: MetaLink + /** Fallback used if the parentId is the same as the display concept */ + metaAltLink?: MetaLink + /** Promoted content type */ + promotedPrefixText?: string + promotedSuffixText?: string } export interface Title { - title: string; - /** Used for testing headline variations */ - altTitle?: string; + title: string + /** Used for testing headline variations */ + altTitle?: string } export interface Standfirst { - standfirst?: string; - /** Used for testing standfirst variations */ - altStandfirst?: string; + standfirst?: string + /** Used for testing standfirst variations */ + altStandfirst?: string } export interface Status { - publishedDate: DateLike; - firstPublishedDate: DateLike; - /** Displays new/updated X mins/hours ago */ - useRelativeTime?: boolean; - /** Live blog status, will override date and time */ - status?: 'inprogress' | 'comingsoon' | 'closed'; + publishedDate: DateLike + firstPublishedDate: DateLike + /** Displays new/updated X mins/hours ago */ + useRelativeTime?: boolean + /** Live blog status, will override date and time */ + status?: 'inprogress' | 'comingsoon' | 'closed' } export interface Image { - /** Images must be accessible to the Origami Image Service */ - image?: Media; - imageSize?: ImageSize; - imageLazyload?: Boolean | String; + /** Images must be accessible to the Origami Image Service */ + image?: Media + imageSize?: ImageSize + imageLazyLoad?: Boolean | String } export interface Headshot { - headshot?: String; - headshotTint?: String; + headshot?: String + headshotTint?: String } export interface Video { - video?: Media + video?: Media } export interface RelatedLinks { - relatedLinks?: Link[]; + relatedLinks?: Link[] } export interface Context { - /** Enables alternative content for headline testing */ - headlineTesting?: Boolean; - /** Shows the alternative meta link when the label matches */ - parentLabel?: String; - /** Shows the alternative meta link when the ID matches */ - parentId?: String; + /** Enables alternative content for headline testing */ + headlineTesting?: Boolean + /** Shows the alternative meta link when the label matches */ + parentLabel?: String + /** Shows the alternative meta link when the ID matches */ + parentId?: String } export interface Variants { - /** Default is "small" */ - layout?: Layout; - /** Content package theme */ - theme?: Theme; - /** Extra class name variations to append */ - modifiers?: Modifier[]; + /** Default is "small" */ + layout?: Layout + /** Content package theme */ + theme?: Theme + /** Extra class name variations to append */ + modifiers?: Modifier[] } // @@ -108,36 +123,48 @@ export interface Variants { // export interface MetaLink { - url: string; - /** Preferred if available */ - relativeUrl?; - prefLabel: string; + url: string + /** Preferred if available */ + relativeUrl? + prefLabel: string } export interface Link { - id: string; - type: ContentType; - url: string; - /** Preferred to url if available */ - relativeUrl?; - title: string; + id: string + type: ContentType + url: string + /** Preferred to url if available */ + relativeUrl? + title: string } export interface Media { - url: string; - width: number; - height: number; + url: string + width: number + height: number } export interface Indicators { - accessLevel: 'premium' | 'subscribed' | 'registered' | 'free'; - isOpinion?: boolean; - isColumn?: boolean; - isPodcast?: boolean; - /** Methode packaging options */ - isEditorsChoice?: boolean; - isExclusive?: boolean; - isScoop?: boolean; -} - -export interface TeaserProps extends Features, General, Meta, Title, Standfirst, Status, Image, Headshot, Video, RelatedLinks, Context, Variants {} + accessLevel: 'premium' | 'subscribed' | 'registered' | 'free' + isOpinion?: boolean + isColumn?: boolean + isPodcast?: boolean + /** Methode packaging options */ + isEditorsChoice?: boolean + isExclusive?: boolean + isScoop?: boolean +} + +export interface TeaserProps + extends Features, + General, + Meta, + Title, + Standfirst, + Status, + Image, + Headshot, + Video, + RelatedLinks, + Context, + Variants {} diff --git a/components/x-teaser/__fixtures__/article-with-data-image.json b/components/x-teaser/__fixtures__/article-with-data-image.json new file mode 100644 index 000000000..6124a4c98 --- /dev/null +++ b/components/x-teaser/__fixtures__/article-with-data-image.json @@ -0,0 +1,30 @@ +{ + "type": "article", + "id": "", + "url": "#", + "title": "Inside charity fundraiser where hostesses are put on show", + "altTitle": "Men Only, the charity fundraiser with hostesses on show", + "standfirst": "FT investigation finds groping and sexual harassment at secretive black-tie dinner", + "altStandfirst": "Groping and sexual harassment at black-tie dinner charity event", + "publishedDate": "2018-01-23T15:07:00.000Z", + "firstPublishedDate": "2018-01-23T13:53:00.000Z", + "metaPrefixText": "", + "metaSuffixText": "", + "metaLink": { + "url": "#", + "prefLabel": "Sexual misconduct allegations" + }, + "metaAltLink": { + "url": "#", + "prefLabel": "FT Investigations" + }, + "image": { + "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==", + "width": 2048, + "height": 1152, + "imageLazyLoad": "js-image-lazy-load" + }, + "indicators": { + "isEditorsChoice": true + } +} diff --git a/components/x-teaser/__fixtures__/article-with-missing-image-url.json b/components/x-teaser/__fixtures__/article-with-missing-image-url.json new file mode 100644 index 000000000..792895849 --- /dev/null +++ b/components/x-teaser/__fixtures__/article-with-missing-image-url.json @@ -0,0 +1,34 @@ +{ + "type": "article", + "id": "", + "url": "#", + "title": "Inside charity fundraiser where hostesses are put on show", + "altTitle": "Men Only, the charity fundraiser with hostesses on show", + "standfirst": "FT investigation finds groping and sexual harassment at secretive black-tie dinner", + "altStandfirst": "Groping and sexual harassment at black-tie dinner charity event", + "publishedDate": "2018-01-23T15:07:00.000Z", + "firstPublishedDate": "2018-01-23T13:53:00.000Z", + "metaPrefixText": "", + "metaSuffixText": "", + "metaLink": { + "url": "#", + "prefLabel": "Sexual misconduct allegations" + }, + "metaAltLink": { + "url": "#", + "prefLabel": "FT Investigations" + }, + "image": { + "width": 2048, + "height": 1152 + }, + "indicators": { + "isEditorsChoice": true + }, + "status": "", + "headshotTint": "", + "accessLevel": "free", + "theme": "", + "parentTheme": "", + "modifiers": "" +} diff --git a/components/x-teaser/__fixtures__/article.json b/components/x-teaser/__fixtures__/article.json new file mode 100644 index 000000000..fdcdb0a91 --- /dev/null +++ b/components/x-teaser/__fixtures__/article.json @@ -0,0 +1,35 @@ +{ + "type": "article", + "id": "", + "url": "#", + "title": "Inside charity fundraiser where hostesses are put on show", + "altTitle": "Men Only, the charity fundraiser with hostesses on show", + "standfirst": "FT investigation finds groping and sexual harassment at secretive black-tie dinner", + "altStandfirst": "Groping and sexual harassment at black-tie dinner charity event", + "publishedDate": "2018-01-23T15:07:00.000Z", + "firstPublishedDate": "2018-01-23T13:53:00.000Z", + "metaPrefixText": "", + "metaSuffixText": "", + "metaLink": { + "url": "#", + "prefLabel": "Sexual misconduct allegations" + }, + "metaAltLink": { + "url": "#", + "prefLabel": "FT Investigations" + }, + "image": { + "url": "http://prod-upp-image-read.ft.com/a25832ea-0053-11e8-9650-9c0ad2d7c5b5", + "width": 2048, + "height": 1152 + }, + "indicators": { + "isEditorsChoice": true + }, + "status": "", + "headshotTint": "", + "accessLevel": "free", + "theme": "", + "parentTheme": "", + "modifiers": "" +} diff --git a/components/x-teaser/__fixtures__/content-package.json b/components/x-teaser/__fixtures__/content-package.json new file mode 100644 index 000000000..33b749cd3 --- /dev/null +++ b/components/x-teaser/__fixtures__/content-package.json @@ -0,0 +1,32 @@ +{ + "type": "package", + "id": "", + "url": "#", + "title": "The royal wedding", + "altTitle": "", + "standfirst": "Prince Harry and Meghan Markle will tie the knot at Windsor Castle", + "altStandfirst": "", + "publishedDate": "2018-05-14T16:38:49.000Z", + "firstPublishedDate": "2018-05-14T16:38:49.000Z", + "metaPrefixText": "", + "metaSuffixText": "", + "metaLink": { + "url": "#", + "prefLabel": "FT Magazine" + }, + "metaAltLink": { + "url": "#", + "prefLabel": "FT Series" + }, + "image": { + "url": "http://prod-upp-image-read.ft.com/7e97f5b6-578d-11e8-b8b2-d6ceb45fa9d0", + "width": 2048, + "height": 1152 + }, + "status": "", + "headshotTint": "", + "accessLevel": "free", + "theme": "", + "parentTheme": "", + "modifiers": "centre" +} diff --git a/components/x-teaser/__fixtures__/opinion.json b/components/x-teaser/__fixtures__/opinion.json new file mode 100644 index 000000000..482fa0394 --- /dev/null +++ b/components/x-teaser/__fixtures__/opinion.json @@ -0,0 +1,38 @@ +{ + "type": "article", + "id": "", + "url": "#", + "title": "Anti-Semitism and the threat of identity politics", + "altTitle": "", + "standfirst": "Today, hatred of Jews is mixed in with fights about Islam and Israel", + "altStandfirst": "Anti-Semitism and identity politics", + "publishedDate": "2018-04-02T12:22:01.000Z", + "firstPublishedDate": "2018-04-02T12:22:01.000Z", + "metaPrefixText": "", + "metaSuffixText": "", + "metaLink": { + "url": "#", + "prefLabel": "Gideon Rachman" + }, + "metaAltLink": { + "url": "#", + "prefLabel": "Anti-Semitism" + }, + "image": { + "url": "http://prod-upp-image-read.ft.com/1005ca96-364b-11e8-8b98-2f31af407cc8", + "width": 2048, + "height": 1152 + }, + "headshot": "fthead-v1:gideon-rachman", + "indicators": { + "isOpinion": true, + "isColumn": true + }, + "showHeadshot": true, + "status": "", + "headshotTint": "", + "accessLevel": "free", + "theme": "", + "parentTheme": "", + "modifiers": "" +} diff --git a/components/x-teaser/__fixtures__/package-item.json b/components/x-teaser/__fixtures__/package-item.json new file mode 100644 index 000000000..e04bcf4f3 --- /dev/null +++ b/components/x-teaser/__fixtures__/package-item.json @@ -0,0 +1,29 @@ +{ + "type": "article", + "id": "", + "url": "#", + "title": "Why so little has changed since the crash", + "standfirst": "Martin Wolf on the power of vested interests in today’s rent-extracting economy", + "publishedDate": "2018-09-02T15:07:00.000Z", + "firstPublishedDate": "2018-09-02T13:53:00.000Z", + "metaPrefixText": "FT Series", + "metaSuffixText": "", + "metaLink": { + "url": "#", + "prefLabel": "Financial crisis: Are we safer now? " + }, + "image": { + "url": "http://prod-upp-image-read.ft.com/a25832ea-0053-11e8-9650-9c0ad2d7c5b5", + "width": 2048, + "height": 1152 + }, + "indicators": { + "isOpinion": true + }, + "status": "", + "headshotTint": "", + "accessLevel": "free", + "theme": "", + "parentTheme": "extra-article", + "modifiers": "centre" +} diff --git a/components/x-teaser/__fixtures__/podcast.json b/components/x-teaser/__fixtures__/podcast.json new file mode 100644 index 000000000..734ec3d6a --- /dev/null +++ b/components/x-teaser/__fixtures__/podcast.json @@ -0,0 +1,30 @@ +{ + "type": "audio", + "id": "d1246074-f7d3-4aaf-951c-80a6db495765", + "url": "https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765", + "title": "Who sets the internet standards?", + "standfirst": "Hannah Kuchler talks to American social scientist and cyber security expert Andrea…", + "altStandfirst": "", + "publishedDate": "2018-10-24T04:00:00.000Z", + "firstPublishedDate": "2018-10-24T04:00:00.000Z", + "metaSuffixText": "12 mins", + "metaLink": { + "url": "#", + "prefLabel": "Tech Tonic podcast" + }, + "metaAltLink": "", + "image": { + "url": "https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F5d1a54aa-f49b-11e8-ae55-df4bf40f9d0d?source=next&fit=scale-down&compression=best&width=240", + "width": 2048, + "height": 1152 + }, + "indicators": { + "isPodcast": true + }, + "status": "", + "headshotTint": "", + "accessLevel": "free", + "theme": "", + "parentTheme": "", + "modifiers": "" +} diff --git a/components/x-teaser/__fixtures__/promoted.json b/components/x-teaser/__fixtures__/promoted.json new file mode 100644 index 000000000..987c45072 --- /dev/null +++ b/components/x-teaser/__fixtures__/promoted.json @@ -0,0 +1,20 @@ +{ + "type": "paid-post", + "id": "", + "url": "#", + "title": "Why eSports companies are on a winning streak", + "standfirst": "ESports is big business and about to get bigger: global revenues could hit $1.5bn by 2020", + "promotedPrefixText": "Paid post", + "promotedSuffixText": "UBS", + "image": { + "url": "https://tpc.googlesyndication.com/pagead/imgad?id=CICAgKCrm_3yahABGAEyCMx3RoLss603", + "width": 700, + "height": 394 + }, + "status": "", + "headshotTint": "", + "accessLevel": "free", + "theme": "", + "parentTheme": "", + "modifiers": "" +} diff --git a/components/x-teaser/__fixtures__/top-story.json b/components/x-teaser/__fixtures__/top-story.json new file mode 100644 index 000000000..d179f5011 --- /dev/null +++ b/components/x-teaser/__fixtures__/top-story.json @@ -0,0 +1,53 @@ +{ + "type": "article", + "id": "", + "url": "#", + "title": "Inside charity fundraiser where hostesses are put on show", + "altTitle": "Men Only, the charity fundraiser with hostesses on show", + "standfirst": "FT investigation finds groping and sexual harassment at secretive black-tie dinner", + "altStandfirst": "Groping and sexual harassment at black-tie dinner charity event", + "publishedDate": "2018-01-23T15:07:00.000Z", + "firstPublishedDate": "2018-01-23T13:53:00.000Z", + "dataTrackable": "slice:hero-slice-1;slicePos:1;layout:standaloneimage;listPos:1", + "metaPrefixText": "", + "metaSuffixText": "", + "metaLink": { + "url": "#", + "prefLabel": "Sexual misconduct allegations" + }, + "metaAltLink": { + "url": "#", + "prefLabel": "FT Investigations" + }, + "image": { + "url": "http://prod-upp-image-read.ft.com/a25832ea-0053-11e8-9650-9c0ad2d7c5b5", + "width": 2048, + "height": 1152 + }, + "relatedLinks": [ + { + "id": "", + "relativeUrl": "#", + "type": "article", + "title": "Removing the fig leaf of charity" + }, + { + "id": "", + "relativeUrl": "#", + "type": "article", + "title": "A dinner that demeaned both women and men" + }, + { + "id": "", + "relativeUrl": "#", + "type": "video", + "title": "PM speaks out after Presidents Club dinner" + } + ], + "status": "", + "headshotTint": "", + "accessLevel": "free", + "theme": "", + "parentTheme": "", + "modifiers": "" +} diff --git a/components/x-teaser/__fixtures__/video.json b/components/x-teaser/__fixtures__/video.json new file mode 100644 index 000000000..e4182827f --- /dev/null +++ b/components/x-teaser/__fixtures__/video.json @@ -0,0 +1,32 @@ +{ + "type": "video", + "id": "0e89d872-5711-457b-80b1-4ca0d8afea46", + "url": "#", + "title": "FT View: Donald Trump, man of steel", + "standfirst": "The FT's Rob Armstrong looks at why Donald Trump is pushing trade tariffs", + "publishedDate": "2018-03-26T08:12:28.137Z", + "firstPublishedDate": "2018-03-26T08:12:28.137Z", + "metaPrefixText": "", + "metaSuffixText": "02:51min", + "systemCode": "x-teaser", + "metaLink": { + "url": "#", + "prefLabel": "Global Trade" + }, + "metaAltLink": { + "url": "#", + "prefLabel": "US" + }, + "image": { + "url": "http://com.ft.imagepublish.upp-prod-eu.s3.amazonaws.com/a27ce49b-85b8-445b-b883-db6e2f533194", + "width": 1920, + "height": 1080 + }, + "video": { + "url": "https://next-media-api.ft.com/renditions/15218247321960/640x360.mp4", + "width": 640, + "height": 360, + "mediaType": "video/mp4", + "codec": "h264" + } +} diff --git a/__tests__/__snapshots__/snapshots.test.js.snap b/components/x-teaser/__tests__/__snapshots__/snapshots.test.js.snap similarity index 50% rename from __tests__/__snapshots__/snapshots.test.js.snap rename to components/x-teaser/__tests__/__snapshots__/snapshots.test.js.snap index 6854a4225..3ad732588 100644 --- a/__tests__/__snapshots__/snapshots.test.js.snap +++ b/components/x-teaser/__tests__/__snapshots__/snapshots.test.js.snap @@ -1,71 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`@financial-times/x-article-save-button renders a default ArticleSaveButton x-article-save-button 1`] = ` -<form - action="/myft/save/0000-0000-0000-0000" - className="ArticleSaveButton_root__Utel0" - data-content-id="0000-0000-0000-0000" - method="GET" - onSubmit={[Function]} -> - <input - name="token" - type="hidden" - value="dummy-token" - /> - <button - aria-label="Save UK crime agency steps up assault on Russian dirty money to myFT for later" - aria-pressed={false} - className="ArticleSaveButton_button__2_wUr" - data-content-id="0000-0000-0000-0000" - data-trackable="trackable-id" - type="submit" - > - <span - className="ArticleSaveButton_icon__-7Con" - /> - Save - </button> -</form> -`; - -exports[`@financial-times/x-increment renders a default Async x-increment 1`] = ` -<div> - <span> - 1 - </span> - <button - disabled={false} - onClick={[Function]} - > - Increment - </button> -</div> -`; - -exports[`@financial-times/x-increment renders a default Increment x-increment 1`] = ` -<div> - <span> - 1 - </span> - <button - disabled={false} - onClick={[Function]} - > - Increment - </button> -</div> -`; - -exports[`@financial-times/x-styling-demo renders a default Styling x-styling-demo 1`] = ` -<button - className="Button_button__vS7Mv" -> - Click me! -</button> -`; - -exports[`@financial-times/x-teaser renders a Hero Article x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a Hero teaser with article data 1`] = ` <div className="o-teaser o-teaser--article o-teaser--hero o-teaser--has-image o-teaser--highlight js-teaser" data-id="" @@ -76,17 +11,14 @@ exports[`@financial-times/x-teaser renders a Hero Article x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Sexual misconduct allegations - </a> - </div> + Sexual misconduct allegations + </a> </div> <div className="o-teaser__heading" @@ -97,51 +29,133 @@ exports[`@financial-times/x-teaser renders a Hero Article x-teaser 1`] = ` href="#" > Inside charity fundraiser where hostesses are put on show - </a> </div> - <p - className="o-teaser__standfirst" + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&dpr=2&width=340" + /> + </div> + </a> + </div> +</div> +`; + +exports[`x-teaser / snapshots renders a Hero teaser with article-with-data-image data 1`] = ` +<div + className="o-teaser o-teaser--article o-teaser--hero o-teaser--has-image o-teaser--highlight js-teaser" + data-id="" +> + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" > <a - className="js-teaser-standfirst-link" - data-trackable="standfirst-link" + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" href="#" - tabIndex={-1} > - FT investigation finds groping and sexual harassment at secretive black-tie dinner + Sexual misconduct allegations </a> - </p> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="#" + > + Inside charity fundraiser where hostesses are put on show + </a> + </div> </div> <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&compression=best&width=340" + src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==" /> + </div> + </a> + </div> +</div> +`; + +exports[`x-teaser / snapshots renders a Hero teaser with article-with-missing-image-url data 1`] = ` +<div + className="o-teaser o-teaser--article o-teaser--hero o-teaser--highlight js-teaser" + data-id="" +> + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" + > + Sexual misconduct allegations + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="#" + > + Inside charity fundraiser where hostesses are put on show </a> </div> </div> </div> `; -exports[`@financial-times/x-teaser renders a Hero Content Package x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a Hero teaser with contentPackage data 1`] = ` <div className="o-teaser o-teaser--package o-teaser--hero o-teaser--centre o-teaser--has-image js-teaser" data-id="" @@ -152,17 +166,14 @@ exports[`@financial-times/x-teaser renders a Hero Content Package x-teaser 1`] = <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: FT Magazine" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - FT Magazine - </a> - </div> + FT Magazine + </a> </div> <div className="o-teaser__heading" @@ -173,39 +184,38 @@ exports[`@financial-times/x-teaser renders a Hero Content Package x-teaser 1`] = href="#" > The royal wedding - </a> </div> </div> <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F7e97f5b6-578d-11e8-b8b2-d6ceb45fa9d0?source=next&fit=scale-down&compression=best&width=340" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F7e97f5b6-578d-11e8-b8b2-d6ceb45fa9d0?source=next&fit=scale-down&dpr=2&width=340" /> - </a> - </div> + </div> + </a> </div> </div> `; -exports[`@financial-times/x-teaser renders a Hero Opinion Piece x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a Hero teaser with opinion data 1`] = ` <div className="o-teaser o-teaser--article o-teaser--hero o-teaser--has-headshot o-teaser--opinion js-teaser" data-id="" @@ -216,17 +226,14 @@ exports[`@financial-times/x-teaser renders a Hero Opinion Piece x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Gideon Rachman" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Gideon Rachman - </a> - </div> + Gideon Rachman + </a> </div> <div className="o-teaser__heading" @@ -237,34 +244,21 @@ exports[`@financial-times/x-teaser renders a Hero Opinion Piece x-teaser 1`] = ` href="#" > Anti-Semitism and the threat of identity politics - </a> </div> - <p - className="o-teaser__standfirst" - > - <a - className="js-teaser-standfirst-link" - data-trackable="standfirst-link" - href="#" - tabIndex={-1} - > - Today, hatred of Jews is mixed in with fights about Islam and Israel - </a> - </p> <img alt="" aria-hidden="true" className="o-teaser__headshot" height={75} - src="https://www.ft.com/__origami/service/image/v2/images/raw/fthead-v1%3Agideon-rachman?source=next&fit=scale-down&compression=best&width=75&tint=054593,d6d5d3&dpr=2" + src="https://www.ft.com/__origami/service/image/v2/images/raw/fthead-v1%3Agideon-rachman?source=next&fit=scale-down&dpr=2&tint=054593%2Cd6d5d3&width=75" width={75} /> </div> </div> `; -exports[`@financial-times/x-teaser renders a Hero Package item x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a Hero teaser with packageItem data 1`] = ` <div className="o-teaser o-teaser--article o-teaser--hero o-teaser--centre o-teaser--has-image o-teaser--opinion js-teaser" data-id="" @@ -275,22 +269,19 @@ exports[`@financial-times/x-teaser renders a Hero Package item x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <span + className="o-teaser__tag-prefix" > - <span - className="o-teaser__tag-prefix" - > - FT Series - </span> - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Financial crisis: Are we safer now? - </a> - </div> + FT Series + </span> + <a + aria-label="Category: Financial crisis: Are we safer now? " + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" + > + Financial crisis: Are we safer now? + </a> </div> <div className="o-teaser__heading" @@ -301,42 +292,41 @@ exports[`@financial-times/x-teaser renders a Hero Package item x-teaser 1`] = ` href="#" > Why so little has changed since the crash - </a> </div> </div> <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&compression=best&width=340" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&dpr=2&width=340" /> - </a> - </div> + </div> + </a> </div> </div> `; -exports[`@financial-times/x-teaser renders a Hero Paid Post x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a Hero teaser with podcast data 1`] = ` <div - className="o-teaser o-teaser--paid-post o-teaser--hero o-teaser--has-image js-teaser" - data-id="" + className="o-teaser o-teaser--audio o-teaser--hero o-teaser--has-image js-teaser" + data-id="d1246074-f7d3-4aaf-951c-80a6db495765" > <div className="o-teaser__content" @@ -344,78 +334,65 @@ exports[`@financial-times/x-teaser renders a Hero Paid Post x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-promoted" + <a + aria-label="Category: Tech Tonic podcast" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <span - className="o-teaser__promoted-prefix" - > - Paid post - </span> - <span - className="o-teaser__promoted-by" - > - by UBS - </span> - </div> + Tech Tonic podcast + </a> + <span + className="o-teaser__tag-suffix" + > + 12 mins + </span> </div> <div className="o-teaser__heading" > <a + aria-label="Listen to podcast Who sets the internet standards?" className="js-teaser-heading-link" data-trackable="heading-link" - href="#" + href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" > - Why eSports companies are on a winning streak - + Who sets the internet standards? </a> </div> - <p - className="o-teaser__standfirst" - > - <a - className="js-teaser-standfirst-link" - data-trackable="standfirst-link" - href="#" - tabIndex={-1} - > - ESports is big business and about to get bigger: global revenues could hit $1.5bn by 2020 - </a> - </p> </div> <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2857%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Ftpc.googlesyndication.com%2Fpagead%2Fimgad%3Fid%3DCICAgKCrm_3yahABGAEyCMx3RoLss603?source=next&fit=scale-down&compression=best&width=340" + src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Fwww.ft.com%2F__origami%2Fservice%2Fimage%2Fv2%2Fimages%2Fraw%2Fhttp%253A%252F%252Fprod-upp-image-read.ft.com%252F5d1a54aa-f49b-11e8-ae55-df4bf40f9d0d%3Fsource%3Dnext%26fit%3Dscale-down%26compression%3Dbest%26width%3D240?source=next&fit=scale-down&dpr=2&width=340" /> - </a> - </div> + </div> + </a> </div> </div> `; -exports[`@financial-times/x-teaser renders a Hero Podcast x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a Hero teaser with promoted data 1`] = ` <div - className="o-teaser o-teaser--audio o-teaser--hero o-teaser--has-image js-teaser" - data-id="d1246074-f7d3-4aaf-951c-80a6db495765" + className="o-teaser o-teaser--paid-post o-teaser--hero o-teaser--has-image js-teaser" + data-id="" > <div className="o-teaser__content" @@ -423,22 +400,17 @@ exports[`@financial-times/x-teaser renders a Hero Podcast x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <span + className="o-teaser__promoted-prefix" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Tech Tonic podcast - </a> - <span - className="o-teaser__tag-suffix" - > - 12 mins - </span> - </div> + Paid post + </span> + by + <span + className="o-teaser__promoted-by" + > + UBS + </span> </div> <div className="o-teaser__heading" @@ -446,57 +418,45 @@ exports[`@financial-times/x-teaser renders a Hero Podcast x-teaser 1`] = ` <a className="js-teaser-heading-link" data-trackable="heading-link" - href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" + href="#" > - Who sets the internet standards? - + Why eSports companies are on a winning streak </a> </div> - <p - className="o-teaser__standfirst" - > - <a - className="js-teaser-standfirst-link" - data-trackable="standfirst-link" - href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" - tabIndex={-1} - > - Hannah Kuchler talks to American social scientist and cyber security expert Andrea… - </a> - </p> </div> <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2857%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Fwww.ft.com%2F__origami%2Fservice%2Fimage%2Fv2%2Fimages%2Fraw%2Fhttp%253A%252F%252Fprod-upp-image-read.ft.com%252F5d1a54aa-f49b-11e8-ae55-df4bf40f9d0d%3Fsource%3Dnext%26fit%3Dscale-down%26compression%3Dbest%26width%3D240?source=next&fit=scale-down&compression=best&width=340" + src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Ftpc.googlesyndication.com%2Fpagead%2Fimgad%3Fid%3DCICAgKCrm_3yahABGAEyCMx3RoLss603?source=next&fit=scale-down&dpr=2&width=340" /> - </a> - </div> + </div> + </a> </div> </div> `; -exports[`@financial-times/x-teaser renders a Hero Top Story x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a Hero teaser with topStory data 1`] = ` <div - className="o-teaser o-teaser--article o-teaser--hero o-teaser--landscape o-teaser--has-image js-teaser" + className="o-teaser o-teaser--article o-teaser--hero o-teaser--has-image js-teaser" data-id="" + data-trackable="slice:hero-slice-1;slicePos:1;layout:standaloneimage;listPos:1" > <div className="o-teaser__content" @@ -504,17 +464,14 @@ exports[`@financial-times/x-teaser renders a Hero Top Story x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Sexual misconduct allegations - </a> - </div> + Sexual misconduct allegations + </a> </div> <div className="o-teaser__heading" @@ -525,91 +482,107 @@ exports[`@financial-times/x-teaser renders a Hero Top Story x-teaser 1`] = ` href="#" > Inside charity fundraiser where hostesses are put on show - </a> </div> - <p - className="o-teaser__standfirst" - > - <a - className="js-teaser-standfirst-link" - data-trackable="standfirst-link" - href="#" - tabIndex={-1} - > - FT investigation finds groping and sexual harassment at secretive black-tie dinner - </a> - </p> </div> <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&compression=best&width=340" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&dpr=2&width=340" /> - </a> - </div> + </div> + </a> </div> - <ul - className="o-teaser__related" - > - <li - className="o-teaser__related-item o-teaser__related-item--article" - data-content-id="" +</div> +`; + +exports[`x-teaser / snapshots renders a Hero teaser with video data 1`] = ` +<div + className="o-teaser o-teaser--video o-teaser--hero o-teaser--has-image js-teaser" + data-id="0e89d872-5711-457b-80b1-4ca0d8afea46" +> + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" > <a - data-trackable="related" + aria-label="Category: Global Trade" + className="o-teaser__tag" + data-trackable="teaser-tag" href="#" > - Removing the fig leaf of charity + Global Trade </a> - </li> - <li - className="o-teaser__related-item o-teaser__related-item--article" - data-content-id="" + <span + className="o-teaser__tag-suffix" + > + 02:51min + </span> + </div> + <div + className="o-teaser__heading" > <a - data-trackable="related" + aria-label="Watch video FT View: Donald Trump, man of steel" + className="js-teaser-heading-link" + data-trackable="heading-link" href="#" > - A dinner that demeaned both women and men + FT View: Donald Trump, man of steel </a> - </li> - <li - className="o-teaser__related-item o-teaser__related-item--video" - data-content-id="" + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - data-trackable="related" - href="#" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > - PM speaks out after Presidents Club dinner - </a> - </li> - </ul> + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fcom.ft.imagepublish.upp-prod-eu.s3.amazonaws.com%2Fa27ce49b-85b8-445b-b883-db6e2f533194?source=next&fit=scale-down&dpr=2&width=340" + /> + </div> + </a> + </div> </div> `; -exports[`@financial-times/x-teaser renders a Hero Video x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a HeroNarrow teaser with article data 1`] = ` <div - className="o-teaser o-teaser--video o-teaser--hero o-teaser--has-video js-teaser" - data-id="0e89d872-5711-457b-80b1-4ca0d8afea46" + className="o-teaser o-teaser--article o-teaser--hero o-teaser--highlight js-teaser" + data-id="" > <div className="o-teaser__content" @@ -617,74 +590,14 @@ exports[`@financial-times/x-teaser renders a Hero Video x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" - > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Global Trade - </a> - <span - className="o-teaser__tag-suffix" - > - 02:51min - </span> - </div> - </div> - <div - className="o-teaser__video" - > - <div - className="o--if-js" - > - <div - className="o-teaser__image-container js-image-container" - > - <div - className="o-video" - data-o-component="o-video" - data-o-video-autorender="true" - data-o-video-data="{\\"renditions\\":[{\\"url\\":\\"https://next-media-api.ft.com/renditions/15218247321960/640x360.mp4\\",\\"width\\":640,\\"height\\":360,\\"mediaType\\":\\"video/mp4\\",\\"codec\\":\\"h264\\"}],\\"mainImageUrl\\":\\"http://com.ft.imagepublish.upp-prod-eu.s3.amazonaws.com/a27ce49b-85b8-445b-b883-db6e2f533194\\"}" - data-o-video-id="0e89d872-5711-457b-80b1-4ca0d8afea46" - data-o-video-placeholder="true" - data-o-video-placeholder-hint="Play video" - data-o-video-placeholder-info="[]" - data-o-video-playsinline="true" - /> - </div> - </div> - <div - className="o--if-no-js" + <a + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <div - className="o-teaser__image-container js-teaser-image-container" - > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } - > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" - > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fcom.ft.imagepublish.upp-prod-eu.s3.amazonaws.com%2Fa27ce49b-85b8-445b-b883-db6e2f533194?source=next&fit=scale-down&compression=best&width=340" - /> - </a> - </div> - </div> - </div> + Sexual misconduct allegations + </a> </div> <div className="o-teaser__heading" @@ -694,17 +607,28 @@ exports[`@financial-times/x-teaser renders a Hero Video x-teaser 1`] = ` data-trackable="heading-link" href="#" > - FT View: Donald Trump, man of steel - + Inside charity fundraiser where hostesses are put on show </a> </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="#" + tabIndex={-1} + > + FT investigation finds groping and sexual harassment at secretive black-tie dinner + </a> + </p> </div> </div> `; -exports[`@financial-times/x-teaser renders a HeroNarrow Article x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a HeroNarrow teaser with article-with-data-image data 1`] = ` <div - className="o-teaser o-teaser--article o-teaser--hero o-teaser--has-image o-teaser--highlight js-teaser" + className="o-teaser o-teaser--article o-teaser--hero o-teaser--highlight js-teaser" data-id="" > <div @@ -713,17 +637,14 @@ exports[`@financial-times/x-teaser renders a HeroNarrow Article x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Sexual misconduct allegations - </a> - </div> + Sexual misconduct allegations + </a> </div> <div className="o-teaser__heading" @@ -734,7 +655,6 @@ exports[`@financial-times/x-teaser renders a HeroNarrow Article x-teaser 1`] = ` href="#" > Inside charity fundraiser where hostesses are put on show - </a> </div> <p @@ -750,37 +670,59 @@ exports[`@financial-times/x-teaser renders a HeroNarrow Article x-teaser 1`] = ` </a> </p> </div> +</div> +`; + +exports[`x-teaser / snapshots renders a HeroNarrow teaser with article-with-missing-image-url data 1`] = ` +<div + className="o-teaser o-teaser--article o-teaser--hero o-teaser--highlight js-teaser" + data-id="" +> <div - className="o-teaser__image-container js-teaser-image-container" + className="o-teaser__content" > <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + className="o-teaser__meta" > <a - aria-hidden="true" - data-trackable="image-link" + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" href="#" - tab-index="-1" > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&compression=best&width=240" - /> + Sexual misconduct allegations + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="#" + > + Inside charity fundraiser where hostesses are put on show </a> </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="#" + tabIndex={-1} + > + FT investigation finds groping and sexual harassment at secretive black-tie dinner + </a> + </p> </div> </div> `; -exports[`@financial-times/x-teaser renders a HeroNarrow Content Package x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a HeroNarrow teaser with contentPackage data 1`] = ` <div - className="o-teaser o-teaser--package o-teaser--hero o-teaser--centre o-teaser--has-image js-teaser" + className="o-teaser o-teaser--package o-teaser--hero o-teaser--centre js-teaser" data-id="" > <div @@ -789,17 +731,14 @@ exports[`@financial-times/x-teaser renders a HeroNarrow Content Package x-teaser <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: FT Magazine" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - FT Magazine - </a> - </div> + FT Magazine + </a> </div> <div className="o-teaser__heading" @@ -810,7 +749,6 @@ exports[`@financial-times/x-teaser renders a HeroNarrow Content Package x-teaser href="#" > The royal wedding - </a> </div> <p @@ -826,35 +764,10 @@ exports[`@financial-times/x-teaser renders a HeroNarrow Content Package x-teaser </a> </p> </div> - <div - className="o-teaser__image-container js-teaser-image-container" - > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } - > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" - > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F7e97f5b6-578d-11e8-b8b2-d6ceb45fa9d0?source=next&fit=scale-down&compression=best&width=340" - /> - </a> - </div> - </div> </div> `; -exports[`@financial-times/x-teaser renders a HeroNarrow Opinion Piece x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a HeroNarrow teaser with opinion data 1`] = ` <div className="o-teaser o-teaser--article o-teaser--hero o-teaser--has-headshot o-teaser--opinion js-teaser" data-id="" @@ -865,17 +778,14 @@ exports[`@financial-times/x-teaser renders a HeroNarrow Opinion Piece x-teaser 1 <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Gideon Rachman" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Gideon Rachman - </a> - </div> + Gideon Rachman + </a> </div> <div className="o-teaser__heading" @@ -886,7 +796,6 @@ exports[`@financial-times/x-teaser renders a HeroNarrow Opinion Piece x-teaser 1 href="#" > Anti-Semitism and the threat of identity politics - </a> </div> <p @@ -906,16 +815,16 @@ exports[`@financial-times/x-teaser renders a HeroNarrow Opinion Piece x-teaser 1 aria-hidden="true" className="o-teaser__headshot" height={75} - src="https://www.ft.com/__origami/service/image/v2/images/raw/fthead-v1%3Agideon-rachman?source=next&fit=scale-down&compression=best&width=75&tint=054593,d6d5d3&dpr=2" + src="https://www.ft.com/__origami/service/image/v2/images/raw/fthead-v1%3Agideon-rachman?source=next&fit=scale-down&dpr=2&tint=054593%2Cd6d5d3&width=75" width={75} /> </div> </div> `; -exports[`@financial-times/x-teaser renders a HeroNarrow Package item x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a HeroNarrow teaser with packageItem data 1`] = ` <div - className="o-teaser o-teaser--article o-teaser--hero o-teaser--centre o-teaser--has-image o-teaser--opinion js-teaser" + className="o-teaser o-teaser--article o-teaser--hero o-teaser--centre o-teaser--opinion js-teaser" data-id="" > <div @@ -924,22 +833,19 @@ exports[`@financial-times/x-teaser renders a HeroNarrow Package item x-teaser 1` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <span + className="o-teaser__tag-prefix" > - <span - className="o-teaser__tag-prefix" - > - FT Series - </span> - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Financial crisis: Are we safer now? - </a> - </div> + FT Series + </span> + <a + aria-label="Category: Financial crisis: Are we safer now? " + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" + > + Financial crisis: Are we safer now? + </a> </div> <div className="o-teaser__heading" @@ -950,7 +856,6 @@ exports[`@financial-times/x-teaser renders a HeroNarrow Package item x-teaser 1` href="#" > Why so little has changed since the crash - </a> </div> <p @@ -966,37 +871,65 @@ exports[`@financial-times/x-teaser renders a HeroNarrow Package item x-teaser 1` </a> </p> </div> +</div> +`; + +exports[`x-teaser / snapshots renders a HeroNarrow teaser with podcast data 1`] = ` +<div + className="o-teaser o-teaser--audio o-teaser--hero js-teaser" + data-id="d1246074-f7d3-4aaf-951c-80a6db495765" +> <div - className="o-teaser__image-container js-teaser-image-container" + className="o-teaser__content" > <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + className="o-teaser__meta" > <a - aria-hidden="true" - data-trackable="image-link" + aria-label="Category: Tech Tonic podcast" + className="o-teaser__tag" + data-trackable="teaser-tag" href="#" - tab-index="-1" > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&compression=best&width=340" - /> + Tech Tonic podcast + </a> + <span + className="o-teaser__tag-suffix" + > + 12 mins + </span> + </div> + <div + className="o-teaser__heading" + > + <a + aria-label="Listen to podcast Who sets the internet standards?" + className="js-teaser-heading-link" + data-trackable="heading-link" + href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" + > + Who sets the internet standards? </a> </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" + tabIndex={-1} + > + Hannah Kuchler talks to American social scientist and cyber security expert Andrea… + </a> + </p> </div> </div> `; -exports[`@financial-times/x-teaser renders a HeroNarrow Paid Post x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a HeroNarrow teaser with promoted data 1`] = ` <div - className="o-teaser o-teaser--paid-post o-teaser--hero o-teaser--has-image js-teaser" + className="o-teaser o-teaser--paid-post o-teaser--hero js-teaser" data-id="" > <div @@ -1005,20 +938,17 @@ exports[`@financial-times/x-teaser renders a HeroNarrow Paid Post x-teaser 1`] = <div className="o-teaser__meta" > - <div - className="o-teaser__meta-promoted" + <span + className="o-teaser__promoted-prefix" > - <span - className="o-teaser__promoted-prefix" - > - Paid post - </span> - <span - className="o-teaser__promoted-by" - > - by UBS - </span> - </div> + Paid post + </span> + by + <span + className="o-teaser__promoted-by" + > + UBS + </span> </div> <div className="o-teaser__heading" @@ -1029,7 +959,6 @@ exports[`@financial-times/x-teaser renders a HeroNarrow Paid Post x-teaser 1`] = href="#" > Why eSports companies are on a winning streak - </a> </div> <p @@ -1045,72 +974,39 @@ exports[`@financial-times/x-teaser renders a HeroNarrow Paid Post x-teaser 1`] = </a> </p> </div> +</div> +`; + +exports[`x-teaser / snapshots renders a HeroNarrow teaser with topStory data 1`] = ` +<div + className="o-teaser o-teaser--article o-teaser--hero js-teaser" + data-id="" + data-trackable="slice:hero-slice-1;slicePos:1;layout:standaloneimage;listPos:1" +> <div - className="o-teaser__image-container js-teaser-image-container" + className="o-teaser__content" > <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2857%", - } - } + className="o-teaser__meta" > <a - aria-hidden="true" - data-trackable="image-link" + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" href="#" - tab-index="-1" > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Ftpc.googlesyndication.com%2Fpagead%2Fimgad%3Fid%3DCICAgKCrm_3yahABGAEyCMx3RoLss603?source=next&fit=scale-down&compression=best&width=240" - /> + Sexual misconduct allegations </a> </div> - </div> -</div> -`; - -exports[`@financial-times/x-teaser renders a HeroNarrow Podcast x-teaser 1`] = ` -<div - className="o-teaser o-teaser--audio o-teaser--hero o-teaser--has-image js-teaser" - data-id="d1246074-f7d3-4aaf-951c-80a6db495765" -> - <div - className="o-teaser__content" - > - <div - className="o-teaser__meta" - > - <div - className="o-teaser__meta-tag" - > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Tech Tonic podcast - </a> - <span - className="o-teaser__tag-suffix" - > - 12 mins - </span> - </div> - </div> <div className="o-teaser__heading" > <a className="js-teaser-heading-link" data-trackable="heading-link" - href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" + href="#" > - Who sets the internet standards? - + Inside charity fundraiser where hostesses are put on show </a> </div> <p @@ -1119,45 +1015,20 @@ exports[`@financial-times/x-teaser renders a HeroNarrow Podcast x-teaser 1`] = ` <a className="js-teaser-standfirst-link" data-trackable="standfirst-link" - href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" + href="#" tabIndex={-1} > - Hannah Kuchler talks to American social scientist and cyber security expert Andrea… + FT investigation finds groping and sexual harassment at secretive black-tie dinner </a> </p> </div> - <div - className="o-teaser__image-container js-teaser-image-container" - > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } - > - <a - aria-hidden="true" - data-trackable="image-link" - href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" - tab-index="-1" - > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Fwww.ft.com%2F__origami%2Fservice%2Fimage%2Fv2%2Fimages%2Fraw%2Fhttp%253A%252F%252Fprod-upp-image-read.ft.com%252F5d1a54aa-f49b-11e8-ae55-df4bf40f9d0d%3Fsource%3Dnext%26fit%3Dscale-down%26compression%3Dbest%26width%3D240?source=next&fit=scale-down&compression=best&width=240" - /> - </a> - </div> - </div> </div> `; -exports[`@financial-times/x-teaser renders a HeroNarrow Top Story x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a HeroNarrow teaser with video data 1`] = ` <div - className="o-teaser o-teaser--article o-teaser--hero o-teaser--landscape o-teaser--has-image js-teaser" - data-id="" + className="o-teaser o-teaser--video o-teaser--hero js-teaser" + data-id="0e89d872-5711-457b-80b1-4ca0d8afea46" > <div className="o-teaser__content" @@ -1165,28 +1036,30 @@ exports[`@financial-times/x-teaser renders a HeroNarrow Top Story x-teaser 1`] = <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Global Trade" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Sexual misconduct allegations - </a> - </div> + Global Trade + </a> + <span + className="o-teaser__tag-suffix" + > + 02:51min + </span> </div> <div className="o-teaser__heading" > <a + aria-label="Watch video FT View: Donald Trump, man of steel" className="js-teaser-heading-link" data-trackable="heading-link" href="#" > - Inside charity fundraiser where hostesses are put on show - + FT View: Donald Trump, man of steel </a> </div> <p @@ -1198,79 +1071,77 @@ exports[`@financial-times/x-teaser renders a HeroNarrow Top Story x-teaser 1`] = href="#" tabIndex={-1} > - FT investigation finds groping and sexual harassment at secretive black-tie dinner + The FT's Rob Armstrong looks at why Donald Trump is pushing trade tariffs </a> </p> </div> +</div> +`; + +exports[`x-teaser / snapshots renders a HeroOverlay teaser with article data 1`] = ` +<div + className="o-teaser o-teaser--article o-teaser--hero o-teaser--hero-image o-teaser--has-image o-teaser--highlight js-teaser" + data-id="" +> <div - className="o-teaser__image-container js-teaser-image-container" + className="o-teaser__content" > <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + className="o-teaser__meta" > <a - aria-hidden="true" - data-trackable="image-link" + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" href="#" - tab-index="-1" > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&compression=best&width=640" - /> + Sexual misconduct allegations </a> </div> - </div> - <ul - className="o-teaser__related" - > - <li - className="o-teaser__related-item o-teaser__related-item--article" - data-content-id="" - > - <a - data-trackable="related" - href="#" - > - Removing the fig leaf of charity - </a> - </li> - <li - className="o-teaser__related-item o-teaser__related-item--article" - data-content-id="" + <div + className="o-teaser__heading" > <a - data-trackable="related" + className="js-teaser-heading-link" + data-trackable="heading-link" href="#" > - A dinner that demeaned both women and men + Inside charity fundraiser where hostesses are put on show </a> - </li> - <li - className="o-teaser__related-item o-teaser__related-item--video" - data-content-id="" + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - data-trackable="related" - href="#" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > - PM speaks out after Presidents Club dinner - </a> - </li> - </ul> + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&dpr=2&width=640" + /> + </div> + </a> + </div> </div> `; -exports[`@financial-times/x-teaser renders a HeroNarrow Video x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a HeroOverlay teaser with article-with-data-image data 1`] = ` <div - className="o-teaser o-teaser--video o-teaser--hero o-teaser--has-video js-teaser" - data-id="0e89d872-5711-457b-80b1-4ca0d8afea46" + className="o-teaser o-teaser--article o-teaser--hero o-teaser--hero-image o-teaser--has-image o-teaser--highlight js-teaser" + data-id="" > <div className="o-teaser__content" @@ -1278,74 +1149,14 @@ exports[`@financial-times/x-teaser renders a HeroNarrow Video x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" - > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Global Trade - </a> - <span - className="o-teaser__tag-suffix" - > - 02:51min - </span> - </div> - </div> - <div - className="o-teaser__video" - > - <div - className="o--if-js" - > - <div - className="o-teaser__image-container js-image-container" - > - <div - className="o-video" - data-o-component="o-video" - data-o-video-autorender="true" - data-o-video-data="{\\"renditions\\":[{\\"url\\":\\"https://next-media-api.ft.com/renditions/15218247321960/640x360.mp4\\",\\"width\\":640,\\"height\\":360,\\"mediaType\\":\\"video/mp4\\",\\"codec\\":\\"h264\\"}],\\"mainImageUrl\\":\\"http://com.ft.imagepublish.upp-prod-eu.s3.amazonaws.com/a27ce49b-85b8-445b-b883-db6e2f533194\\"}" - data-o-video-id="0e89d872-5711-457b-80b1-4ca0d8afea46" - data-o-video-placeholder="true" - data-o-video-placeholder-hint="Play video" - data-o-video-placeholder-info="[]" - data-o-video-playsinline="true" - /> - </div> - </div> - <div - className="o--if-no-js" + <a + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <div - className="o-teaser__image-container js-teaser-image-container" - > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } - > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" - > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fcom.ft.imagepublish.upp-prod-eu.s3.amazonaws.com%2Fa27ce49b-85b8-445b-b883-db6e2f533194?source=next&fit=scale-down&compression=best&width=420" - /> - </a> - </div> - </div> - </div> + Sexual misconduct allegations + </a> </div> <div className="o-teaser__heading" @@ -1355,29 +1166,41 @@ exports[`@financial-times/x-teaser renders a HeroNarrow Video x-teaser 1`] = ` data-trackable="heading-link" href="#" > - FT View: Donald Trump, man of steel - + Inside charity fundraiser where hostesses are put on show </a> </div> - <p - className="o-teaser__standfirst" + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - className="js-teaser-standfirst-link" - data-trackable="standfirst-link" - href="#" - tabIndex={-1} + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > - The FT's Rob Armstrong looks at why Donald Trump is pushing trade tariffs - </a> - </p> + <img + alt="" + className="o-teaser__image" + src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==" + /> + </div> + </a> </div> </div> `; -exports[`@financial-times/x-teaser renders a HeroOverlay Article x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a HeroOverlay teaser with article-with-missing-image-url data 1`] = ` <div - className="o-teaser o-teaser--article o-teaser--hero o-teaser--hero-image o-teaser--has-image o-teaser--highlight js-teaser" + className="o-teaser o-teaser--article o-teaser--hero o-teaser--hero-image o-teaser--highlight js-teaser" data-id="" > <div @@ -1386,17 +1209,14 @@ exports[`@financial-times/x-teaser renders a HeroOverlay Article x-teaser 1`] = <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Sexual misconduct allegations - </a> - </div> + Sexual misconduct allegations + </a> </div> <div className="o-teaser__heading" @@ -1407,51 +1227,13 @@ exports[`@financial-times/x-teaser renders a HeroOverlay Article x-teaser 1`] = href="#" > Inside charity fundraiser where hostesses are put on show - - </a> - </div> - <p - className="o-teaser__standfirst" - > - <a - className="js-teaser-standfirst-link" - data-trackable="standfirst-link" - href="#" - tabIndex={-1} - > - FT investigation finds groping and sexual harassment at secretive black-tie dinner - </a> - </p> - </div> - <div - className="o-teaser__image-container js-teaser-image-container" - > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } - > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" - > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&compression=best&width=640" - /> </a> </div> </div> </div> `; -exports[`@financial-times/x-teaser renders a HeroOverlay Content Package x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a HeroOverlay teaser with contentPackage data 1`] = ` <div className="o-teaser o-teaser--package o-teaser--hero o-teaser--hero-image o-teaser--has-image js-teaser" data-id="" @@ -1462,17 +1244,14 @@ exports[`@financial-times/x-teaser renders a HeroOverlay Content Package x-tease <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: FT Magazine" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - FT Magazine - </a> - </div> + FT Magazine + </a> </div> <div className="o-teaser__heading" @@ -1483,39 +1262,38 @@ exports[`@financial-times/x-teaser renders a HeroOverlay Content Package x-tease href="#" > The royal wedding - </a> </div> </div> <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F7e97f5b6-578d-11e8-b8b2-d6ceb45fa9d0?source=next&fit=scale-down&compression=best&width=640" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F7e97f5b6-578d-11e8-b8b2-d6ceb45fa9d0?source=next&fit=scale-down&dpr=2&width=640" /> - </a> - </div> + </div> + </a> </div> </div> `; -exports[`@financial-times/x-teaser renders a HeroOverlay Opinion Piece x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a HeroOverlay teaser with opinion data 1`] = ` <div className="o-teaser o-teaser--article o-teaser--hero o-teaser--hero-image o-teaser--has-headshot o-teaser--opinion js-teaser" data-id="" @@ -1526,17 +1304,14 @@ exports[`@financial-times/x-teaser renders a HeroOverlay Opinion Piece x-teaser <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Gideon Rachman" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Gideon Rachman - </a> - </div> + Gideon Rachman + </a> </div> <div className="o-teaser__heading" @@ -1547,34 +1322,21 @@ exports[`@financial-times/x-teaser renders a HeroOverlay Opinion Piece x-teaser href="#" > Anti-Semitism and the threat of identity politics - </a> </div> - <p - className="o-teaser__standfirst" - > - <a - className="js-teaser-standfirst-link" - data-trackable="standfirst-link" - href="#" - tabIndex={-1} - > - Today, hatred of Jews is mixed in with fights about Islam and Israel - </a> - </p> <img alt="" aria-hidden="true" className="o-teaser__headshot" height={75} - src="https://www.ft.com/__origami/service/image/v2/images/raw/fthead-v1%3Agideon-rachman?source=next&fit=scale-down&compression=best&width=75&tint=054593,d6d5d3&dpr=2" + src="https://www.ft.com/__origami/service/image/v2/images/raw/fthead-v1%3Agideon-rachman?source=next&fit=scale-down&dpr=2&tint=054593%2Cd6d5d3&width=75" width={75} /> </div> </div> `; -exports[`@financial-times/x-teaser renders a HeroOverlay Package item x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a HeroOverlay teaser with packageItem data 1`] = ` <div className="o-teaser o-teaser--article o-teaser--hero o-teaser--hero-image o-teaser--has-image o-teaser--opinion js-teaser" data-id="" @@ -1585,22 +1347,19 @@ exports[`@financial-times/x-teaser renders a HeroOverlay Package item x-teaser 1 <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <span + className="o-teaser__tag-prefix" > - <span - className="o-teaser__tag-prefix" - > - FT Series - </span> - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Financial crisis: Are we safer now? - </a> - </div> + FT Series + </span> + <a + aria-label="Category: Financial crisis: Are we safer now? " + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" + > + Financial crisis: Are we safer now? + </a> </div> <div className="o-teaser__heading" @@ -1611,42 +1370,41 @@ exports[`@financial-times/x-teaser renders a HeroOverlay Package item x-teaser 1 href="#" > Why so little has changed since the crash - </a> </div> </div> <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&compression=best&width=640" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&dpr=2&width=640" /> - </a> - </div> + </div> + </a> </div> </div> `; -exports[`@financial-times/x-teaser renders a HeroOverlay Paid Post x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a HeroOverlay teaser with podcast data 1`] = ` <div - className="o-teaser o-teaser--paid-post o-teaser--hero o-teaser--hero-image o-teaser--has-image js-teaser" - data-id="" + className="o-teaser o-teaser--audio o-teaser--hero o-teaser--hero-image o-teaser--has-image js-teaser" + data-id="d1246074-f7d3-4aaf-951c-80a6db495765" > <div className="o-teaser__content" @@ -1654,78 +1412,65 @@ exports[`@financial-times/x-teaser renders a HeroOverlay Paid Post x-teaser 1`] <div className="o-teaser__meta" > - <div - className="o-teaser__meta-promoted" + <a + aria-label="Category: Tech Tonic podcast" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <span - className="o-teaser__promoted-prefix" - > - Paid post - </span> - <span - className="o-teaser__promoted-by" - > - by UBS - </span> - </div> + Tech Tonic podcast + </a> + <span + className="o-teaser__tag-suffix" + > + 12 mins + </span> </div> <div className="o-teaser__heading" > <a + aria-label="Listen to podcast Who sets the internet standards?" className="js-teaser-heading-link" data-trackable="heading-link" - href="#" + href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" > - Why eSports companies are on a winning streak - + Who sets the internet standards? </a> </div> - <p - className="o-teaser__standfirst" - > - <a - className="js-teaser-standfirst-link" - data-trackable="standfirst-link" - href="#" - tabIndex={-1} - > - ESports is big business and about to get bigger: global revenues could hit $1.5bn by 2020 - </a> - </p> </div> <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2857%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Ftpc.googlesyndication.com%2Fpagead%2Fimgad%3Fid%3DCICAgKCrm_3yahABGAEyCMx3RoLss603?source=next&fit=scale-down&compression=best&width=640" + src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Fwww.ft.com%2F__origami%2Fservice%2Fimage%2Fv2%2Fimages%2Fraw%2Fhttp%253A%252F%252Fprod-upp-image-read.ft.com%252F5d1a54aa-f49b-11e8-ae55-df4bf40f9d0d%3Fsource%3Dnext%26fit%3Dscale-down%26compression%3Dbest%26width%3D240?source=next&fit=scale-down&dpr=2&width=640" /> - </a> - </div> + </div> + </a> </div> </div> `; -exports[`@financial-times/x-teaser renders a HeroOverlay Podcast x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a HeroOverlay teaser with promoted data 1`] = ` <div - className="o-teaser o-teaser--audio o-teaser--hero o-teaser--hero-image o-teaser--has-image js-teaser" - data-id="d1246074-f7d3-4aaf-951c-80a6db495765" + className="o-teaser o-teaser--paid-post o-teaser--hero o-teaser--hero-image o-teaser--has-image js-teaser" + data-id="" > <div className="o-teaser__content" @@ -1733,22 +1478,17 @@ exports[`@financial-times/x-teaser renders a HeroOverlay Podcast x-teaser 1`] = <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <span + className="o-teaser__promoted-prefix" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Tech Tonic podcast - </a> - <span - className="o-teaser__tag-suffix" - > - 12 mins - </span> - </div> + Paid post + </span> + by + <span + className="o-teaser__promoted-by" + > + UBS + </span> </div> <div className="o-teaser__heading" @@ -1756,57 +1496,45 @@ exports[`@financial-times/x-teaser renders a HeroOverlay Podcast x-teaser 1`] = <a className="js-teaser-heading-link" data-trackable="heading-link" - href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" + href="#" > - Who sets the internet standards? - + Why eSports companies are on a winning streak </a> </div> - <p - className="o-teaser__standfirst" - > - <a - className="js-teaser-standfirst-link" - data-trackable="standfirst-link" - href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" - tabIndex={-1} - > - Hannah Kuchler talks to American social scientist and cyber security expert Andrea… - </a> - </p> </div> <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2857%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Fwww.ft.com%2F__origami%2Fservice%2Fimage%2Fv2%2Fimages%2Fraw%2Fhttp%253A%252F%252Fprod-upp-image-read.ft.com%252F5d1a54aa-f49b-11e8-ae55-df4bf40f9d0d%3Fsource%3Dnext%26fit%3Dscale-down%26compression%3Dbest%26width%3D240?source=next&fit=scale-down&compression=best&width=640" + src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Ftpc.googlesyndication.com%2Fpagead%2Fimgad%3Fid%3DCICAgKCrm_3yahABGAEyCMx3RoLss603?source=next&fit=scale-down&dpr=2&width=640" /> - </a> - </div> + </div> + </a> </div> </div> `; -exports[`@financial-times/x-teaser renders a HeroOverlay Top Story x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a HeroOverlay teaser with topStory data 1`] = ` <div className="o-teaser o-teaser--article o-teaser--hero o-teaser--hero-image o-teaser--has-image js-teaser" data-id="" + data-trackable="slice:hero-slice-1;slicePos:1;layout:standaloneimage;listPos:1" > <div className="o-teaser__content" @@ -1814,17 +1542,14 @@ exports[`@financial-times/x-teaser renders a HeroOverlay Top Story x-teaser 1`] <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Sexual misconduct allegations - </a> - </div> + Sexual misconduct allegations + </a> </div> <div className="o-teaser__heading" @@ -1835,91 +1560,107 @@ exports[`@financial-times/x-teaser renders a HeroOverlay Top Story x-teaser 1`] href="#" > Inside charity fundraiser where hostesses are put on show - </a> </div> - <p - className="o-teaser__standfirst" - > - <a - className="js-teaser-standfirst-link" - data-trackable="standfirst-link" - href="#" - tabIndex={-1} - > - FT investigation finds groping and sexual harassment at secretive black-tie dinner - </a> - </p> </div> <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&compression=best&width=640" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&dpr=2&width=640" /> - </a> - </div> + </div> + </a> </div> - <ul - className="o-teaser__related" +</div> +`; + +exports[`x-teaser / snapshots renders a HeroOverlay teaser with video data 1`] = ` +<div + className="o-teaser o-teaser--video o-teaser--hero o-teaser--hero-image o-teaser--has-image js-teaser" + data-id="0e89d872-5711-457b-80b1-4ca0d8afea46" +> + <div + className="o-teaser__content" > - <li - className="o-teaser__related-item o-teaser__related-item--article" - data-content-id="" + <div + className="o-teaser__meta" > <a - data-trackable="related" + aria-label="Category: Global Trade" + className="o-teaser__tag" + data-trackable="teaser-tag" href="#" > - Removing the fig leaf of charity + Global Trade </a> - </li> - <li - className="o-teaser__related-item o-teaser__related-item--article" - data-content-id="" + <span + className="o-teaser__tag-suffix" + > + 02:51min + </span> + </div> + <div + className="o-teaser__heading" > <a - data-trackable="related" + aria-label="Watch video FT View: Donald Trump, man of steel" + className="js-teaser-heading-link" + data-trackable="heading-link" href="#" > - A dinner that demeaned both women and men + FT View: Donald Trump, man of steel </a> - </li> - <li - className="o-teaser__related-item o-teaser__related-item--video" - data-content-id="" + </div> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - data-trackable="related" - href="#" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > - PM speaks out after Presidents Club dinner - </a> - </li> - </ul> + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fcom.ft.imagepublish.upp-prod-eu.s3.amazonaws.com%2Fa27ce49b-85b8-445b-b883-db6e2f533194?source=next&fit=scale-down&dpr=2&width=640" + /> + </div> + </a> + </div> </div> `; -exports[`@financial-times/x-teaser renders a HeroOverlay Video x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a HeroVideo teaser with article data 1`] = ` <div - className="o-teaser o-teaser--video o-teaser--hero o-teaser--hero-image o-teaser--has-video js-teaser" - data-id="0e89d872-5711-457b-80b1-4ca0d8afea46" + className="o-teaser o-teaser--article o-teaser--hero o-teaser--highlight js-teaser" + data-id="" > <div className="o-teaser__content" @@ -1927,74 +1668,14 @@ exports[`@financial-times/x-teaser renders a HeroOverlay Video x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" - > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Global Trade - </a> - <span - className="o-teaser__tag-suffix" - > - 02:51min - </span> - </div> - </div> - <div - className="o-teaser__video" - > - <div - className="o--if-js" - > - <div - className="o-teaser__image-container js-image-container" - > - <div - className="o-video" - data-o-component="o-video" - data-o-video-autorender="true" - data-o-video-data="{\\"renditions\\":[{\\"url\\":\\"https://next-media-api.ft.com/renditions/15218247321960/640x360.mp4\\",\\"width\\":640,\\"height\\":360,\\"mediaType\\":\\"video/mp4\\",\\"codec\\":\\"h264\\"}],\\"mainImageUrl\\":\\"http://com.ft.imagepublish.upp-prod-eu.s3.amazonaws.com/a27ce49b-85b8-445b-b883-db6e2f533194\\"}" - data-o-video-id="0e89d872-5711-457b-80b1-4ca0d8afea46" - data-o-video-placeholder="true" - data-o-video-placeholder-hint="Play video" - data-o-video-placeholder-info="[]" - data-o-video-playsinline="true" - /> - </div> - </div> - <div - className="o--if-no-js" + <a + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <div - className="o-teaser__image-container js-teaser-image-container" - > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } - > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" - > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fcom.ft.imagepublish.upp-prod-eu.s3.amazonaws.com%2Fa27ce49b-85b8-445b-b883-db6e2f533194?source=next&fit=scale-down&compression=best&width=640" - /> - </a> - </div> - </div> - </div> + Sexual misconduct allegations + </a> </div> <div className="o-teaser__heading" @@ -2004,17 +1685,16 @@ exports[`@financial-times/x-teaser renders a HeroOverlay Video x-teaser 1`] = ` data-trackable="heading-link" href="#" > - FT View: Donald Trump, man of steel - + Inside charity fundraiser where hostesses are put on show </a> </div> </div> </div> `; -exports[`@financial-times/x-teaser renders a HeroVideo Article x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a HeroVideo teaser with article-with-data-image data 1`] = ` <div - className="o-teaser o-teaser--article o-teaser--hero o-teaser--has-image o-teaser--highlight js-teaser" + className="o-teaser o-teaser--article o-teaser--hero o-teaser--highlight js-teaser" data-id="" > <div @@ -2023,17 +1703,14 @@ exports[`@financial-times/x-teaser renders a HeroVideo Article x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Sexual misconduct allegations - </a> - </div> + Sexual misconduct allegations + </a> </div> <div className="o-teaser__heading" @@ -2044,53 +1721,50 @@ exports[`@financial-times/x-teaser renders a HeroVideo Article x-teaser 1`] = ` href="#" > Inside charity fundraiser where hostesses are put on show - </a> </div> - <p - className="o-teaser__standfirst" + </div> +</div> +`; + +exports[`x-teaser / snapshots renders a HeroVideo teaser with article-with-missing-image-url data 1`] = ` +<div + className="o-teaser o-teaser--article o-teaser--hero o-teaser--highlight js-teaser" + data-id="" +> + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" > <a - className="js-teaser-standfirst-link" - data-trackable="standfirst-link" + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" href="#" - tabIndex={-1} > - FT investigation finds groping and sexual harassment at secretive black-tie dinner + Sexual misconduct allegations </a> - </p> - </div> - <div - className="o-teaser__image-container js-teaser-image-container" - > + </div> <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + className="o-teaser__heading" > <a - aria-hidden="true" - data-trackable="image-link" + className="js-teaser-heading-link" + data-trackable="heading-link" href="#" - tab-index="-1" > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&compression=best&width=420" - /> + Inside charity fundraiser where hostesses are put on show </a> </div> </div> </div> `; -exports[`@financial-times/x-teaser renders a HeroVideo Content Package x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a HeroVideo teaser with contentPackage data 1`] = ` <div - className="o-teaser o-teaser--package o-teaser--hero o-teaser--centre o-teaser--has-image js-teaser" + className="o-teaser o-teaser--package o-teaser--hero o-teaser--centre js-teaser" data-id="" > <div @@ -2099,17 +1773,14 @@ exports[`@financial-times/x-teaser renders a HeroVideo Content Package x-teaser <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: FT Magazine" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - FT Magazine - </a> - </div> + FT Magazine + </a> </div> <div className="o-teaser__heading" @@ -2120,39 +1791,13 @@ exports[`@financial-times/x-teaser renders a HeroVideo Content Package x-teaser href="#" > The royal wedding - - </a> - </div> - </div> - <div - className="o-teaser__image-container js-teaser-image-container" - > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } - > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" - > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F7e97f5b6-578d-11e8-b8b2-d6ceb45fa9d0?source=next&fit=scale-down&compression=best&width=420" - /> </a> </div> </div> </div> `; -exports[`@financial-times/x-teaser renders a HeroVideo Opinion Piece x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a HeroVideo teaser with opinion data 1`] = ` <div className="o-teaser o-teaser--article o-teaser--hero o-teaser--has-headshot o-teaser--opinion js-teaser" data-id="" @@ -2163,17 +1808,14 @@ exports[`@financial-times/x-teaser renders a HeroVideo Opinion Piece x-teaser 1` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Gideon Rachman" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Gideon Rachman - </a> - </div> + Gideon Rachman + </a> </div> <div className="o-teaser__heading" @@ -2184,36 +1826,23 @@ exports[`@financial-times/x-teaser renders a HeroVideo Opinion Piece x-teaser 1` href="#" > Anti-Semitism and the threat of identity politics - </a> </div> - <p - className="o-teaser__standfirst" - > - <a - className="js-teaser-standfirst-link" - data-trackable="standfirst-link" - href="#" - tabIndex={-1} - > - Today, hatred of Jews is mixed in with fights about Islam and Israel - </a> - </p> <img alt="" aria-hidden="true" className="o-teaser__headshot" height={75} - src="https://www.ft.com/__origami/service/image/v2/images/raw/fthead-v1%3Agideon-rachman?source=next&fit=scale-down&compression=best&width=75&tint=054593,d6d5d3&dpr=2" + src="https://www.ft.com/__origami/service/image/v2/images/raw/fthead-v1%3Agideon-rachman?source=next&fit=scale-down&dpr=2&tint=054593%2Cd6d5d3&width=75" width={75} /> </div> </div> `; -exports[`@financial-times/x-teaser renders a HeroVideo Package item x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a HeroVideo teaser with packageItem data 1`] = ` <div - className="o-teaser o-teaser--article o-teaser--hero o-teaser--centre o-teaser--has-image o-teaser--opinion js-teaser" + className="o-teaser o-teaser--article o-teaser--hero o-teaser--centre o-teaser--opinion js-teaser" data-id="" > <div @@ -2222,147 +1851,80 @@ exports[`@financial-times/x-teaser renders a HeroVideo Package item x-teaser 1`] <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <span + className="o-teaser__tag-prefix" > - <span - className="o-teaser__tag-prefix" - > - FT Series - </span> - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Financial crisis: Are we safer now? - </a> - </div> - </div> - <div - className="o-teaser__heading" - > + FT Series + </span> <a - className="js-teaser-heading-link" - data-trackable="heading-link" + aria-label="Category: Financial crisis: Are we safer now? " + className="o-teaser__tag" + data-trackable="teaser-tag" href="#" > - Why so little has changed since the crash - + Financial crisis: Are we safer now? </a> </div> - </div> - <div - className="o-teaser__image-container js-teaser-image-container" - > <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + className="o-teaser__heading" > <a - aria-hidden="true" - data-trackable="image-link" + className="js-teaser-heading-link" + data-trackable="heading-link" href="#" - tab-index="-1" > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&compression=best&width=420" - /> + Why so little has changed since the crash </a> </div> </div> </div> `; -exports[`@financial-times/x-teaser renders a HeroVideo Paid Post x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a HeroVideo teaser with podcast data 1`] = ` <div - className="o-teaser o-teaser--paid-post o-teaser--hero o-teaser--has-image js-teaser" - data-id="" + className="o-teaser o-teaser--audio o-teaser--hero js-teaser" + data-id="d1246074-f7d3-4aaf-951c-80a6db495765" > <div className="o-teaser__content" > <div className="o-teaser__meta" - > - <div - className="o-teaser__meta-promoted" - > - <span - className="o-teaser__promoted-prefix" - > - Paid post - </span> - <span - className="o-teaser__promoted-by" - > - by UBS - </span> - </div> - </div> - <div - className="o-teaser__heading" > <a - className="js-teaser-heading-link" - data-trackable="heading-link" + aria-label="Category: Tech Tonic podcast" + className="o-teaser__tag" + data-trackable="teaser-tag" href="#" > - Why eSports companies are on a winning streak - + Tech Tonic podcast </a> - </div> - <p - className="o-teaser__standfirst" - > - <a - className="js-teaser-standfirst-link" - data-trackable="standfirst-link" - href="#" - tabIndex={-1} + <span + className="o-teaser__tag-suffix" > - ESports is big business and about to get bigger: global revenues could hit $1.5bn by 2020 - </a> - </p> - </div> - <div - className="o-teaser__image-container js-teaser-image-container" - > + 12 mins + </span> + </div> <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2857%", - } - } + className="o-teaser__heading" > <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" + aria-label="Listen to podcast Who sets the internet standards?" + className="js-teaser-heading-link" + data-trackable="heading-link" + href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Ftpc.googlesyndication.com%2Fpagead%2Fimgad%3Fid%3DCICAgKCrm_3yahABGAEyCMx3RoLss603?source=next&fit=scale-down&compression=best&width=420" - /> + Who sets the internet standards? </a> </div> </div> </div> `; -exports[`@financial-times/x-teaser renders a HeroVideo Podcast x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a HeroVideo teaser with promoted data 1`] = ` <div - className="o-teaser o-teaser--audio o-teaser--hero o-teaser--has-image js-teaser" - data-id="d1246074-f7d3-4aaf-951c-80a6db495765" + className="o-teaser o-teaser--paid-post o-teaser--hero js-teaser" + data-id="" > <div className="o-teaser__content" @@ -2370,22 +1932,17 @@ exports[`@financial-times/x-teaser renders a HeroVideo Podcast x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <span + className="o-teaser__promoted-prefix" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Tech Tonic podcast - </a> - <span - className="o-teaser__tag-suffix" - > - 12 mins - </span> - </div> + Paid post + </span> + by + <span + className="o-teaser__promoted-by" + > + UBS + </span> </div> <div className="o-teaser__heading" @@ -2393,57 +1950,20 @@ exports[`@financial-times/x-teaser renders a HeroVideo Podcast x-teaser 1`] = ` <a className="js-teaser-heading-link" data-trackable="heading-link" - href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" - > - Who sets the internet standards? - - </a> - </div> - <p - className="o-teaser__standfirst" - > - <a - className="js-teaser-standfirst-link" - data-trackable="standfirst-link" - href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" - tabIndex={-1} - > - Hannah Kuchler talks to American social scientist and cyber security expert Andrea… - </a> - </p> - </div> - <div - className="o-teaser__image-container js-teaser-image-container" - > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } - > - <a - aria-hidden="true" - data-trackable="image-link" - href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" - tab-index="-1" + href="#" > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Fwww.ft.com%2F__origami%2Fservice%2Fimage%2Fv2%2Fimages%2Fraw%2Fhttp%253A%252F%252Fprod-upp-image-read.ft.com%252F5d1a54aa-f49b-11e8-ae55-df4bf40f9d0d%3Fsource%3Dnext%26fit%3Dscale-down%26compression%3Dbest%26width%3D240?source=next&fit=scale-down&compression=best&width=420" - /> + Why eSports companies are on a winning streak </a> </div> </div> </div> `; -exports[`@financial-times/x-teaser renders a HeroVideo Top Story x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a HeroVideo teaser with topStory data 1`] = ` <div - className="o-teaser o-teaser--article o-teaser--hero o-teaser--landscape o-teaser--has-image js-teaser" + className="o-teaser o-teaser--article o-teaser--hero js-teaser" data-id="" + data-trackable="slice:hero-slice-1;slicePos:1;layout:standaloneimage;listPos:1" > <div className="o-teaser__content" @@ -2451,17 +1971,14 @@ exports[`@financial-times/x-teaser renders a HeroVideo Top Story x-teaser 1`] = <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Sexual misconduct allegations - </a> - </div> + Sexual misconduct allegations + </a> </div> <div className="o-teaser__heading" @@ -2472,88 +1989,13 @@ exports[`@financial-times/x-teaser renders a HeroVideo Top Story x-teaser 1`] = href="#" > Inside charity fundraiser where hostesses are put on show - - </a> - </div> - <p - className="o-teaser__standfirst" - > - <a - className="js-teaser-standfirst-link" - data-trackable="standfirst-link" - href="#" - tabIndex={-1} - > - FT investigation finds groping and sexual harassment at secretive black-tie dinner - </a> - </p> - </div> - <div - className="o-teaser__image-container js-teaser-image-container" - > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } - > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" - > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&compression=best&width=420" - /> </a> </div> </div> - <ul - className="o-teaser__related" - > - <li - className="o-teaser__related-item o-teaser__related-item--article" - data-content-id="" - > - <a - data-trackable="related" - href="#" - > - Removing the fig leaf of charity - </a> - </li> - <li - className="o-teaser__related-item o-teaser__related-item--article" - data-content-id="" - > - <a - data-trackable="related" - href="#" - > - A dinner that demeaned both women and men - </a> - </li> - <li - className="o-teaser__related-item o-teaser__related-item--video" - data-content-id="" - > - <a - data-trackable="related" - href="#" - > - PM speaks out after Presidents Club dinner - </a> - </li> - </ul> </div> `; -exports[`@financial-times/x-teaser renders a HeroVideo Video x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a HeroVideo teaser with video data 1`] = ` <div className="o-teaser o-teaser--video o-teaser--hero o-teaser--has-video js-teaser" data-id="0e89d872-5711-457b-80b1-4ca0d8afea46" @@ -2564,22 +2006,19 @@ exports[`@financial-times/x-teaser renders a HeroVideo Video x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Global Trade" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Global Trade - </a> - <span - className="o-teaser__tag-suffix" - > - 02:51min - </span> - </div> + Global Trade + </a> + <span + className="o-teaser__tag-suffix" + > + 02:51min + </span> </div> <div className="o-teaser__video" @@ -2596,10 +2035,14 @@ exports[`@financial-times/x-teaser renders a HeroVideo Video x-teaser 1`] = ` data-o-video-autorender="true" data-o-video-data="{\\"renditions\\":[{\\"url\\":\\"https://next-media-api.ft.com/renditions/15218247321960/640x360.mp4\\",\\"width\\":640,\\"height\\":360,\\"mediaType\\":\\"video/mp4\\",\\"codec\\":\\"h264\\"}],\\"mainImageUrl\\":\\"http://com.ft.imagepublish.upp-prod-eu.s3.amazonaws.com/a27ce49b-85b8-445b-b883-db6e2f533194\\"}" data-o-video-id="0e89d872-5711-457b-80b1-4ca0d8afea46" + data-o-video-optimumvideowidth="640" + data-o-video-optimumwidth="640" data-o-video-placeholder="true" data-o-video-placeholder-hint="Play video" data-o-video-placeholder-info="[]" data-o-video-playsinline="true" + data-o-video-show-guidance="true" + data-o-video-systemcode="x-teaser" /> </div> </div> @@ -2609,27 +2052,27 @@ exports[`@financial-times/x-teaser renders a HeroVideo Video x-teaser 1`] = ` <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fcom.ft.imagepublish.upp-prod-eu.s3.amazonaws.com%2Fa27ce49b-85b8-445b-b883-db6e2f533194?source=next&fit=scale-down&compression=best&width=420" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fcom.ft.imagepublish.upp-prod-eu.s3.amazonaws.com%2Fa27ce49b-85b8-445b-b883-db6e2f533194?source=next&fit=scale-down&dpr=2&width=420" /> - </a> - </div> + </div> + </a> </div> </div> </div> @@ -2637,19 +2080,19 @@ exports[`@financial-times/x-teaser renders a HeroVideo Video x-teaser 1`] = ` className="o-teaser__heading" > <a + aria-label="Watch video FT View: Donald Trump, man of steel" className="js-teaser-heading-link" data-trackable="heading-link" href="#" > FT View: Donald Trump, man of steel - </a> </div> </div> </div> `; -exports[`@financial-times/x-teaser renders a Large Article x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a Large teaser with article data 1`] = ` <div className="o-teaser o-teaser--article o-teaser--large o-teaser--has-image o-teaser--highlight js-teaser" data-id="" @@ -2660,17 +2103,14 @@ exports[`@financial-times/x-teaser renders a Large Article x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Sexual misconduct allegations - </a> - </div> + Sexual misconduct allegations + </a> </div> <div className="o-teaser__heading" @@ -2681,7 +2121,6 @@ exports[`@financial-times/x-teaser renders a Large Article x-teaser 1`] = ` href="#" > Inside charity fundraiser where hostesses are put on show - </a> </div> <p @@ -2700,34 +2139,34 @@ exports[`@financial-times/x-teaser renders a Large Article x-teaser 1`] = ` <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&compression=best&width=340" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&dpr=2&width=340" /> - </a> - </div> + </div> + </a> </div> </div> `; -exports[`@financial-times/x-teaser renders a Large Content Package x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a Large teaser with article-with-data-image data 1`] = ` <div - className="o-teaser o-teaser--package o-teaser--large o-teaser--centre o-teaser--has-image js-teaser" + className="o-teaser o-teaser--article o-teaser--large o-teaser--has-image o-teaser--highlight js-teaser" data-id="" > <div @@ -2736,17 +2175,14 @@ exports[`@financial-times/x-teaser renders a Large Content Package x-teaser 1`] <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - FT Magazine - </a> - </div> + Sexual misconduct allegations + </a> </div> <div className="o-teaser__heading" @@ -2756,8 +2192,7 @@ exports[`@financial-times/x-teaser renders a Large Content Package x-teaser 1`] data-trackable="heading-link" href="#" > - The royal wedding - + Inside charity fundraiser where hostesses are put on show </a> </div> <p @@ -2769,41 +2204,41 @@ exports[`@financial-times/x-teaser renders a Large Content Package x-teaser 1`] href="#" tabIndex={-1} > - Prince Harry and Meghan Markle will tie the knot at Windsor Castle + FT investigation finds groping and sexual harassment at secretive black-tie dinner </a> </p> </div> <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F7e97f5b6-578d-11e8-b8b2-d6ceb45fa9d0?source=next&fit=scale-down&compression=best&width=340" + src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==" /> - </a> - </div> + </div> + </a> </div> </div> `; -exports[`@financial-times/x-teaser renders a Large Opinion Piece x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a Large teaser with article-with-missing-image-url data 1`] = ` <div - className="o-teaser o-teaser--article o-teaser--large o-teaser--has-headshot o-teaser--opinion js-teaser" + className="o-teaser o-teaser--article o-teaser--large o-teaser--highlight js-teaser" data-id="" > <div @@ -2812,17 +2247,14 @@ exports[`@financial-times/x-teaser renders a Large Opinion Piece x-teaser 1`] = <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Gideon Rachman - </a> - </div> + Sexual misconduct allegations + </a> </div> <div className="o-teaser__heading" @@ -2832,8 +2264,7 @@ exports[`@financial-times/x-teaser renders a Large Opinion Piece x-teaser 1`] = data-trackable="heading-link" href="#" > - Anti-Semitism and the threat of identity politics - + Inside charity fundraiser where hostesses are put on show </a> </div> <p @@ -2845,24 +2276,16 @@ exports[`@financial-times/x-teaser renders a Large Opinion Piece x-teaser 1`] = href="#" tabIndex={-1} > - Today, hatred of Jews is mixed in with fights about Islam and Israel + FT investigation finds groping and sexual harassment at secretive black-tie dinner </a> </p> - <img - alt="" - aria-hidden="true" - className="o-teaser__headshot" - height={75} - src="https://www.ft.com/__origami/service/image/v2/images/raw/fthead-v1%3Agideon-rachman?source=next&fit=scale-down&compression=best&width=75&tint=054593,d6d5d3&dpr=2" - width={75} - /> </div> </div> `; -exports[`@financial-times/x-teaser renders a Large Package item x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a Large teaser with contentPackage data 1`] = ` <div - className="o-teaser o-teaser--article o-teaser--large o-teaser--centre o-teaser--has-image o-teaser--opinion js-teaser" + className="o-teaser o-teaser--package o-teaser--large o-teaser--centre o-teaser--has-image js-teaser" data-id="" > <div @@ -2871,22 +2294,14 @@ exports[`@financial-times/x-teaser renders a Large Package item x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: FT Magazine" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <span - className="o-teaser__tag-prefix" - > - FT Series - </span> - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Financial crisis: Are we safer now? - </a> - </div> + FT Magazine + </a> </div> <div className="o-teaser__heading" @@ -2896,8 +2311,7 @@ exports[`@financial-times/x-teaser renders a Large Package item x-teaser 1`] = ` data-trackable="heading-link" href="#" > - Why so little has changed since the crash - + The royal wedding </a> </div> <p @@ -2909,41 +2323,41 @@ exports[`@financial-times/x-teaser renders a Large Package item x-teaser 1`] = ` href="#" tabIndex={-1} > - Martin Wolf on the power of vested interests in today’s rent-extracting economy + Prince Harry and Meghan Markle will tie the knot at Windsor Castle </a> </p> </div> <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&compression=best&width=340" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F7e97f5b6-578d-11e8-b8b2-d6ceb45fa9d0?source=next&fit=scale-down&dpr=2&width=340" /> - </a> - </div> + </div> + </a> </div> </div> `; -exports[`@financial-times/x-teaser renders a Large Paid Post x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a Large teaser with opinion data 1`] = ` <div - className="o-teaser o-teaser--paid-post o-teaser--large o-teaser--has-image js-teaser" + className="o-teaser o-teaser--article o-teaser--large o-teaser--has-headshot o-teaser--opinion js-teaser" data-id="" > <div @@ -2952,20 +2366,14 @@ exports[`@financial-times/x-teaser renders a Large Paid Post x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-promoted" + <a + aria-label="Category: Gideon Rachman" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <span - className="o-teaser__promoted-prefix" - > - Paid post - </span> - <span - className="o-teaser__promoted-by" - > - by UBS - </span> - </div> + Gideon Rachman + </a> </div> <div className="o-teaser__heading" @@ -2975,8 +2383,7 @@ exports[`@financial-times/x-teaser renders a Large Paid Post x-teaser 1`] = ` data-trackable="heading-link" href="#" > - Why eSports companies are on a winning streak - + Anti-Semitism and the threat of identity politics </a> </div> <p @@ -2988,39 +2395,99 @@ exports[`@financial-times/x-teaser renders a Large Paid Post x-teaser 1`] = ` href="#" tabIndex={-1} > - ESports is big business and about to get bigger: global revenues could hit $1.5bn by 2020 + Today, hatred of Jews is mixed in with fights about Islam and Israel </a> </p> + <img + alt="" + aria-hidden="true" + className="o-teaser__headshot" + height={75} + src="https://www.ft.com/__origami/service/image/v2/images/raw/fthead-v1%3Agideon-rachman?source=next&fit=scale-down&dpr=2&tint=054593%2Cd6d5d3&width=75" + width={75} + /> </div> +</div> +`; + +exports[`x-teaser / snapshots renders a Large teaser with packageItem data 1`] = ` +<div + className="o-teaser o-teaser--article o-teaser--large o-teaser--centre o-teaser--has-image o-teaser--opinion js-teaser" + data-id="" +> <div - className="o-teaser__image-container js-teaser-image-container" + className="o-teaser__content" > <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2857%", - } - } + className="o-teaser__meta" + > + <span + className="o-teaser__tag-prefix" + > + FT Series + </span> + <a + aria-label="Category: Financial crisis: Are we safer now? " + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" + > + Financial crisis: Are we safer now? + </a> + </div> + <div + className="o-teaser__heading" > <a - aria-hidden="true" - data-trackable="image-link" + className="js-teaser-heading-link" + data-trackable="heading-link" + href="#" + > + Why so little has changed since the crash + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" href="#" - tab-index="-1" + tabIndex={-1} + > + Martin Wolf on the power of vested interests in today’s rent-extracting economy + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Ftpc.googlesyndication.com%2Fpagead%2Fimgad%3Fid%3DCICAgKCrm_3yahABGAEyCMx3RoLss603?source=next&fit=scale-down&compression=best&width=340" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&dpr=2&width=340" /> - </a> - </div> + </div> + </a> </div> </div> `; -exports[`@financial-times/x-teaser renders a Large Podcast x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a Large teaser with podcast data 1`] = ` <div className="o-teaser o-teaser--audio o-teaser--large o-teaser--has-image js-teaser" data-id="d1246074-f7d3-4aaf-951c-80a6db495765" @@ -3031,33 +2498,30 @@ exports[`@financial-times/x-teaser renders a Large Podcast x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Tech Tonic podcast" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Tech Tonic podcast - </a> - <span - className="o-teaser__tag-suffix" - > - 12 mins - </span> - </div> + Tech Tonic podcast + </a> + <span + className="o-teaser__tag-suffix" + > + 12 mins + </span> </div> <div className="o-teaser__heading" > <a + aria-label="Listen to podcast Who sets the internet standards?" className="js-teaser-heading-link" data-trackable="heading-link" href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" > Who sets the internet standards? - </a> </div> <p @@ -3076,34 +2540,34 @@ exports[`@financial-times/x-teaser renders a Large Podcast x-teaser 1`] = ` <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Fwww.ft.com%2F__origami%2Fservice%2Fimage%2Fv2%2Fimages%2Fraw%2Fhttp%253A%252F%252Fprod-upp-image-read.ft.com%252F5d1a54aa-f49b-11e8-ae55-df4bf40f9d0d%3Fsource%3Dnext%26fit%3Dscale-down%26compression%3Dbest%26width%3D240?source=next&fit=scale-down&compression=best&width=340" + src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Fwww.ft.com%2F__origami%2Fservice%2Fimage%2Fv2%2Fimages%2Fraw%2Fhttp%253A%252F%252Fprod-upp-image-read.ft.com%252F5d1a54aa-f49b-11e8-ae55-df4bf40f9d0d%3Fsource%3Dnext%26fit%3Dscale-down%26compression%3Dbest%26width%3D240?source=next&fit=scale-down&dpr=2&width=340" /> - </a> - </div> + </div> + </a> </div> </div> `; -exports[`@financial-times/x-teaser renders a Large Top Story x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a Large teaser with promoted data 1`] = ` <div - className="o-teaser o-teaser--article o-teaser--large o-teaser--landscape o-teaser--has-image js-teaser" + className="o-teaser o-teaser--paid-post o-teaser--large o-teaser--has-image js-teaser" data-id="" > <div @@ -3112,17 +2576,17 @@ exports[`@financial-times/x-teaser renders a Large Top Story x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <span + className="o-teaser__promoted-prefix" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Sexual misconduct allegations - </a> - </div> + Paid post + </span> + by + <span + className="o-teaser__promoted-by" + > + UBS + </span> </div> <div className="o-teaser__heading" @@ -3132,8 +2596,7 @@ exports[`@financial-times/x-teaser renders a Large Top Story x-teaser 1`] = ` data-trackable="heading-link" href="#" > - Inside charity fundraiser where hostesses are put on show - + Why eSports companies are on a winning streak </a> </div> <p @@ -3145,78 +2608,114 @@ exports[`@financial-times/x-teaser renders a Large Top Story x-teaser 1`] = ` href="#" tabIndex={-1} > - FT investigation finds groping and sexual harassment at secretive black-tie dinner + ESports is big business and about to get bigger: global revenues could hit $1.5bn by 2020 </a> </p> </div> <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2857%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&compression=best&width=340" + src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Ftpc.googlesyndication.com%2Fpagead%2Fimgad%3Fid%3DCICAgKCrm_3yahABGAEyCMx3RoLss603?source=next&fit=scale-down&dpr=2&width=340" /> - </a> - </div> + </div> + </a> </div> - <ul - className="o-teaser__related" +</div> +`; + +exports[`x-teaser / snapshots renders a Large teaser with topStory data 1`] = ` +<div + className="o-teaser o-teaser--article o-teaser--large o-teaser--has-image js-teaser" + data-id="" + data-trackable="slice:hero-slice-1;slicePos:1;layout:standaloneimage;listPos:1" +> + <div + className="o-teaser__content" > - <li - className="o-teaser__related-item o-teaser__related-item--article" - data-content-id="" + <div + className="o-teaser__meta" > <a - data-trackable="related" + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" href="#" > - Removing the fig leaf of charity + Sexual misconduct allegations </a> - </li> - <li - className="o-teaser__related-item o-teaser__related-item--article" - data-content-id="" + </div> + <div + className="o-teaser__heading" > <a - data-trackable="related" + className="js-teaser-heading-link" + data-trackable="heading-link" href="#" > - A dinner that demeaned both women and men + Inside charity fundraiser where hostesses are put on show </a> - </li> - <li - className="o-teaser__related-item o-teaser__related-item--video" - data-content-id="" + </div> + <p + className="o-teaser__standfirst" > <a - data-trackable="related" + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" href="#" + tabIndex={-1} > - PM speaks out after Presidents Club dinner + FT investigation finds groping and sexual harassment at secretive black-tie dinner </a> - </li> - </ul> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&dpr=2&width=340" + /> + </div> + </a> + </div> </div> `; -exports[`@financial-times/x-teaser renders a Large Video x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a Large teaser with video data 1`] = ` <div - className="o-teaser o-teaser--video o-teaser--large o-teaser--has-video js-teaser" + className="o-teaser o-teaser--video o-teaser--large o-teaser--has-image js-teaser" data-id="0e89d872-5711-457b-80b1-4ca0d8afea46" > <div @@ -3225,85 +2724,30 @@ exports[`@financial-times/x-teaser renders a Large Video x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Global Trade" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Global Trade - </a> - <span - className="o-teaser__tag-suffix" - > - 02:51min - </span> - </div> + Global Trade + </a> + <span + className="o-teaser__tag-suffix" + > + 02:51min + </span> </div> <div - className="o-teaser__video" + className="o-teaser__heading" > - <div - className="o--if-js" - > - <div - className="o-teaser__image-container js-image-container" - > - <div - className="o-video" - data-o-component="o-video" - data-o-video-autorender="true" - data-o-video-data="{\\"renditions\\":[{\\"url\\":\\"https://next-media-api.ft.com/renditions/15218247321960/640x360.mp4\\",\\"width\\":640,\\"height\\":360,\\"mediaType\\":\\"video/mp4\\",\\"codec\\":\\"h264\\"}],\\"mainImageUrl\\":\\"http://com.ft.imagepublish.upp-prod-eu.s3.amazonaws.com/a27ce49b-85b8-445b-b883-db6e2f533194\\"}" - data-o-video-id="0e89d872-5711-457b-80b1-4ca0d8afea46" - data-o-video-placeholder="true" - data-o-video-placeholder-hint="Play video" - data-o-video-placeholder-info="[]" - data-o-video-playsinline="true" - /> - </div> - </div> - <div - className="o--if-no-js" - > - <div - className="o-teaser__image-container js-teaser-image-container" - > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } - > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" - > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fcom.ft.imagepublish.upp-prod-eu.s3.amazonaws.com%2Fa27ce49b-85b8-445b-b883-db6e2f533194?source=next&fit=scale-down&compression=best&width=340" - /> - </a> - </div> - </div> - </div> - </div> - <div - className="o-teaser__heading" - > - <a - className="js-teaser-heading-link" - data-trackable="heading-link" - href="#" + <a + aria-label="Watch video FT View: Donald Trump, man of steel" + className="js-teaser-heading-link" + data-trackable="heading-link" + href="#" > FT View: Donald Trump, man of steel - </a> </div> <p @@ -3319,12 +2763,37 @@ exports[`@financial-times/x-teaser renders a Large Video x-teaser 1`] = ` </a> </p> </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fcom.ft.imagepublish.upp-prod-eu.s3.amazonaws.com%2Fa27ce49b-85b8-445b-b883-db6e2f533194?source=next&fit=scale-down&dpr=2&width=340" + /> + </div> + </a> + </div> </div> `; -exports[`@financial-times/x-teaser renders a Small Article x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a Small teaser with article data 1`] = ` <div - className="o-teaser o-teaser--article o-teaser--small o-teaser--has-image o-teaser--highlight js-teaser" + className="o-teaser o-teaser--article o-teaser--small o-teaser--highlight js-teaser" data-id="" > <div @@ -3333,17 +2802,14 @@ exports[`@financial-times/x-teaser renders a Small Article x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Sexual misconduct allegations - </a> - </div> + Sexual misconduct allegations + </a> </div> <div className="o-teaser__heading" @@ -3354,53 +2820,50 @@ exports[`@financial-times/x-teaser renders a Small Article x-teaser 1`] = ` href="#" > Inside charity fundraiser where hostesses are put on show - </a> </div> - <p - className="o-teaser__standfirst" + </div> +</div> +`; + +exports[`x-teaser / snapshots renders a Small teaser with article-with-data-image data 1`] = ` +<div + className="o-teaser o-teaser--article o-teaser--small o-teaser--highlight js-teaser" + data-id="" +> + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" > <a - className="js-teaser-standfirst-link" - data-trackable="standfirst-link" + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" href="#" - tabIndex={-1} > - FT investigation finds groping and sexual harassment at secretive black-tie dinner + Sexual misconduct allegations </a> - </p> - </div> - <div - className="o-teaser__image-container js-teaser-image-container" - > + </div> <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + className="o-teaser__heading" > <a - aria-hidden="true" - data-trackable="image-link" + className="js-teaser-heading-link" + data-trackable="heading-link" href="#" - tab-index="-1" > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&compression=best&width=240" - /> + Inside charity fundraiser where hostesses are put on show </a> </div> </div> </div> `; -exports[`@financial-times/x-teaser renders a Small Content Package x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a Small teaser with article-with-missing-image-url data 1`] = ` <div - className="o-teaser o-teaser--package o-teaser--small o-teaser--centre o-teaser--has-image js-teaser" + className="o-teaser o-teaser--article o-teaser--small o-teaser--highlight js-teaser" data-id="" > <div @@ -3409,17 +2872,14 @@ exports[`@financial-times/x-teaser renders a Small Content Package x-teaser 1`] <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - FT Magazine - </a> - </div> + Sexual misconduct allegations + </a> </div> <div className="o-teaser__heading" @@ -3429,40 +2889,49 @@ exports[`@financial-times/x-teaser renders a Small Content Package x-teaser 1`] data-trackable="heading-link" href="#" > - The royal wedding - + Inside charity fundraiser where hostesses are put on show </a> </div> </div> +</div> +`; + +exports[`x-teaser / snapshots renders a Small teaser with contentPackage data 1`] = ` +<div + className="o-teaser o-teaser--package o-teaser--small o-teaser--centre js-teaser" + data-id="" +> <div - className="o-teaser__image-container js-teaser-image-container" + className="o-teaser__content" > <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + className="o-teaser__meta" > <a - aria-hidden="true" - data-trackable="image-link" + aria-label="Category: FT Magazine" + className="o-teaser__tag" + data-trackable="teaser-tag" href="#" - tab-index="-1" > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F7e97f5b6-578d-11e8-b8b2-d6ceb45fa9d0?source=next&fit=scale-down&compression=best&width=340" - /> + FT Magazine + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="#" + > + The royal wedding </a> </div> </div> </div> `; -exports[`@financial-times/x-teaser renders a Small Opinion Piece x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a Small teaser with opinion data 1`] = ` <div className="o-teaser o-teaser--article o-teaser--small o-teaser--has-headshot o-teaser--opinion js-teaser" data-id="" @@ -3473,17 +2942,14 @@ exports[`@financial-times/x-teaser renders a Small Opinion Piece x-teaser 1`] = <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Gideon Rachman" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Gideon Rachman - </a> - </div> + Gideon Rachman + </a> </div> <div className="o-teaser__heading" @@ -3494,36 +2960,23 @@ exports[`@financial-times/x-teaser renders a Small Opinion Piece x-teaser 1`] = href="#" > Anti-Semitism and the threat of identity politics - </a> </div> - <p - className="o-teaser__standfirst" - > - <a - className="js-teaser-standfirst-link" - data-trackable="standfirst-link" - href="#" - tabIndex={-1} - > - Today, hatred of Jews is mixed in with fights about Islam and Israel - </a> - </p> <img alt="" aria-hidden="true" className="o-teaser__headshot" height={75} - src="https://www.ft.com/__origami/service/image/v2/images/raw/fthead-v1%3Agideon-rachman?source=next&fit=scale-down&compression=best&width=75&tint=054593,d6d5d3&dpr=2" + src="https://www.ft.com/__origami/service/image/v2/images/raw/fthead-v1%3Agideon-rachman?source=next&fit=scale-down&dpr=2&tint=054593%2Cd6d5d3&width=75" width={75} /> </div> </div> `; -exports[`@financial-times/x-teaser renders a Small Package item x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a Small teaser with packageItem data 1`] = ` <div - className="o-teaser o-teaser--article o-teaser--small o-teaser--centre o-teaser--has-image o-teaser--opinion js-teaser" + className="o-teaser o-teaser--article o-teaser--small o-teaser--centre o-teaser--opinion js-teaser" data-id="" > <div @@ -3532,22 +2985,19 @@ exports[`@financial-times/x-teaser renders a Small Package item x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <span + className="o-teaser__tag-prefix" > - <span - className="o-teaser__tag-prefix" - > - FT Series - </span> - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Financial crisis: Are we safer now? - </a> - </div> + FT Series + </span> + <a + aria-label="Category: Financial crisis: Are we safer now? " + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" + > + Financial crisis: Are we safer now? + </a> </div> <div className="o-teaser__heading" @@ -3558,41 +3008,56 @@ exports[`@financial-times/x-teaser renders a Small Package item x-teaser 1`] = ` href="#" > Why so little has changed since the crash - </a> </div> </div> +</div> +`; + +exports[`x-teaser / snapshots renders a Small teaser with podcast data 1`] = ` +<div + className="o-teaser o-teaser--audio o-teaser--small js-teaser" + data-id="d1246074-f7d3-4aaf-951c-80a6db495765" +> <div - className="o-teaser__image-container js-teaser-image-container" + className="o-teaser__content" > <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + className="o-teaser__meta" > <a - aria-hidden="true" - data-trackable="image-link" + aria-label="Category: Tech Tonic podcast" + className="o-teaser__tag" + data-trackable="teaser-tag" href="#" - tab-index="-1" > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&compression=best&width=340" - /> + Tech Tonic podcast + </a> + <span + className="o-teaser__tag-suffix" + > + 12 mins + </span> + </div> + <div + className="o-teaser__heading" + > + <a + aria-label="Listen to podcast Who sets the internet standards?" + className="js-teaser-heading-link" + data-trackable="heading-link" + href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" + > + Who sets the internet standards? </a> </div> </div> </div> `; -exports[`@financial-times/x-teaser renders a Small Paid Post x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a Small teaser with promoted data 1`] = ` <div - className="o-teaser o-teaser--paid-post o-teaser--small o-teaser--has-image js-teaser" + className="o-teaser o-teaser--paid-post o-teaser--small js-teaser" data-id="" > <div @@ -3601,20 +3066,17 @@ exports[`@financial-times/x-teaser renders a Small Paid Post x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-promoted" + <span + className="o-teaser__promoted-prefix" > - <span - className="o-teaser__promoted-prefix" - > - Paid post - </span> - <span - className="o-teaser__promoted-by" - > - by UBS - </span> - </div> + Paid post + </span> + by + <span + className="o-teaser__promoted-by" + > + UBS + </span> </div> <div className="o-teaser__heading" @@ -3625,54 +3087,52 @@ exports[`@financial-times/x-teaser renders a Small Paid Post x-teaser 1`] = ` href="#" > Why eSports companies are on a winning streak - </a> </div> - <p - className="o-teaser__standfirst" + </div> +</div> +`; + +exports[`x-teaser / snapshots renders a Small teaser with topStory data 1`] = ` +<div + className="o-teaser o-teaser--article o-teaser--small js-teaser" + data-id="" + data-trackable="slice:hero-slice-1;slicePos:1;layout:standaloneimage;listPos:1" +> + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" > <a - className="js-teaser-standfirst-link" - data-trackable="standfirst-link" + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" href="#" - tabIndex={-1} > - ESports is big business and about to get bigger: global revenues could hit $1.5bn by 2020 + Sexual misconduct allegations </a> - </p> - </div> - <div - className="o-teaser__image-container js-teaser-image-container" - > + </div> <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2857%", - } - } + className="o-teaser__heading" > <a - aria-hidden="true" - data-trackable="image-link" + className="js-teaser-heading-link" + data-trackable="heading-link" href="#" - tab-index="-1" > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Ftpc.googlesyndication.com%2Fpagead%2Fimgad%3Fid%3DCICAgKCrm_3yahABGAEyCMx3RoLss603?source=next&fit=scale-down&compression=best&width=240" - /> + Inside charity fundraiser where hostesses are put on show </a> </div> </div> </div> `; -exports[`@financial-times/x-teaser renders a Small Podcast x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a Small teaser with video data 1`] = ` <div - className="o-teaser o-teaser--audio o-teaser--small o-teaser--has-image js-teaser" - data-id="d1246074-f7d3-4aaf-951c-80a6db495765" + className="o-teaser o-teaser--video o-teaser--small js-teaser" + data-id="0e89d872-5711-457b-80b1-4ca0d8afea46" > <div className="o-teaser__content" @@ -3680,79 +3140,39 @@ exports[`@financial-times/x-teaser renders a Small Podcast x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Global Trade" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Tech Tonic podcast - </a> - <span - className="o-teaser__tag-suffix" - > - 12 mins - </span> - </div> + Global Trade + </a> + <span + className="o-teaser__tag-suffix" + > + 02:51min + </span> </div> <div className="o-teaser__heading" > <a + aria-label="Watch video FT View: Donald Trump, man of steel" className="js-teaser-heading-link" data-trackable="heading-link" - href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" - > - Who sets the internet standards? - - </a> - </div> - <p - className="o-teaser__standfirst" - > - <a - className="js-teaser-standfirst-link" - data-trackable="standfirst-link" - href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" - tabIndex={-1} - > - Hannah Kuchler talks to American social scientist and cyber security expert Andrea… - </a> - </p> - </div> - <div - className="o-teaser__image-container js-teaser-image-container" - > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } - > - <a - aria-hidden="true" - data-trackable="image-link" - href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" - tab-index="-1" + href="#" > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Fwww.ft.com%2F__origami%2Fservice%2Fimage%2Fv2%2Fimages%2Fraw%2Fhttp%253A%252F%252Fprod-upp-image-read.ft.com%252F5d1a54aa-f49b-11e8-ae55-df4bf40f9d0d%3Fsource%3Dnext%26fit%3Dscale-down%26compression%3Dbest%26width%3D240?source=next&fit=scale-down&compression=best&width=240" - /> + FT View: Donald Trump, man of steel </a> </div> </div> </div> `; -exports[`@financial-times/x-teaser renders a Small Top Story x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a SmallHeavy teaser with article data 1`] = ` <div - className="o-teaser o-teaser--article o-teaser--small o-teaser--landscape o-teaser--has-image js-teaser" + className="o-teaser o-teaser--article o-teaser--small o-teaser--has-image o-teaser--highlight js-teaser" data-id="" > <div @@ -3761,17 +3181,14 @@ exports[`@financial-times/x-teaser renders a Small Top Story x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Sexual misconduct allegations - </a> - </div> + Sexual misconduct allegations + </a> </div> <div className="o-teaser__heading" @@ -3782,7 +3199,6 @@ exports[`@financial-times/x-teaser renders a Small Top Story x-teaser 1`] = ` href="#" > Inside charity fundraiser where hostesses are put on show - </a> </div> <p @@ -3801,72 +3217,35 @@ exports[`@financial-times/x-teaser renders a Small Top Story x-teaser 1`] = ` <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&compression=best&width=640" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&dpr=2&width=240" /> - </a> - </div> + </div> + </a> </div> - <ul - className="o-teaser__related" - > - <li - className="o-teaser__related-item o-teaser__related-item--article" - data-content-id="" - > - <a - data-trackable="related" - href="#" - > - Removing the fig leaf of charity - </a> - </li> - <li - className="o-teaser__related-item o-teaser__related-item--article" - data-content-id="" - > - <a - data-trackable="related" - href="#" - > - A dinner that demeaned both women and men - </a> - </li> - <li - className="o-teaser__related-item o-teaser__related-item--video" - data-content-id="" - > - <a - data-trackable="related" - href="#" - > - PM speaks out after Presidents Club dinner - </a> - </li> - </ul> </div> `; -exports[`@financial-times/x-teaser renders a Small Video x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a SmallHeavy teaser with article-with-data-image data 1`] = ` <div - className="o-teaser o-teaser--video o-teaser--small o-teaser--has-video js-teaser" - data-id="0e89d872-5711-457b-80b1-4ca0d8afea46" + className="o-teaser o-teaser--article o-teaser--small o-teaser--has-image o-teaser--highlight js-teaser" + data-id="" > <div className="o-teaser__content" @@ -3874,74 +3253,14 @@ exports[`@financial-times/x-teaser renders a Small Video x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" - > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Global Trade - </a> - <span - className="o-teaser__tag-suffix" - > - 02:51min - </span> - </div> - </div> - <div - className="o-teaser__video" - > - <div - className="o--if-js" - > - <div - className="o-teaser__image-container js-image-container" - > - <div - className="o-video" - data-o-component="o-video" - data-o-video-autorender="true" - data-o-video-data="{\\"renditions\\":[{\\"url\\":\\"https://next-media-api.ft.com/renditions/15218247321960/640x360.mp4\\",\\"width\\":640,\\"height\\":360,\\"mediaType\\":\\"video/mp4\\",\\"codec\\":\\"h264\\"}],\\"mainImageUrl\\":\\"http://com.ft.imagepublish.upp-prod-eu.s3.amazonaws.com/a27ce49b-85b8-445b-b883-db6e2f533194\\"}" - data-o-video-id="0e89d872-5711-457b-80b1-4ca0d8afea46" - data-o-video-placeholder="true" - data-o-video-placeholder-hint="Play video" - data-o-video-placeholder-info="[]" - data-o-video-playsinline="true" - /> - </div> - </div> - <div - className="o--if-no-js" + <a + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <div - className="o-teaser__image-container js-teaser-image-container" - > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } - > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" - > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fcom.ft.imagepublish.upp-prod-eu.s3.amazonaws.com%2Fa27ce49b-85b8-445b-b883-db6e2f533194?source=next&fit=scale-down&compression=best&width=420" - /> - </a> - </div> - </div> - </div> + Sexual misconduct allegations + </a> </div> <div className="o-teaser__heading" @@ -3951,17 +3270,53 @@ exports[`@financial-times/x-teaser renders a Small Video x-teaser 1`] = ` data-trackable="heading-link" href="#" > - FT View: Donald Trump, man of steel - - </a> - </div> + Inside charity fundraiser where hostesses are put on show + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="#" + tabIndex={-1} + > + FT investigation finds groping and sexual harassment at secretive black-tie dinner + </a> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==" + /> + </div> + </a> </div> </div> `; -exports[`@financial-times/x-teaser renders a SmallHeavy Article x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a SmallHeavy teaser with article-with-missing-image-url data 1`] = ` <div - className="o-teaser o-teaser--article o-teaser--small o-teaser--has-image o-teaser--highlight js-teaser" + className="o-teaser o-teaser--article o-teaser--small o-teaser--highlight js-teaser" data-id="" > <div @@ -3970,17 +3325,14 @@ exports[`@financial-times/x-teaser renders a SmallHeavy Article x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Sexual misconduct allegations - </a> - </div> + Sexual misconduct allegations + </a> </div> <div className="o-teaser__heading" @@ -3991,7 +3343,6 @@ exports[`@financial-times/x-teaser renders a SmallHeavy Article x-teaser 1`] = ` href="#" > Inside charity fundraiser where hostesses are put on show - </a> </div> <p @@ -4007,35 +3358,10 @@ exports[`@financial-times/x-teaser renders a SmallHeavy Article x-teaser 1`] = ` </a> </p> </div> - <div - className="o-teaser__image-container js-teaser-image-container" - > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } - > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" - > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&compression=best&width=240" - /> - </a> - </div> - </div> </div> `; -exports[`@financial-times/x-teaser renders a SmallHeavy Content Package x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a SmallHeavy teaser with contentPackage data 1`] = ` <div className="o-teaser o-teaser--package o-teaser--small o-teaser--centre o-teaser--has-image js-teaser" data-id="" @@ -4046,17 +3372,14 @@ exports[`@financial-times/x-teaser renders a SmallHeavy Content Package x-teaser <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: FT Magazine" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - FT Magazine - </a> - </div> + FT Magazine + </a> </div> <div className="o-teaser__heading" @@ -4067,7 +3390,6 @@ exports[`@financial-times/x-teaser renders a SmallHeavy Content Package x-teaser href="#" > The royal wedding - </a> </div> <p @@ -4086,32 +3408,32 @@ exports[`@financial-times/x-teaser renders a SmallHeavy Content Package x-teaser <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F7e97f5b6-578d-11e8-b8b2-d6ceb45fa9d0?source=next&fit=scale-down&compression=best&width=240" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F7e97f5b6-578d-11e8-b8b2-d6ceb45fa9d0?source=next&fit=scale-down&dpr=2&width=240" /> - </a> - </div> + </div> + </a> </div> </div> `; -exports[`@financial-times/x-teaser renders a SmallHeavy Opinion Piece x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a SmallHeavy teaser with opinion data 1`] = ` <div className="o-teaser o-teaser--article o-teaser--small o-teaser--has-headshot o-teaser--opinion js-teaser" data-id="" @@ -4122,17 +3444,14 @@ exports[`@financial-times/x-teaser renders a SmallHeavy Opinion Piece x-teaser 1 <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Gideon Rachman" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Gideon Rachman - </a> - </div> + Gideon Rachman + </a> </div> <div className="o-teaser__heading" @@ -4143,7 +3462,6 @@ exports[`@financial-times/x-teaser renders a SmallHeavy Opinion Piece x-teaser 1 href="#" > Anti-Semitism and the threat of identity politics - </a> </div> <p @@ -4163,14 +3481,14 @@ exports[`@financial-times/x-teaser renders a SmallHeavy Opinion Piece x-teaser 1 aria-hidden="true" className="o-teaser__headshot" height={75} - src="https://www.ft.com/__origami/service/image/v2/images/raw/fthead-v1%3Agideon-rachman?source=next&fit=scale-down&compression=best&width=75&tint=054593,d6d5d3&dpr=2" + src="https://www.ft.com/__origami/service/image/v2/images/raw/fthead-v1%3Agideon-rachman?source=next&fit=scale-down&dpr=2&tint=054593%2Cd6d5d3&width=75" width={75} /> </div> </div> `; -exports[`@financial-times/x-teaser renders a SmallHeavy Package item x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a SmallHeavy teaser with packageItem data 1`] = ` <div className="o-teaser o-teaser--article o-teaser--small o-teaser--centre o-teaser--has-image o-teaser--opinion js-teaser" data-id="" @@ -4181,22 +3499,19 @@ exports[`@financial-times/x-teaser renders a SmallHeavy Package item x-teaser 1` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <span + className="o-teaser__tag-prefix" > - <span - className="o-teaser__tag-prefix" - > - FT Series - </span> - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Financial crisis: Are we safer now? - </a> - </div> + FT Series + </span> + <a + aria-label="Category: Financial crisis: Are we safer now? " + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" + > + Financial crisis: Are we safer now? + </a> </div> <div className="o-teaser__heading" @@ -4207,7 +3522,6 @@ exports[`@financial-times/x-teaser renders a SmallHeavy Package item x-teaser 1` href="#" > Why so little has changed since the crash - </a> </div> <p @@ -4226,35 +3540,35 @@ exports[`@financial-times/x-teaser renders a SmallHeavy Package item x-teaser 1` <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&compression=best&width=240" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&dpr=2&width=240" /> - </a> - </div> + </div> + </a> </div> </div> `; -exports[`@financial-times/x-teaser renders a SmallHeavy Paid Post x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a SmallHeavy teaser with podcast data 1`] = ` <div - className="o-teaser o-teaser--paid-post o-teaser--small o-teaser--has-image js-teaser" - data-id="" + className="o-teaser o-teaser--audio o-teaser--small o-teaser--has-image js-teaser" + data-id="d1246074-f7d3-4aaf-951c-80a6db495765" > <div className="o-teaser__content" @@ -4262,31 +3576,30 @@ exports[`@financial-times/x-teaser renders a SmallHeavy Paid Post x-teaser 1`] = <div className="o-teaser__meta" > - <div - className="o-teaser__meta-promoted" + <a + aria-label="Category: Tech Tonic podcast" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <span - className="o-teaser__promoted-prefix" - > - Paid post - </span> - <span - className="o-teaser__promoted-by" - > - by UBS - </span> - </div> + Tech Tonic podcast + </a> + <span + className="o-teaser__tag-suffix" + > + 12 mins + </span> </div> <div className="o-teaser__heading" > <a + aria-label="Listen to podcast Who sets the internet standards?" className="js-teaser-heading-link" data-trackable="heading-link" - href="#" + href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" > - Why eSports companies are on a winning streak - + Who sets the internet standards? </a> </div> <p @@ -4295,45 +3608,45 @@ exports[`@financial-times/x-teaser renders a SmallHeavy Paid Post x-teaser 1`] = <a className="js-teaser-standfirst-link" data-trackable="standfirst-link" - href="#" + href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" tabIndex={-1} > - ESports is big business and about to get bigger: global revenues could hit $1.5bn by 2020 + Hannah Kuchler talks to American social scientist and cyber security expert Andrea… </a> </p> </div> <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2857%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Ftpc.googlesyndication.com%2Fpagead%2Fimgad%3Fid%3DCICAgKCrm_3yahABGAEyCMx3RoLss603?source=next&fit=scale-down&compression=best&width=240" + src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Fwww.ft.com%2F__origami%2Fservice%2Fimage%2Fv2%2Fimages%2Fraw%2Fhttp%253A%252F%252Fprod-upp-image-read.ft.com%252F5d1a54aa-f49b-11e8-ae55-df4bf40f9d0d%3Fsource%3Dnext%26fit%3Dscale-down%26compression%3Dbest%26width%3D240?source=next&fit=scale-down&dpr=2&width=240" /> - </a> - </div> + </div> + </a> </div> </div> `; -exports[`@financial-times/x-teaser renders a SmallHeavy Podcast x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a SmallHeavy teaser with promoted data 1`] = ` <div - className="o-teaser o-teaser--audio o-teaser--small o-teaser--has-image js-teaser" - data-id="d1246074-f7d3-4aaf-951c-80a6db495765" + className="o-teaser o-teaser--paid-post o-teaser--small o-teaser--has-image js-teaser" + data-id="" > <div className="o-teaser__content" @@ -4341,22 +3654,17 @@ exports[`@financial-times/x-teaser renders a SmallHeavy Podcast x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <span + className="o-teaser__promoted-prefix" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Tech Tonic podcast - </a> - <span - className="o-teaser__tag-suffix" - > - 12 mins - </span> - </div> + Paid post + </span> + by + <span + className="o-teaser__promoted-by" + > + UBS + </span> </div> <div className="o-teaser__heading" @@ -4364,10 +3672,9 @@ exports[`@financial-times/x-teaser renders a SmallHeavy Podcast x-teaser 1`] = ` <a className="js-teaser-heading-link" data-trackable="heading-link" - href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" + href="#" > - Who sets the internet standards? - + Why eSports companies are on a winning streak </a> </div> <p @@ -4376,45 +3683,46 @@ exports[`@financial-times/x-teaser renders a SmallHeavy Podcast x-teaser 1`] = ` <a className="js-teaser-standfirst-link" data-trackable="standfirst-link" - href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" + href="#" tabIndex={-1} > - Hannah Kuchler talks to American social scientist and cyber security expert Andrea… + ESports is big business and about to get bigger: global revenues could hit $1.5bn by 2020 </a> </p> </div> <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2857%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Fwww.ft.com%2F__origami%2Fservice%2Fimage%2Fv2%2Fimages%2Fraw%2Fhttp%253A%252F%252Fprod-upp-image-read.ft.com%252F5d1a54aa-f49b-11e8-ae55-df4bf40f9d0d%3Fsource%3Dnext%26fit%3Dscale-down%26compression%3Dbest%26width%3D240?source=next&fit=scale-down&compression=best&width=240" + src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Ftpc.googlesyndication.com%2Fpagead%2Fimgad%3Fid%3DCICAgKCrm_3yahABGAEyCMx3RoLss603?source=next&fit=scale-down&dpr=2&width=240" /> - </a> - </div> + </div> + </a> </div> </div> `; -exports[`@financial-times/x-teaser renders a SmallHeavy Top Story x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a SmallHeavy teaser with topStory data 1`] = ` <div - className="o-teaser o-teaser--article o-teaser--small o-teaser--landscape o-teaser--has-image js-teaser" + className="o-teaser o-teaser--article o-teaser--small o-teaser--has-image js-teaser" data-id="" + data-trackable="slice:hero-slice-1;slicePos:1;layout:standaloneimage;listPos:1" > <div className="o-teaser__content" @@ -4422,17 +3730,14 @@ exports[`@financial-times/x-teaser renders a SmallHeavy Top Story x-teaser 1`] = <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Sexual misconduct allegations - </a> - </div> + Sexual misconduct allegations + </a> </div> <div className="o-teaser__heading" @@ -4443,7 +3748,6 @@ exports[`@financial-times/x-teaser renders a SmallHeavy Top Story x-teaser 1`] = href="#" > Inside charity fundraiser where hostesses are put on show - </a> </div> <p @@ -4462,72 +3766,113 @@ exports[`@financial-times/x-teaser renders a SmallHeavy Top Story x-teaser 1`] = <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&compression=best&width=240" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&dpr=2&width=240" /> - </a> - </div> + </div> + </a> </div> - <ul - className="o-teaser__related" +</div> +`; + +exports[`x-teaser / snapshots renders a SmallHeavy teaser with video data 1`] = ` +<div + className="o-teaser o-teaser--video o-teaser--small o-teaser--has-image js-teaser" + data-id="0e89d872-5711-457b-80b1-4ca0d8afea46" +> + <div + className="o-teaser__content" > - <li - className="o-teaser__related-item o-teaser__related-item--article" - data-content-id="" + <div + className="o-teaser__meta" > <a - data-trackable="related" + aria-label="Category: Global Trade" + className="o-teaser__tag" + data-trackable="teaser-tag" href="#" > - Removing the fig leaf of charity + Global Trade </a> - </li> - <li - className="o-teaser__related-item o-teaser__related-item--article" - data-content-id="" + <span + className="o-teaser__tag-suffix" + > + 02:51min + </span> + </div> + <div + className="o-teaser__heading" > <a - data-trackable="related" + aria-label="Watch video FT View: Donald Trump, man of steel" + className="js-teaser-heading-link" + data-trackable="heading-link" href="#" > - A dinner that demeaned both women and men + FT View: Donald Trump, man of steel </a> - </li> - <li - className="o-teaser__related-item o-teaser__related-item--video" - data-content-id="" + </div> + <p + className="o-teaser__standfirst" > <a - data-trackable="related" + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" href="#" + tabIndex={-1} > - PM speaks out after Presidents Club dinner + The FT's Rob Armstrong looks at why Donald Trump is pushing trade tariffs </a> - </li> - </ul> + </p> + </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fcom.ft.imagepublish.upp-prod-eu.s3.amazonaws.com%2Fa27ce49b-85b8-445b-b883-db6e2f533194?source=next&fit=scale-down&dpr=2&width=240" + /> + </div> + </a> + </div> </div> `; -exports[`@financial-times/x-teaser renders a SmallHeavy Video x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a TopStory teaser with article data 1`] = ` <div - className="o-teaser o-teaser--video o-teaser--small o-teaser--has-video js-teaser" - data-id="0e89d872-5711-457b-80b1-4ca0d8afea46" + className="o-teaser o-teaser--article o-teaser--top-story o-teaser--highlight js-teaser" + data-id="" > <div className="o-teaser__content" @@ -4535,74 +3880,14 @@ exports[`@financial-times/x-teaser renders a SmallHeavy Video x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" - > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Global Trade - </a> - <span - className="o-teaser__tag-suffix" - > - 02:51min - </span> - </div> - </div> - <div - className="o-teaser__video" - > - <div - className="o--if-js" - > - <div - className="o-teaser__image-container js-image-container" - > - <div - className="o-video" - data-o-component="o-video" - data-o-video-autorender="true" - data-o-video-data="{\\"renditions\\":[{\\"url\\":\\"https://next-media-api.ft.com/renditions/15218247321960/640x360.mp4\\",\\"width\\":640,\\"height\\":360,\\"mediaType\\":\\"video/mp4\\",\\"codec\\":\\"h264\\"}],\\"mainImageUrl\\":\\"http://com.ft.imagepublish.upp-prod-eu.s3.amazonaws.com/a27ce49b-85b8-445b-b883-db6e2f533194\\"}" - data-o-video-id="0e89d872-5711-457b-80b1-4ca0d8afea46" - data-o-video-placeholder="true" - data-o-video-placeholder-hint="Play video" - data-o-video-placeholder-info="[]" - data-o-video-playsinline="true" - /> - </div> - </div> - <div - className="o--if-no-js" - > - <div - className="o-teaser__image-container js-teaser-image-container" - > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } - > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" - > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fcom.ft.imagepublish.upp-prod-eu.s3.amazonaws.com%2Fa27ce49b-85b8-445b-b883-db6e2f533194?source=next&fit=scale-down&compression=best&width=240" - /> - </a> - </div> - </div> - </div> + <a + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" + > + Sexual misconduct allegations + </a> </div> <div className="o-teaser__heading" @@ -4612,8 +3897,7 @@ exports[`@financial-times/x-teaser renders a SmallHeavy Video x-teaser 1`] = ` data-trackable="heading-link" href="#" > - FT View: Donald Trump, man of steel - + Inside charity fundraiser where hostesses are put on show </a> </div> <p @@ -4625,16 +3909,16 @@ exports[`@financial-times/x-teaser renders a SmallHeavy Video x-teaser 1`] = ` href="#" tabIndex={-1} > - The FT's Rob Armstrong looks at why Donald Trump is pushing trade tariffs + FT investigation finds groping and sexual harassment at secretive black-tie dinner </a> </p> </div> </div> `; -exports[`@financial-times/x-teaser renders a TopStory Article x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a TopStory teaser with article-with-data-image data 1`] = ` <div - className="o-teaser o-teaser--article o-teaser--top-story o-teaser--has-image o-teaser--highlight js-teaser" + className="o-teaser o-teaser--article o-teaser--top-story o-teaser--highlight js-teaser" data-id="" > <div @@ -4643,17 +3927,14 @@ exports[`@financial-times/x-teaser renders a TopStory Article x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Sexual misconduct allegations - </a> - </div> + Sexual misconduct allegations + </a> </div> <div className="o-teaser__heading" @@ -4664,7 +3945,6 @@ exports[`@financial-times/x-teaser renders a TopStory Article x-teaser 1`] = ` href="#" > Inside charity fundraiser where hostesses are put on show - </a> </div> <p @@ -4680,37 +3960,59 @@ exports[`@financial-times/x-teaser renders a TopStory Article x-teaser 1`] = ` </a> </p> </div> +</div> +`; + +exports[`x-teaser / snapshots renders a TopStory teaser with article-with-missing-image-url data 1`] = ` +<div + className="o-teaser o-teaser--article o-teaser--top-story o-teaser--highlight js-teaser" + data-id="" +> <div - className="o-teaser__image-container js-teaser-image-container" + className="o-teaser__content" > <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + className="o-teaser__meta" > <a - aria-hidden="true" - data-trackable="image-link" + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" href="#" - tab-index="-1" > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&compression=best&width=240" - /> + Sexual misconduct allegations + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="#" + > + Inside charity fundraiser where hostesses are put on show </a> </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="#" + tabIndex={-1} + > + FT investigation finds groping and sexual harassment at secretive black-tie dinner + </a> + </p> </div> </div> `; -exports[`@financial-times/x-teaser renders a TopStory Content Package x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a TopStory teaser with contentPackage data 1`] = ` <div - className="o-teaser o-teaser--package o-teaser--top-story o-teaser--centre o-teaser--has-image js-teaser" + className="o-teaser o-teaser--package o-teaser--top-story o-teaser--centre js-teaser" data-id="" > <div @@ -4719,17 +4021,14 @@ exports[`@financial-times/x-teaser renders a TopStory Content Package x-teaser 1 <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: FT Magazine" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - FT Magazine - </a> - </div> + FT Magazine + </a> </div> <div className="o-teaser__heading" @@ -4740,7 +4039,6 @@ exports[`@financial-times/x-teaser renders a TopStory Content Package x-teaser 1 href="#" > The royal wedding - </a> </div> <p @@ -4756,35 +4054,10 @@ exports[`@financial-times/x-teaser renders a TopStory Content Package x-teaser 1 </a> </p> </div> - <div - className="o-teaser__image-container js-teaser-image-container" - > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } - > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" - > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F7e97f5b6-578d-11e8-b8b2-d6ceb45fa9d0?source=next&fit=scale-down&compression=best&width=340" - /> - </a> - </div> - </div> </div> `; -exports[`@financial-times/x-teaser renders a TopStory Opinion Piece x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a TopStory teaser with opinion data 1`] = ` <div className="o-teaser o-teaser--article o-teaser--top-story o-teaser--has-headshot o-teaser--opinion js-teaser" data-id="" @@ -4795,17 +4068,14 @@ exports[`@financial-times/x-teaser renders a TopStory Opinion Piece x-teaser 1`] <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Gideon Rachman" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Gideon Rachman - </a> - </div> + Gideon Rachman + </a> </div> <div className="o-teaser__heading" @@ -4816,7 +4086,6 @@ exports[`@financial-times/x-teaser renders a TopStory Opinion Piece x-teaser 1`] href="#" > Anti-Semitism and the threat of identity politics - </a> </div> <p @@ -4836,16 +4105,16 @@ exports[`@financial-times/x-teaser renders a TopStory Opinion Piece x-teaser 1`] aria-hidden="true" className="o-teaser__headshot" height={75} - src="https://www.ft.com/__origami/service/image/v2/images/raw/fthead-v1%3Agideon-rachman?source=next&fit=scale-down&compression=best&width=75&tint=054593,d6d5d3&dpr=2" + src="https://www.ft.com/__origami/service/image/v2/images/raw/fthead-v1%3Agideon-rachman?source=next&fit=scale-down&dpr=2&tint=054593%2Cd6d5d3&width=75" width={75} /> </div> </div> `; -exports[`@financial-times/x-teaser renders a TopStory Package item x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a TopStory teaser with packageItem data 1`] = ` <div - className="o-teaser o-teaser--article o-teaser--top-story o-teaser--centre o-teaser--has-image o-teaser--opinion js-teaser" + className="o-teaser o-teaser--article o-teaser--top-story o-teaser--centre o-teaser--opinion js-teaser" data-id="" > <div @@ -4854,22 +4123,19 @@ exports[`@financial-times/x-teaser renders a TopStory Package item x-teaser 1`] <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <span + className="o-teaser__tag-prefix" > - <span - className="o-teaser__tag-prefix" - > - FT Series - </span> - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Financial crisis: Are we safer now? - </a> - </div> + FT Series + </span> + <a + aria-label="Category: Financial crisis: Are we safer now? " + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" + > + Financial crisis: Are we safer now? + </a> </div> <div className="o-teaser__heading" @@ -4880,7 +4146,6 @@ exports[`@financial-times/x-teaser renders a TopStory Package item x-teaser 1`] href="#" > Why so little has changed since the crash - </a> </div> <p @@ -4896,38 +4161,13 @@ exports[`@financial-times/x-teaser renders a TopStory Package item x-teaser 1`] </a> </p> </div> - <div - className="o-teaser__image-container js-teaser-image-container" - > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } - > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" - > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&compression=best&width=340" - /> - </a> - </div> - </div> </div> `; -exports[`@financial-times/x-teaser renders a TopStory Paid Post x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a TopStory teaser with podcast data 1`] = ` <div - className="o-teaser o-teaser--paid-post o-teaser--top-story o-teaser--has-image js-teaser" - data-id="" + className="o-teaser o-teaser--audio o-teaser--top-story js-teaser" + data-id="d1246074-f7d3-4aaf-951c-80a6db495765" > <div className="o-teaser__content" @@ -4935,31 +4175,30 @@ exports[`@financial-times/x-teaser renders a TopStory Paid Post x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-promoted" + <a + aria-label="Category: Tech Tonic podcast" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <span - className="o-teaser__promoted-prefix" - > - Paid post - </span> - <span - className="o-teaser__promoted-by" - > - by UBS - </span> - </div> + Tech Tonic podcast + </a> + <span + className="o-teaser__tag-suffix" + > + 12 mins + </span> </div> <div className="o-teaser__heading" > <a + aria-label="Listen to podcast Who sets the internet standards?" className="js-teaser-heading-link" data-trackable="heading-link" - href="#" + href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" > - Why eSports companies are on a winning streak - + Who sets the internet standards? </a> </div> <p @@ -4968,45 +4207,20 @@ exports[`@financial-times/x-teaser renders a TopStory Paid Post x-teaser 1`] = ` <a className="js-teaser-standfirst-link" data-trackable="standfirst-link" - href="#" + href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" tabIndex={-1} > - ESports is big business and about to get bigger: global revenues could hit $1.5bn by 2020 + Hannah Kuchler talks to American social scientist and cyber security expert Andrea… </a> </p> </div> - <div - className="o-teaser__image-container js-teaser-image-container" - > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2857%", - } - } - > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" - > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Ftpc.googlesyndication.com%2Fpagead%2Fimgad%3Fid%3DCICAgKCrm_3yahABGAEyCMx3RoLss603?source=next&fit=scale-down&compression=best&width=240" - /> - </a> - </div> - </div> </div> `; -exports[`@financial-times/x-teaser renders a TopStory Podcast x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a TopStory teaser with promoted data 1`] = ` <div - className="o-teaser o-teaser--audio o-teaser--top-story o-teaser--has-image js-teaser" - data-id="d1246074-f7d3-4aaf-951c-80a6db495765" + className="o-teaser o-teaser--paid-post o-teaser--top-story js-teaser" + data-id="" > <div className="o-teaser__content" @@ -5014,22 +4228,17 @@ exports[`@financial-times/x-teaser renders a TopStory Podcast x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <span + className="o-teaser__promoted-prefix" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Tech Tonic podcast - </a> - <span - className="o-teaser__tag-suffix" - > - 12 mins - </span> - </div> + Paid post + </span> + by + <span + className="o-teaser__promoted-by" + > + UBS + </span> </div> <div className="o-teaser__heading" @@ -5037,10 +4246,9 @@ exports[`@financial-times/x-teaser renders a TopStory Podcast x-teaser 1`] = ` <a className="js-teaser-heading-link" data-trackable="heading-link" - href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" + href="#" > - Who sets the internet standards? - + Why eSports companies are on a winning streak </a> </div> <p @@ -5049,45 +4257,21 @@ exports[`@financial-times/x-teaser renders a TopStory Podcast x-teaser 1`] = ` <a className="js-teaser-standfirst-link" data-trackable="standfirst-link" - href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" + href="#" tabIndex={-1} > - Hannah Kuchler talks to American social scientist and cyber security expert Andrea… + ESports is big business and about to get bigger: global revenues could hit $1.5bn by 2020 </a> </p> </div> - <div - className="o-teaser__image-container js-teaser-image-container" - > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } - > - <a - aria-hidden="true" - data-trackable="image-link" - href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" - tab-index="-1" - > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Fwww.ft.com%2F__origami%2Fservice%2Fimage%2Fv2%2Fimages%2Fraw%2Fhttp%253A%252F%252Fprod-upp-image-read.ft.com%252F5d1a54aa-f49b-11e8-ae55-df4bf40f9d0d%3Fsource%3Dnext%26fit%3Dscale-down%26compression%3Dbest%26width%3D240?source=next&fit=scale-down&compression=best&width=240" - /> - </a> - </div> - </div> </div> `; -exports[`@financial-times/x-teaser renders a TopStory Top Story x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a TopStory teaser with topStory data 1`] = ` <div - className="o-teaser o-teaser--article o-teaser--top-story o-teaser--landscape o-teaser--has-image js-teaser" + className="o-teaser o-teaser--article o-teaser--top-story js-teaser" data-id="" + data-trackable="slice:hero-slice-1;slicePos:1;layout:standaloneimage;listPos:1" > <div className="o-teaser__content" @@ -5095,17 +4279,14 @@ exports[`@financial-times/x-teaser renders a TopStory Top Story x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Sexual misconduct allegations - </a> - </div> + Sexual misconduct allegations + </a> </div> <div className="o-teaser__heading" @@ -5116,7 +4297,6 @@ exports[`@financial-times/x-teaser renders a TopStory Top Story x-teaser 1`] = ` href="#" > Inside charity fundraiser where hostesses are put on show - </a> </div> <p @@ -5132,31 +4312,6 @@ exports[`@financial-times/x-teaser renders a TopStory Top Story x-teaser 1`] = ` </a> </p> </div> - <div - className="o-teaser__image-container js-teaser-image-container" - > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } - > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" - > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&compression=best&width=640" - /> - </a> - </div> - </div> <ul className="o-teaser__related" > @@ -5197,9 +4352,9 @@ exports[`@financial-times/x-teaser renders a TopStory Top Story x-teaser 1`] = ` </div> `; -exports[`@financial-times/x-teaser renders a TopStory Video x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a TopStory teaser with video data 1`] = ` <div - className="o-teaser o-teaser--video o-teaser--top-story o-teaser--has-video js-teaser" + className="o-teaser o-teaser--video o-teaser--top-story js-teaser" data-id="0e89d872-5711-457b-80b1-4ca0d8afea46" > <div @@ -5208,74 +4363,67 @@ exports[`@financial-times/x-teaser renders a TopStory Video x-teaser 1`] = ` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Global Trade" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Global Trade - </a> - <span - className="o-teaser__tag-suffix" - > - 02:51min - </span> - </div> + Global Trade + </a> + <span + className="o-teaser__tag-suffix" + > + 02:51min + </span> </div> <div - className="o-teaser__video" + className="o-teaser__heading" > - <div - className="o--if-js" + <a + aria-label="Watch video FT View: Donald Trump, man of steel" + className="js-teaser-heading-link" + data-trackable="heading-link" + href="#" > - <div - className="o-teaser__image-container js-image-container" - > - <div - className="o-video" - data-o-component="o-video" - data-o-video-autorender="true" - data-o-video-data="{\\"renditions\\":[{\\"url\\":\\"https://next-media-api.ft.com/renditions/15218247321960/640x360.mp4\\",\\"width\\":640,\\"height\\":360,\\"mediaType\\":\\"video/mp4\\",\\"codec\\":\\"h264\\"}],\\"mainImageUrl\\":\\"http://com.ft.imagepublish.upp-prod-eu.s3.amazonaws.com/a27ce49b-85b8-445b-b883-db6e2f533194\\"}" - data-o-video-id="0e89d872-5711-457b-80b1-4ca0d8afea46" - data-o-video-placeholder="true" - data-o-video-placeholder-hint="Play video" - data-o-video-placeholder-info="[]" - data-o-video-playsinline="true" - /> - </div> - </div> - <div - className="o--if-no-js" + FT View: Donald Trump, man of steel + </a> + </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="#" + tabIndex={-1} > - <div - className="o-teaser__image-container js-teaser-image-container" - > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } - > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" - > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fcom.ft.imagepublish.upp-prod-eu.s3.amazonaws.com%2Fa27ce49b-85b8-445b-b883-db6e2f533194?source=next&fit=scale-down&compression=best&width=420" - /> - </a> - </div> - </div> - </div> + The FT's Rob Armstrong looks at why Donald Trump is pushing trade tariffs + </a> + </p> + </div> +</div> +`; + +exports[`x-teaser / snapshots renders a TopStoryLandscape teaser with article data 1`] = ` +<div + className="o-teaser o-teaser--article o-teaser--top-story o-teaser--landscape o-teaser--has-image o-teaser--highlight js-teaser" + data-id="" +> + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" + > + Sexual misconduct allegations + </a> </div> <div className="o-teaser__heading" @@ -5285,8 +4433,7 @@ exports[`@financial-times/x-teaser renders a TopStory Video x-teaser 1`] = ` data-trackable="heading-link" href="#" > - FT View: Donald Trump, man of steel - + Inside charity fundraiser where hostesses are put on show </a> </div> <p @@ -5298,14 +4445,39 @@ exports[`@financial-times/x-teaser renders a TopStory Video x-teaser 1`] = ` href="#" tabIndex={-1} > - The FT's Rob Armstrong looks at why Donald Trump is pushing trade tariffs + FT investigation finds groping and sexual harassment at secretive black-tie dinner </a> </p> </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&dpr=2&width=640" + /> + </div> + </a> + </div> </div> `; -exports[`@financial-times/x-teaser renders a TopStoryLandscape Article x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a TopStoryLandscape teaser with article-with-data-image data 1`] = ` <div className="o-teaser o-teaser--article o-teaser--top-story o-teaser--landscape o-teaser--has-image o-teaser--highlight js-teaser" data-id="" @@ -5316,17 +4488,14 @@ exports[`@financial-times/x-teaser renders a TopStoryLandscape Article x-teaser <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Sexual misconduct allegations - </a> - </div> + Sexual misconduct allegations + </a> </div> <div className="o-teaser__heading" @@ -5337,7 +4506,6 @@ exports[`@financial-times/x-teaser renders a TopStoryLandscape Article x-teaser href="#" > Inside charity fundraiser where hostesses are put on show - </a> </div> <p @@ -5356,32 +4524,79 @@ exports[`@financial-times/x-teaser renders a TopStoryLandscape Article x-teaser <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&compression=best&width=640" + src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==" /> + </div> + </a> + </div> +</div> +`; + +exports[`x-teaser / snapshots renders a TopStoryLandscape teaser with article-with-missing-image-url data 1`] = ` +<div + className="o-teaser o-teaser--article o-teaser--top-story o-teaser--landscape o-teaser--highlight js-teaser" + data-id="" +> + <div + className="o-teaser__content" + > + <div + className="o-teaser__meta" + > + <a + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" + > + Sexual misconduct allegations + </a> + </div> + <div + className="o-teaser__heading" + > + <a + className="js-teaser-heading-link" + data-trackable="heading-link" + href="#" + > + Inside charity fundraiser where hostesses are put on show </a> </div> + <p + className="o-teaser__standfirst" + > + <a + className="js-teaser-standfirst-link" + data-trackable="standfirst-link" + href="#" + tabIndex={-1} + > + FT investigation finds groping and sexual harassment at secretive black-tie dinner + </a> + </p> </div> </div> `; -exports[`@financial-times/x-teaser renders a TopStoryLandscape Content Package x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a TopStoryLandscape teaser with contentPackage data 1`] = ` <div className="o-teaser o-teaser--package o-teaser--top-story o-teaser--landscape o-teaser--has-image js-teaser" data-id="" @@ -5392,17 +4607,14 @@ exports[`@financial-times/x-teaser renders a TopStoryLandscape Content Package x <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: FT Magazine" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - FT Magazine - </a> - </div> + FT Magazine + </a> </div> <div className="o-teaser__heading" @@ -5413,7 +4625,6 @@ exports[`@financial-times/x-teaser renders a TopStoryLandscape Content Package x href="#" > The royal wedding - </a> </div> <p @@ -5432,32 +4643,32 @@ exports[`@financial-times/x-teaser renders a TopStoryLandscape Content Package x <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F7e97f5b6-578d-11e8-b8b2-d6ceb45fa9d0?source=next&fit=scale-down&compression=best&width=640" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F7e97f5b6-578d-11e8-b8b2-d6ceb45fa9d0?source=next&fit=scale-down&dpr=2&width=640" /> - </a> - </div> + </div> + </a> </div> </div> `; -exports[`@financial-times/x-teaser renders a TopStoryLandscape Opinion Piece x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a TopStoryLandscape teaser with opinion data 1`] = ` <div className="o-teaser o-teaser--article o-teaser--top-story o-teaser--landscape o-teaser--has-headshot o-teaser--opinion js-teaser" data-id="" @@ -5468,17 +4679,14 @@ exports[`@financial-times/x-teaser renders a TopStoryLandscape Opinion Piece x-t <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Gideon Rachman" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Gideon Rachman - </a> - </div> + Gideon Rachman + </a> </div> <div className="o-teaser__heading" @@ -5489,7 +4697,6 @@ exports[`@financial-times/x-teaser renders a TopStoryLandscape Opinion Piece x-t href="#" > Anti-Semitism and the threat of identity politics - </a> </div> <p @@ -5509,14 +4716,14 @@ exports[`@financial-times/x-teaser renders a TopStoryLandscape Opinion Piece x-t aria-hidden="true" className="o-teaser__headshot" height={75} - src="https://www.ft.com/__origami/service/image/v2/images/raw/fthead-v1%3Agideon-rachman?source=next&fit=scale-down&compression=best&width=75&tint=054593,d6d5d3&dpr=2" + src="https://www.ft.com/__origami/service/image/v2/images/raw/fthead-v1%3Agideon-rachman?source=next&fit=scale-down&dpr=2&tint=054593%2Cd6d5d3&width=75" width={75} /> </div> </div> `; -exports[`@financial-times/x-teaser renders a TopStoryLandscape Package item x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a TopStoryLandscape teaser with packageItem data 1`] = ` <div className="o-teaser o-teaser--article o-teaser--top-story o-teaser--landscape o-teaser--has-image o-teaser--opinion js-teaser" data-id="" @@ -5527,22 +4734,19 @@ exports[`@financial-times/x-teaser renders a TopStoryLandscape Package item x-te <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <span + className="o-teaser__tag-prefix" > - <span - className="o-teaser__tag-prefix" - > - FT Series - </span> - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Financial crisis: Are we safer now? - </a> - </div> + FT Series + </span> + <a + aria-label="Category: Financial crisis: Are we safer now? " + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" + > + Financial crisis: Are we safer now? + </a> </div> <div className="o-teaser__heading" @@ -5553,7 +4757,6 @@ exports[`@financial-times/x-teaser renders a TopStoryLandscape Package item x-te href="#" > Why so little has changed since the crash - </a> </div> <p @@ -5572,35 +4775,35 @@ exports[`@financial-times/x-teaser renders a TopStoryLandscape Package item x-te <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&compression=best&width=640" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&dpr=2&width=640" /> - </a> - </div> + </div> + </a> </div> </div> `; -exports[`@financial-times/x-teaser renders a TopStoryLandscape Paid Post x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a TopStoryLandscape teaser with podcast data 1`] = ` <div - className="o-teaser o-teaser--paid-post o-teaser--top-story o-teaser--landscape o-teaser--has-image js-teaser" - data-id="" + className="o-teaser o-teaser--audio o-teaser--top-story o-teaser--landscape o-teaser--has-image js-teaser" + data-id="d1246074-f7d3-4aaf-951c-80a6db495765" > <div className="o-teaser__content" @@ -5608,31 +4811,30 @@ exports[`@financial-times/x-teaser renders a TopStoryLandscape Paid Post x-tease <div className="o-teaser__meta" > - <div - className="o-teaser__meta-promoted" + <a + aria-label="Category: Tech Tonic podcast" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <span - className="o-teaser__promoted-prefix" - > - Paid post - </span> - <span - className="o-teaser__promoted-by" - > - by UBS - </span> - </div> + Tech Tonic podcast + </a> + <span + className="o-teaser__tag-suffix" + > + 12 mins + </span> </div> <div className="o-teaser__heading" > <a + aria-label="Listen to podcast Who sets the internet standards?" className="js-teaser-heading-link" data-trackable="heading-link" - href="#" + href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" > - Why eSports companies are on a winning streak - + Who sets the internet standards? </a> </div> <p @@ -5641,45 +4843,45 @@ exports[`@financial-times/x-teaser renders a TopStoryLandscape Paid Post x-tease <a className="js-teaser-standfirst-link" data-trackable="standfirst-link" - href="#" + href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" tabIndex={-1} > - ESports is big business and about to get bigger: global revenues could hit $1.5bn by 2020 + Hannah Kuchler talks to American social scientist and cyber security expert Andrea… </a> </p> </div> <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2857%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Ftpc.googlesyndication.com%2Fpagead%2Fimgad%3Fid%3DCICAgKCrm_3yahABGAEyCMx3RoLss603?source=next&fit=scale-down&compression=best&width=640" + src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Fwww.ft.com%2F__origami%2Fservice%2Fimage%2Fv2%2Fimages%2Fraw%2Fhttp%253A%252F%252Fprod-upp-image-read.ft.com%252F5d1a54aa-f49b-11e8-ae55-df4bf40f9d0d%3Fsource%3Dnext%26fit%3Dscale-down%26compression%3Dbest%26width%3D240?source=next&fit=scale-down&dpr=2&width=640" /> - </a> - </div> + </div> + </a> </div> </div> `; -exports[`@financial-times/x-teaser renders a TopStoryLandscape Podcast x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a TopStoryLandscape teaser with promoted data 1`] = ` <div - className="o-teaser o-teaser--audio o-teaser--top-story o-teaser--landscape o-teaser--has-image js-teaser" - data-id="d1246074-f7d3-4aaf-951c-80a6db495765" + className="o-teaser o-teaser--paid-post o-teaser--top-story o-teaser--landscape o-teaser--has-image js-teaser" + data-id="" > <div className="o-teaser__content" @@ -5687,22 +4889,17 @@ exports[`@financial-times/x-teaser renders a TopStoryLandscape Podcast x-teaser <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <span + className="o-teaser__promoted-prefix" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Tech Tonic podcast - </a> - <span - className="o-teaser__tag-suffix" - > - 12 mins - </span> - </div> + Paid post + </span> + by + <span + className="o-teaser__promoted-by" + > + UBS + </span> </div> <div className="o-teaser__heading" @@ -5710,10 +4907,9 @@ exports[`@financial-times/x-teaser renders a TopStoryLandscape Podcast x-teaser <a className="js-teaser-heading-link" data-trackable="heading-link" - href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" + href="#" > - Who sets the internet standards? - + Why eSports companies are on a winning streak </a> </div> <p @@ -5722,45 +4918,46 @@ exports[`@financial-times/x-teaser renders a TopStoryLandscape Podcast x-teaser <a className="js-teaser-standfirst-link" data-trackable="standfirst-link" - href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" + href="#" tabIndex={-1} > - Hannah Kuchler talks to American social scientist and cyber security expert Andrea… + ESports is big business and about to get bigger: global revenues could hit $1.5bn by 2020 </a> </p> </div> <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2857%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Fwww.ft.com%2F__origami%2Fservice%2Fimage%2Fv2%2Fimages%2Fraw%2Fhttp%253A%252F%252Fprod-upp-image-read.ft.com%252F5d1a54aa-f49b-11e8-ae55-df4bf40f9d0d%3Fsource%3Dnext%26fit%3Dscale-down%26compression%3Dbest%26width%3D240?source=next&fit=scale-down&compression=best&width=640" + src="https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Ftpc.googlesyndication.com%2Fpagead%2Fimgad%3Fid%3DCICAgKCrm_3yahABGAEyCMx3RoLss603?source=next&fit=scale-down&dpr=2&width=640" /> - </a> - </div> + </div> + </a> </div> </div> `; -exports[`@financial-times/x-teaser renders a TopStoryLandscape Top Story x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a TopStoryLandscape teaser with topStory data 1`] = ` <div className="o-teaser o-teaser--article o-teaser--top-story o-teaser--landscape o-teaser--has-image js-teaser" data-id="" + data-trackable="slice:hero-slice-1;slicePos:1;layout:standaloneimage;listPos:1" > <div className="o-teaser__content" @@ -5768,17 +4965,14 @@ exports[`@financial-times/x-teaser renders a TopStoryLandscape Top Story x-tease <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" + <a + aria-label="Category: Sexual misconduct allegations" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Sexual misconduct allegations - </a> - </div> + Sexual misconduct allegations + </a> </div> <div className="o-teaser__heading" @@ -5789,7 +4983,6 @@ exports[`@financial-times/x-teaser renders a TopStoryLandscape Top Story x-tease href="#" > Inside charity fundraiser where hostesses are put on show - </a> </div> <p @@ -5808,27 +5001,27 @@ exports[`@financial-times/x-teaser renders a TopStoryLandscape Top Story x-tease <div className="o-teaser__image-container js-teaser-image-container" > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } > <img alt="" className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&compression=best&width=640" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2Fa25832ea-0053-11e8-9650-9c0ad2d7c5b5?source=next&fit=scale-down&dpr=2&width=640" /> - </a> - </div> + </div> + </a> </div> <ul className="o-teaser__related" @@ -5870,9 +5063,9 @@ exports[`@financial-times/x-teaser renders a TopStoryLandscape Top Story x-tease </div> `; -exports[`@financial-times/x-teaser renders a TopStoryLandscape Video x-teaser 1`] = ` +exports[`x-teaser / snapshots renders a TopStoryLandscape teaser with video data 1`] = ` <div - className="o-teaser o-teaser--video o-teaser--top-story o-teaser--landscape o-teaser--has-video js-teaser" + className="o-teaser o-teaser--video o-teaser--top-story o-teaser--landscape o-teaser--has-image js-teaser" data-id="0e89d872-5711-457b-80b1-4ca0d8afea46" > <div @@ -5881,85 +5074,30 @@ exports[`@financial-times/x-teaser renders a TopStoryLandscape Video x-teaser 1` <div className="o-teaser__meta" > - <div - className="o-teaser__meta-tag" - > - <a - className="o-teaser__tag" - data-trackable="teaser-tag" - href="#" - > - Global Trade - </a> - <span - className="o-teaser__tag-suffix" - > - 02:51min - </span> - </div> - </div> - <div - className="o-teaser__video" - > - <div - className="o--if-js" + <a + aria-label="Category: Global Trade" + className="o-teaser__tag" + data-trackable="teaser-tag" + href="#" > - <div - className="o-teaser__image-container js-image-container" - > - <div - className="o-video" - data-o-component="o-video" - data-o-video-autorender="true" - data-o-video-data="{\\"renditions\\":[{\\"url\\":\\"https://next-media-api.ft.com/renditions/15218247321960/640x360.mp4\\",\\"width\\":640,\\"height\\":360,\\"mediaType\\":\\"video/mp4\\",\\"codec\\":\\"h264\\"}],\\"mainImageUrl\\":\\"http://com.ft.imagepublish.upp-prod-eu.s3.amazonaws.com/a27ce49b-85b8-445b-b883-db6e2f533194\\"}" - data-o-video-id="0e89d872-5711-457b-80b1-4ca0d8afea46" - data-o-video-placeholder="true" - data-o-video-placeholder-hint="Play video" - data-o-video-placeholder-info="[]" - data-o-video-playsinline="true" - /> - </div> - </div> - <div - className="o--if-no-js" + Global Trade + </a> + <span + className="o-teaser__tag-suffix" > - <div - className="o-teaser__image-container js-teaser-image-container" - > - <div - className="o-teaser__image-placeholder" - style={ - Object { - "paddingBottom": "56.2500%", - } - } - > - <a - aria-hidden="true" - data-trackable="image-link" - href="#" - tab-index="-1" - > - <img - alt="" - className="o-teaser__image" - src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fcom.ft.imagepublish.upp-prod-eu.s3.amazonaws.com%2Fa27ce49b-85b8-445b-b883-db6e2f533194?source=next&fit=scale-down&compression=best&width=640" - /> - </a> - </div> - </div> - </div> + 02:51min + </span> </div> <div className="o-teaser__heading" > <a + aria-label="Watch video FT View: Donald Trump, man of steel" className="js-teaser-heading-link" data-trackable="heading-link" href="#" > FT View: Donald Trump, man of steel - </a> </div> <p @@ -5975,5 +5113,30 @@ exports[`@financial-times/x-teaser renders a TopStoryLandscape Video x-teaser 1` </a> </p> </div> + <div + className="o-teaser__image-container js-teaser-image-container" + > + <a + aria-hidden="true" + data-trackable="image-link" + href="#" + tabIndex="-1" + > + <div + className="o-teaser__image-placeholder" + style={ + Object { + "paddingBottom": "56.2500%", + } + } + > + <img + alt="" + className="o-teaser__image" + src="https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fcom.ft.imagepublish.upp-prod-eu.s3.amazonaws.com%2Fa27ce49b-85b8-445b-b883-db6e2f533194?source=next&fit=scale-down&dpr=2&width=640" + /> + </div> + </a> + </div> </div> `; diff --git a/components/x-teaser/__tests__/snapshots.test.js b/components/x-teaser/__tests__/snapshots.test.js new file mode 100644 index 000000000..cdd0b2ddf --- /dev/null +++ b/components/x-teaser/__tests__/snapshots.test.js @@ -0,0 +1,29 @@ +const { h } = require('@financial-times/x-engine') +const renderer = require('react-test-renderer') +const { Teaser, presets } = require('../') + +const storyData = { + article: require('../__fixtures__/article.json'), + 'article-with-data-image': require('../__fixtures__/article-with-data-image.json'), + 'article-with-missing-image-url': require('../__fixtures__/article-with-missing-image-url.json'), + opinion: require('../__fixtures__/opinion.json'), + contentPackage: require('../__fixtures__/content-package.json'), + packageItem: require('../__fixtures__/package-item.json'), + podcast: require('../__fixtures__/podcast.json'), + video: require('../__fixtures__/video.json'), + promoted: require('../__fixtures__/promoted.json'), + topStory: require('../__fixtures__/top-story.json') +} + +describe('x-teaser / snapshots', () => { + Object.entries(storyData).forEach(([type, data]) => { + Object.entries(presets).forEach(([name, settings]) => { + it(`renders a ${name} teaser with ${type} data`, () => { + const props = { ...data, ...settings } + const tree = renderer.create(h(Teaser, props)).toJSON() + + expect(tree).toMatchSnapshot() + }) + }) + }) +}) diff --git a/components/x-teaser/package-lock.json b/components/x-teaser/package-lock.json new file mode 100644 index 000000000..2880e9b32 --- /dev/null +++ b/components/x-teaser/package-lock.json @@ -0,0 +1,6223 @@ +{ + "name": "@financial-times/x-teaser", + "version": "0.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@financial-times/x-engine": { + "version": "file:../../packages/x-engine", + "requires": { + "assign-deep": "^1.0.0" + }, + "dependencies": { + "assign-deep": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/assign-deep/-/assign-deep-1.0.1.tgz", + "integrity": "sha512-CSXAX79mibneEYfqLT5FEmkqR5WXF+xDRjgQQuVf6wSCXCYU8/vHttPidNar7wJ5BFmKAo8Wei0rCtzb+M/yeA==", + "requires": { + "assign-symbols": "^2.0.2" + } + }, + "assign-symbols": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-2.0.2.tgz", + "integrity": "sha512-9sBQUQZMKFKcO/C3Bo6Rx4CQany0R0UeVcefNGRRdW2vbmaMOhV1sbmlXcQLcD56juLXbSGTBm0GGuvmrAF8pA==" + } + } + }, + "@financial-times/x-rollup": { + "version": "file:../../packages/x-rollup", + "dev": true, + "requires": { + "@babel/core": "^7.6.4", + "@babel/plugin-external-helpers": "^7.2.0", + "@financial-times/x-babel-config": "file:../../packages/x-babel-config", + "chalk": "^2.4.2", + "log-symbols": "^3.0.0", + "rollup": "^1.23.0", + "rollup-plugin-babel": "^4.3.2", + "rollup-plugin-commonjs": "^10.1.0", + "rollup-plugin-postcss": "^2.0.2" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "dev": true, + "requires": { + "@babel/highlight": "^7.10.4" + } + }, + "@babel/core": { + "version": "7.11.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.11.6.tgz", + "integrity": "sha512-Wpcv03AGnmkgm6uS6k8iwhIwTrcP0m17TL1n1sy7qD0qelDu4XNeW0dN0mHfa+Gei211yDaLoEe/VlbXQzM4Bg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/generator": "^7.11.6", + "@babel/helper-module-transforms": "^7.11.0", + "@babel/helpers": "^7.10.4", + "@babel/parser": "^7.11.5", + "@babel/template": "^7.10.4", + "@babel/traverse": "^7.11.5", + "@babel/types": "^7.11.5", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.1", + "json5": "^2.1.2", + "lodash": "^4.17.19", + "resolve": "^1.3.2", + "semver": "^5.4.1", + "source-map": "^0.5.0" + } + }, + "@babel/generator": { + "version": "7.11.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.11.6.tgz", + "integrity": "sha512-DWtQ1PV3r+cLbySoHrwn9RWEgKMBLLma4OBQloPRyDYvc5msJM9kvTLo1YnlJd1P/ZuKbdli3ijr5q3FvAF3uA==", + "dev": true, + "requires": { + "@babel/types": "^7.11.5", + "jsesc": "^2.5.1", + "source-map": "^0.5.0" + } + }, + "@babel/helper-function-name": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.10.4.tgz", + "integrity": "sha512-YdaSyz1n8gY44EmN7x44zBn9zQ1Ry2Y+3GTA+3vH6Mizke1Vw0aWDM66FOYEPw8//qKkmqOckrGgTYa+6sceqQ==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.10.4", + "@babel/template": "^7.10.4", + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.4.tgz", + "integrity": "sha512-EkN3YDB+SRDgiIUnNgcmiD361ti+AVbL3f3Henf6dqqUyr5dMsorno0lJWJuLhDhkI5sYEpgj6y9kB8AOU1I2A==", + "dev": true, + "requires": { + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.11.0.tgz", + "integrity": "sha512-JbFlKHFntRV5qKw3YC0CvQnDZ4XMwgzzBbld7Ly4Mj4cbFy3KywcR8NtNctRToMWJOVvLINJv525Gd6wwVEx/Q==", + "dev": true, + "requires": { + "@babel/types": "^7.11.0" + } + }, + "@babel/helper-module-imports": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.10.4.tgz", + "integrity": "sha512-nEQJHqYavI217oD9+s5MUBzk6x1IlvoS9WTPfgG43CbMEeStE0v+r+TucWdx8KFGowPGvyOkDT9+7DHedIDnVw==", + "dev": true, + "requires": { + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-module-transforms": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.11.0.tgz", + "integrity": "sha512-02EVu8COMuTRO1TAzdMtpBPbe6aQ1w/8fePD2YgQmxZU4gpNWaL9gK3Jp7dxlkUlUCJOTaSeA+Hrm1BRQwqIhg==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.10.4", + "@babel/helper-replace-supers": "^7.10.4", + "@babel/helper-simple-access": "^7.10.4", + "@babel/helper-split-export-declaration": "^7.11.0", + "@babel/template": "^7.10.4", + "@babel/types": "^7.11.0", + "lodash": "^4.17.19" + } + }, + "@babel/helper-optimise-call-expression": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.4.tgz", + "integrity": "sha512-n3UGKY4VXwXThEiKrgRAoVPBMqeoPgHVqiHZOanAJCG9nQUL2pLRQirUzl0ioKclHGpGqRgIOkgcIJaIWLpygg==", + "dev": true, + "requires": { + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz", + "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==", + "dev": true + }, + "@babel/helper-replace-supers": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.10.4.tgz", + "integrity": "sha512-sPxZfFXocEymYTdVK1UNmFPBN+Hv5mJkLPsYWwGBxZAxaWfFu+xqp7b6qWD0yjNuNL2VKc6L5M18tOXUP7NU0A==", + "dev": true, + "requires": { + "@babel/helper-member-expression-to-functions": "^7.10.4", + "@babel/helper-optimise-call-expression": "^7.10.4", + "@babel/traverse": "^7.10.4", + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-simple-access": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.10.4.tgz", + "integrity": "sha512-0fMy72ej/VEvF8ULmX6yb5MtHG4uH4Dbd6I/aHDb/JVg0bbivwt9Wg+h3uMvX+QSFtwr5MeItvazbrc4jtRAXw==", + "dev": true, + "requires": { + "@babel/template": "^7.10.4", + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.11.0.tgz", + "integrity": "sha512-74Vejvp6mHkGE+m+k5vHY93FX2cAtrw1zXrZXRlG4l410Nm9PxfEiVTn1PjDPV5SnmieiueY4AFg2xqhNFuuZg==", + "dev": true, + "requires": { + "@babel/types": "^7.11.0" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz", + "integrity": "sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==", + "dev": true + }, + "@babel/helpers": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.10.4.tgz", + "integrity": "sha512-L2gX/XeUONeEbI78dXSrJzGdz4GQ+ZTA/aazfUsFaWjSe95kiCuOZ5HsXvkiw3iwF+mFHSRUfJU8t6YavocdXA==", + "dev": true, + "requires": { + "@babel/template": "^7.10.4", + "@babel/traverse": "^7.10.4", + "@babel/types": "^7.10.4" + } + }, + "@babel/highlight": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.4.tgz", + "integrity": "sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.10.4", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.11.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.11.5.tgz", + "integrity": "sha512-X9rD8qqm695vgmeaQ4fvz/o3+Wk4ZzQvSHkDBgpYKxpD4qTAUm88ZKtHkVqIOsYFFbIQ6wQYhC6q7pjqVK0E0Q==", + "dev": true + }, + "@babel/plugin-external-helpers": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-external-helpers/-/plugin-external-helpers-7.10.4.tgz", + "integrity": "sha512-5mASqSthmRNYVXOphYzlqmR3Y8yp5SZMZhtKDh2DGV3R2PWGLEmP7qOahw66//6m4hjhlpV1bVM7xIJHt1F77Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/template": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.10.4.tgz", + "integrity": "sha512-ZCjD27cGJFUB6nmCB1Enki3r+L5kJveX9pq1SvAUKoICy6CZ9yD8xO086YXdYhvNjBdnekm4ZnaP5yC8Cs/1tA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/parser": "^7.10.4", + "@babel/types": "^7.10.4" + } + }, + "@babel/traverse": { + "version": "7.11.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.11.5.tgz", + "integrity": "sha512-EjiPXt+r7LiCZXEfRpSJd+jUMnBd4/9OUv7Nx3+0u9+eimMwJmG0Q98lw4/289JCoxSE8OolDMNZaaF/JZ69WQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/generator": "^7.11.5", + "@babel/helper-function-name": "^7.10.4", + "@babel/helper-split-export-declaration": "^7.11.0", + "@babel/parser": "^7.11.5", + "@babel/types": "^7.11.5", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.19" + } + }, + "@babel/types": { + "version": "7.11.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.11.5.tgz", + "integrity": "sha512-bvM7Qz6eKnJVFIn+1LPtjlBFPVN5jNDc1XmN15vWe7Q3DPBufWWsLiIvUu7xW87uTG6QoggpIDnUgLQvPheU+Q==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.10.4", + "lodash": "^4.17.19", + "to-fast-properties": "^2.0.0" + } + }, + "@financial-times/x-babel-config": { + "version": "file:../../packages/x-babel-config", + "dev": true, + "requires": { + "@babel/plugin-transform-react-jsx": "^7.3.0", + "@babel/preset-env": "^7.4.3", + "babel-jest": "^24.0.0", + "fast-async": "^7.0.6" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "dev": true, + "requires": { + "@babel/highlight": "^7.10.4" + } + }, + "@babel/compat-data": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.11.0.tgz", + "integrity": "sha512-TPSvJfv73ng0pfnEOh17bYMPQbI95+nGWc71Ss4vZdRBHTDqmM9Z8ZV4rYz8Ks7sfzc95n30k6ODIq5UGnXcYQ==", + "dev": true, + "requires": { + "browserslist": "^4.12.0", + "invariant": "^2.2.4", + "semver": "^5.5.0" + } + }, + "@babel/core": { + "version": "7.11.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.11.6.tgz", + "integrity": "sha512-Wpcv03AGnmkgm6uS6k8iwhIwTrcP0m17TL1n1sy7qD0qelDu4XNeW0dN0mHfa+Gei211yDaLoEe/VlbXQzM4Bg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/generator": "^7.11.6", + "@babel/helper-module-transforms": "^7.11.0", + "@babel/helpers": "^7.10.4", + "@babel/parser": "^7.11.5", + "@babel/template": "^7.10.4", + "@babel/traverse": "^7.11.5", + "@babel/types": "^7.11.5", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.1", + "json5": "^2.1.2", + "lodash": "^4.17.19", + "resolve": "^1.3.2", + "semver": "^5.4.1", + "source-map": "^0.5.0" + } + }, + "@babel/generator": { + "version": "7.11.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.11.6.tgz", + "integrity": "sha512-DWtQ1PV3r+cLbySoHrwn9RWEgKMBLLma4OBQloPRyDYvc5msJM9kvTLo1YnlJd1P/ZuKbdli3ijr5q3FvAF3uA==", + "dev": true, + "requires": { + "@babel/types": "^7.11.5", + "jsesc": "^2.5.1", + "source-map": "^0.5.0" + } + }, + "@babel/helper-annotate-as-pure": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.10.4.tgz", + "integrity": "sha512-XQlqKQP4vXFB7BN8fEEerrmYvHp3fK/rBkRFz9jaJbzK0B1DSfej9Kc7ZzE8Z/OnId1jpJdNAZ3BFQjWG68rcA==", + "dev": true, + "requires": { + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.10.4.tgz", + "integrity": "sha512-L0zGlFrGWZK4PbT8AszSfLTM5sDU1+Az/En9VrdT8/LmEiJt4zXt+Jve9DCAnQcbqDhCI+29y/L93mrDzddCcg==", + "dev": true, + "requires": { + "@babel/helper-explode-assignable-expression": "^7.10.4", + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-builder-react-jsx": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-react-jsx/-/helper-builder-react-jsx-7.10.4.tgz", + "integrity": "sha512-5nPcIZ7+KKDxT1427oBivl9V9YTal7qk0diccnh7RrcgrT/pGFOjgGw1dgryyx1GvHEpXVfoDF6Ak3rTiWh8Rg==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.10.4", + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-builder-react-jsx-experimental": { + "version": "7.11.5", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-react-jsx-experimental/-/helper-builder-react-jsx-experimental-7.11.5.tgz", + "integrity": "sha512-Vc4aPJnRZKWfzeCBsqTBnzulVNjABVdahSPhtdMD3Vs80ykx4a87jTHtF/VR+alSrDmNvat7l13yrRHauGcHVw==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.10.4", + "@babel/helper-module-imports": "^7.10.4", + "@babel/types": "^7.11.5" + } + }, + "@babel/helper-compilation-targets": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.10.4.tgz", + "integrity": "sha512-a3rYhlsGV0UHNDvrtOXBg8/OpfV0OKTkxKPzIplS1zpx7CygDcWWxckxZeDd3gzPzC4kUT0A4nVFDK0wGMh4MQ==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.10.4", + "browserslist": "^4.12.0", + "invariant": "^2.2.4", + "levenary": "^1.1.1", + "semver": "^5.5.0" + } + }, + "@babel/helper-create-class-features-plugin": { + "version": "7.10.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.10.5.tgz", + "integrity": "sha512-0nkdeijB7VlZoLT3r/mY3bUkw3T8WG/hNw+FATs/6+pG2039IJWjTYL0VTISqsNHMUTEnwbVnc89WIJX9Qed0A==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.10.4", + "@babel/helper-member-expression-to-functions": "^7.10.5", + "@babel/helper-optimise-call-expression": "^7.10.4", + "@babel/helper-plugin-utils": "^7.10.4", + "@babel/helper-replace-supers": "^7.10.4", + "@babel/helper-split-export-declaration": "^7.10.4" + } + }, + "@babel/helper-create-regexp-features-plugin": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.10.4.tgz", + "integrity": "sha512-2/hu58IEPKeoLF45DBwx3XFqsbCXmkdAay4spVr2x0jYgRxrSNp+ePwvSsy9g6YSaNDcKIQVPXk1Ov8S2edk2g==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.10.4", + "@babel/helper-regex": "^7.10.4", + "regexpu-core": "^4.7.0" + } + }, + "@babel/helper-define-map": { + "version": "7.10.5", + "resolved": "https://registry.npmjs.org/@babel/helper-define-map/-/helper-define-map-7.10.5.tgz", + "integrity": "sha512-fMw4kgFB720aQFXSVaXr79pjjcW5puTCM16+rECJ/plGS+zByelE8l9nCpV1GibxTnFVmUuYG9U8wYfQHdzOEQ==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.10.4", + "@babel/types": "^7.10.5", + "lodash": "^4.17.19" + } + }, + "@babel/helper-explode-assignable-expression": { + "version": "7.11.4", + "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.11.4.tgz", + "integrity": "sha512-ux9hm3zR4WV1Y3xXxXkdG/0gxF9nvI0YVmKVhvK9AfMoaQkemL3sJpXw+Xbz65azo8qJiEz2XVDUpK3KYhH3ZQ==", + "dev": true, + "requires": { + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-function-name": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.10.4.tgz", + "integrity": "sha512-YdaSyz1n8gY44EmN7x44zBn9zQ1Ry2Y+3GTA+3vH6Mizke1Vw0aWDM66FOYEPw8//qKkmqOckrGgTYa+6sceqQ==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.10.4", + "@babel/template": "^7.10.4", + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.4.tgz", + "integrity": "sha512-EkN3YDB+SRDgiIUnNgcmiD361ti+AVbL3f3Henf6dqqUyr5dMsorno0lJWJuLhDhkI5sYEpgj6y9kB8AOU1I2A==", + "dev": true, + "requires": { + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-hoist-variables": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.10.4.tgz", + "integrity": "sha512-wljroF5PgCk2juF69kanHVs6vrLwIPNp6DLD+Lrl3hoQ3PpPPikaDRNFA+0t81NOoMt2DL6WW/mdU8k4k6ZzuA==", + "dev": true, + "requires": { + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.11.0.tgz", + "integrity": "sha512-JbFlKHFntRV5qKw3YC0CvQnDZ4XMwgzzBbld7Ly4Mj4cbFy3KywcR8NtNctRToMWJOVvLINJv525Gd6wwVEx/Q==", + "dev": true, + "requires": { + "@babel/types": "^7.11.0" + } + }, + "@babel/helper-module-imports": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.10.4.tgz", + "integrity": "sha512-nEQJHqYavI217oD9+s5MUBzk6x1IlvoS9WTPfgG43CbMEeStE0v+r+TucWdx8KFGowPGvyOkDT9+7DHedIDnVw==", + "dev": true, + "requires": { + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-module-transforms": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.11.0.tgz", + "integrity": "sha512-02EVu8COMuTRO1TAzdMtpBPbe6aQ1w/8fePD2YgQmxZU4gpNWaL9gK3Jp7dxlkUlUCJOTaSeA+Hrm1BRQwqIhg==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.10.4", + "@babel/helper-replace-supers": "^7.10.4", + "@babel/helper-simple-access": "^7.10.4", + "@babel/helper-split-export-declaration": "^7.11.0", + "@babel/template": "^7.10.4", + "@babel/types": "^7.11.0", + "lodash": "^4.17.19" + } + }, + "@babel/helper-optimise-call-expression": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.4.tgz", + "integrity": "sha512-n3UGKY4VXwXThEiKrgRAoVPBMqeoPgHVqiHZOanAJCG9nQUL2pLRQirUzl0ioKclHGpGqRgIOkgcIJaIWLpygg==", + "dev": true, + "requires": { + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz", + "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==", + "dev": true + }, + "@babel/helper-regex": { + "version": "7.10.5", + "resolved": "https://registry.npmjs.org/@babel/helper-regex/-/helper-regex-7.10.5.tgz", + "integrity": "sha512-68kdUAzDrljqBrio7DYAEgCoJHxppJOERHOgOrDN7WjOzP0ZQ1LsSDRXcemzVZaLvjaJsJEESb6qt+znNuENDg==", + "dev": true, + "requires": { + "lodash": "^4.17.19" + } + }, + "@babel/helper-remap-async-to-generator": { + "version": "7.11.4", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.11.4.tgz", + "integrity": "sha512-tR5vJ/vBa9wFy3m5LLv2faapJLnDFxNWff2SAYkSE4rLUdbp7CdObYFgI7wK4T/Mj4UzpjPwzR8Pzmr5m7MHGA==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.10.4", + "@babel/helper-wrap-function": "^7.10.4", + "@babel/template": "^7.10.4", + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-replace-supers": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.10.4.tgz", + "integrity": "sha512-sPxZfFXocEymYTdVK1UNmFPBN+Hv5mJkLPsYWwGBxZAxaWfFu+xqp7b6qWD0yjNuNL2VKc6L5M18tOXUP7NU0A==", + "dev": true, + "requires": { + "@babel/helper-member-expression-to-functions": "^7.10.4", + "@babel/helper-optimise-call-expression": "^7.10.4", + "@babel/traverse": "^7.10.4", + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-simple-access": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.10.4.tgz", + "integrity": "sha512-0fMy72ej/VEvF8ULmX6yb5MtHG4uH4Dbd6I/aHDb/JVg0bbivwt9Wg+h3uMvX+QSFtwr5MeItvazbrc4jtRAXw==", + "dev": true, + "requires": { + "@babel/template": "^7.10.4", + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.11.0.tgz", + "integrity": "sha512-0XIdiQln4Elglgjbwo9wuJpL/K7AGCY26kmEt0+pRP0TAj4jjyNq1MjoRvikrTVqKcx4Gysxt4cXvVFXP/JO2Q==", + "dev": true, + "requires": { + "@babel/types": "^7.11.0" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.11.0.tgz", + "integrity": "sha512-74Vejvp6mHkGE+m+k5vHY93FX2cAtrw1zXrZXRlG4l410Nm9PxfEiVTn1PjDPV5SnmieiueY4AFg2xqhNFuuZg==", + "dev": true, + "requires": { + "@babel/types": "^7.11.0" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz", + "integrity": "sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==", + "dev": true + }, + "@babel/helper-wrap-function": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.10.4.tgz", + "integrity": "sha512-6py45WvEF0MhiLrdxtRjKjufwLL1/ob2qDJgg5JgNdojBAZSAKnAjkyOCNug6n+OBl4VW76XjvgSFTdaMcW0Ug==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.10.4", + "@babel/template": "^7.10.4", + "@babel/traverse": "^7.10.4", + "@babel/types": "^7.10.4" + } + }, + "@babel/helpers": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.10.4.tgz", + "integrity": "sha512-L2gX/XeUONeEbI78dXSrJzGdz4GQ+ZTA/aazfUsFaWjSe95kiCuOZ5HsXvkiw3iwF+mFHSRUfJU8t6YavocdXA==", + "dev": true, + "requires": { + "@babel/template": "^7.10.4", + "@babel/traverse": "^7.10.4", + "@babel/types": "^7.10.4" + } + }, + "@babel/highlight": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.4.tgz", + "integrity": "sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.10.4", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.11.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.11.5.tgz", + "integrity": "sha512-X9rD8qqm695vgmeaQ4fvz/o3+Wk4ZzQvSHkDBgpYKxpD4qTAUm88ZKtHkVqIOsYFFbIQ6wQYhC6q7pjqVK0E0Q==", + "dev": true + }, + "@babel/plugin-proposal-async-generator-functions": { + "version": "7.10.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.10.5.tgz", + "integrity": "sha512-cNMCVezQbrRGvXJwm9fu/1sJj9bHdGAgKodZdLqOQIpfoH3raqmRPBM17+lh7CzhiKRRBrGtZL9WcjxSoGYUSg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4", + "@babel/helper-remap-async-to-generator": "^7.10.4", + "@babel/plugin-syntax-async-generators": "^7.8.0" + } + }, + "@babel/plugin-proposal-class-properties": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.10.4.tgz", + "integrity": "sha512-vhwkEROxzcHGNu2mzUC0OFFNXdZ4M23ib8aRRcJSsW8BZK9pQMD7QB7csl97NBbgGZO7ZyHUyKDnxzOaP4IrCg==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.10.4", + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-proposal-dynamic-import": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.10.4.tgz", + "integrity": "sha512-up6oID1LeidOOASNXgv/CFbgBqTuKJ0cJjz6An5tWD+NVBNlp3VNSBxv2ZdU7SYl3NxJC7agAQDApZusV6uFwQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4", + "@babel/plugin-syntax-dynamic-import": "^7.8.0" + } + }, + "@babel/plugin-proposal-export-namespace-from": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.10.4.tgz", + "integrity": "sha512-aNdf0LY6/3WXkhh0Fdb6Zk9j1NMD8ovj3F6r0+3j837Pn1S1PdNtcwJ5EG9WkVPNHPxyJDaxMaAOVq4eki0qbg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3" + } + }, + "@babel/plugin-proposal-json-strings": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.10.4.tgz", + "integrity": "sha512-fCL7QF0Jo83uy1K0P2YXrfX11tj3lkpN7l4dMv9Y9VkowkhkQDwFHFd8IiwyK5MZjE8UpbgokkgtcReH88Abaw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.0" + } + }, + "@babel/plugin-proposal-logical-assignment-operators": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.11.0.tgz", + "integrity": "sha512-/f8p4z+Auz0Uaf+i8Ekf1iM7wUNLcViFUGiPxKeXvxTSl63B875YPiVdUDdem7hREcI0E0kSpEhS8tF5RphK7Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" + } + }, + "@babel/plugin-proposal-nullish-coalescing-operator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.10.4.tgz", + "integrity": "sha512-wq5n1M3ZUlHl9sqT2ok1T2/MTt6AXE0e1Lz4WzWBr95LsAZ5qDXe4KnFuauYyEyLiohvXFMdbsOTMyLZs91Zlw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.0" + } + }, + "@babel/plugin-proposal-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.10.4.tgz", + "integrity": "sha512-73/G7QoRoeNkLZFxsoCCvlg4ezE4eM+57PnOqgaPOozd5myfj7p0muD1mRVJvbUWbOzD+q3No2bWbaKy+DJ8DA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4", + "@babel/plugin-syntax-numeric-separator": "^7.10.4" + } + }, + "@babel/plugin-proposal-object-rest-spread": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.11.0.tgz", + "integrity": "sha512-wzch41N4yztwoRw0ak+37wxwJM2oiIiy6huGCoqkvSTA9acYWcPfn9Y4aJqmFFJ70KTJUu29f3DQ43uJ9HXzEA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.0", + "@babel/plugin-transform-parameters": "^7.10.4" + } + }, + "@babel/plugin-proposal-optional-catch-binding": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.10.4.tgz", + "integrity": "sha512-LflT6nPh+GK2MnFiKDyLiqSqVHkQnVf7hdoAvyTnnKj9xB3docGRsdPuxp6qqqW19ifK3xgc9U5/FwrSaCNX5g==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.0" + } + }, + "@babel/plugin-proposal-optional-chaining": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.11.0.tgz", + "integrity": "sha512-v9fZIu3Y8562RRwhm1BbMRxtqZNFmFA2EG+pT2diuU8PT3H6T/KXoZ54KgYisfOFZHV6PfvAiBIZ9Rcz+/JCxA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4", + "@babel/helper-skip-transparent-expression-wrappers": "^7.11.0", + "@babel/plugin-syntax-optional-chaining": "^7.8.0" + } + }, + "@babel/plugin-proposal-private-methods": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.10.4.tgz", + "integrity": "sha512-wh5GJleuI8k3emgTg5KkJK6kHNsGEr0uBTDBuQUBJwckk9xs1ez79ioheEVVxMLyPscB0LfkbVHslQqIzWV6Bw==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.10.4", + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-proposal-unicode-property-regex": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.10.4.tgz", + "integrity": "sha512-H+3fOgPnEXFL9zGYtKQe4IDOPKYlZdF1kqFDQRRb8PK4B8af1vAGK04tF5iQAAsui+mHNBQSAtd2/ndEDe9wuA==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.10.4", + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-class-properties": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.10.4.tgz", + "integrity": "sha512-GCSBF7iUle6rNugfURwNmCGG3Z/2+opxAMLs1nND4bhEG5PuxTIggDBoeYYSujAlLtsupzOHYJQgPS3pivwXIA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-export-namespace-from": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", + "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-jsx": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.10.4.tgz", + "integrity": "sha512-KCg9mio9jwiARCB7WAcQ7Y1q+qicILjoK8LP/VkPkEKaf5dkaZZK1EcTe91a3JJlZ3qy6L5s9X52boEYi8DM9g==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-top-level-await": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.10.4.tgz", + "integrity": "sha512-ni1brg4lXEmWyafKr0ccFWkJG0CeMt4WV1oyeBW6EFObF4oOHclbkj5cARxAPQyAQ2UTuplJyK4nfkXIMMFvsQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-transform-arrow-functions": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.10.4.tgz", + "integrity": "sha512-9J/oD1jV0ZCBcgnoFWFq1vJd4msoKb/TCpGNFyyLt0zABdcvgK3aYikZ8HjzB14c26bc7E3Q1yugpwGy2aTPNA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-transform-async-to-generator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.10.4.tgz", + "integrity": "sha512-F6nREOan7J5UXTLsDsZG3DXmZSVofr2tGNwfdrVwkDWHfQckbQXnXSPfD7iO+c/2HGqycwyLST3DnZ16n+cBJQ==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.10.4", + "@babel/helper-plugin-utils": "^7.10.4", + "@babel/helper-remap-async-to-generator": "^7.10.4" + } + }, + "@babel/plugin-transform-block-scoped-functions": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.10.4.tgz", + "integrity": "sha512-WzXDarQXYYfjaV1szJvN3AD7rZgZzC1JtjJZ8dMHUyiK8mxPRahynp14zzNjU3VkPqPsO38CzxiWO1c9ARZ8JA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-transform-block-scoping": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.11.1.tgz", + "integrity": "sha512-00dYeDE0EVEHuuM+26+0w/SCL0BH2Qy7LwHuI4Hi4MH5gkC8/AqMN5uWFJIsoXZrAphiMm1iXzBw6L2T+eA0ew==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-transform-classes": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.10.4.tgz", + "integrity": "sha512-2oZ9qLjt161dn1ZE0Ms66xBncQH4In8Sqw1YWgBUZuGVJJS5c0OFZXL6dP2MRHrkU/eKhWg8CzFJhRQl50rQxA==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.10.4", + "@babel/helper-define-map": "^7.10.4", + "@babel/helper-function-name": "^7.10.4", + "@babel/helper-optimise-call-expression": "^7.10.4", + "@babel/helper-plugin-utils": "^7.10.4", + "@babel/helper-replace-supers": "^7.10.4", + "@babel/helper-split-export-declaration": "^7.10.4", + "globals": "^11.1.0" + } + }, + "@babel/plugin-transform-computed-properties": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.10.4.tgz", + "integrity": "sha512-JFwVDXcP/hM/TbyzGq3l/XWGut7p46Z3QvqFMXTfk6/09m7xZHJUN9xHfsv7vqqD4YnfI5ueYdSJtXqqBLyjBw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-transform-destructuring": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.10.4.tgz", + "integrity": "sha512-+WmfvyfsyF603iPa6825mq6Qrb7uLjTOsa3XOFzlYcYDHSS4QmpOWOL0NNBY5qMbvrcf3tq0Cw+v4lxswOBpgA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-transform-dotall-regex": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.10.4.tgz", + "integrity": "sha512-ZEAVvUTCMlMFAbASYSVQoxIbHm2OkG2MseW6bV2JjIygOjdVv8tuxrCTzj1+Rynh7ODb8GivUy7dzEXzEhuPaA==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.10.4", + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-transform-duplicate-keys": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.10.4.tgz", + "integrity": "sha512-GL0/fJnmgMclHiBTTWXNlYjYsA7rDrtsazHG6mglaGSTh0KsrW04qml+Bbz9FL0LcJIRwBWL5ZqlNHKTkU3xAA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-transform-exponentiation-operator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.10.4.tgz", + "integrity": "sha512-S5HgLVgkBcRdyQAHbKj+7KyuWx8C6t5oETmUuwz1pt3WTWJhsUV0WIIXuVvfXMxl/QQyHKlSCNNtaIamG8fysw==", + "dev": true, + "requires": { + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.10.4", + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-transform-for-of": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.10.4.tgz", + "integrity": "sha512-ItdQfAzu9AlEqmusA/65TqJ79eRcgGmpPPFvBnGILXZH975G0LNjP1yjHvGgfuCxqrPPueXOPe+FsvxmxKiHHQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-transform-function-name": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.10.4.tgz", + "integrity": "sha512-OcDCq2y5+E0dVD5MagT5X+yTRbcvFjDI2ZVAottGH6tzqjx/LKpgkUepu3hp/u4tZBzxxpNGwLsAvGBvQ2mJzg==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.10.4", + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-transform-literals": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.10.4.tgz", + "integrity": "sha512-Xd/dFSTEVuUWnyZiMu76/InZxLTYilOSr1UlHV+p115Z/Le2Fi1KXkJUYz0b42DfndostYlPub3m8ZTQlMaiqQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-transform-member-expression-literals": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.10.4.tgz", + "integrity": "sha512-0bFOvPyAoTBhtcJLr9VcwZqKmSjFml1iVxvPL0ReomGU53CX53HsM4h2SzckNdkQcHox1bpAqzxBI1Y09LlBSw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-transform-modules-amd": { + "version": "7.10.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.10.5.tgz", + "integrity": "sha512-elm5uruNio7CTLFItVC/rIzKLfQ17+fX7EVz5W0TMgIHFo1zY0Ozzx+lgwhL4plzl8OzVn6Qasx5DeEFyoNiRw==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.10.5", + "@babel/helper-plugin-utils": "^7.10.4", + "babel-plugin-dynamic-import-node": "^2.3.3" + } + }, + "@babel/plugin-transform-modules-commonjs": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.10.4.tgz", + "integrity": "sha512-Xj7Uq5o80HDLlW64rVfDBhao6OX89HKUmb+9vWYaLXBZOma4gA6tw4Ni1O5qVDoZWUV0fxMYA0aYzOawz0l+1w==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.10.4", + "@babel/helper-plugin-utils": "^7.10.4", + "@babel/helper-simple-access": "^7.10.4", + "babel-plugin-dynamic-import-node": "^2.3.3" + } + }, + "@babel/plugin-transform-modules-systemjs": { + "version": "7.10.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.10.5.tgz", + "integrity": "sha512-f4RLO/OL14/FP1AEbcsWMzpbUz6tssRaeQg11RH1BP/XnPpRoVwgeYViMFacnkaw4k4wjRSjn3ip1Uw9TaXuMw==", + "dev": true, + "requires": { + "@babel/helper-hoist-variables": "^7.10.4", + "@babel/helper-module-transforms": "^7.10.5", + "@babel/helper-plugin-utils": "^7.10.4", + "babel-plugin-dynamic-import-node": "^2.3.3" + } + }, + "@babel/plugin-transform-modules-umd": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.10.4.tgz", + "integrity": "sha512-mohW5q3uAEt8T45YT7Qc5ws6mWgJAaL/8BfWD9Dodo1A3RKWli8wTS+WiQ/knF+tXlPirW/1/MqzzGfCExKECA==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.10.4", + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.10.4.tgz", + "integrity": "sha512-V6LuOnD31kTkxQPhKiVYzYC/Jgdq53irJC/xBSmqcNcqFGV+PER4l6rU5SH2Vl7bH9mLDHcc0+l9HUOe4RNGKA==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.10.4" + } + }, + "@babel/plugin-transform-new-target": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.10.4.tgz", + "integrity": "sha512-YXwWUDAH/J6dlfwqlWsztI2Puz1NtUAubXhOPLQ5gjR/qmQ5U96DY4FQO8At33JN4XPBhrjB8I4eMmLROjjLjw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-transform-object-super": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.10.4.tgz", + "integrity": "sha512-5iTw0JkdRdJvr7sY0vHqTpnruUpTea32JHmq/atIWqsnNussbRzjEDyWep8UNztt1B5IusBYg8Irb0bLbiEBCQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4", + "@babel/helper-replace-supers": "^7.10.4" + } + }, + "@babel/plugin-transform-parameters": { + "version": "7.10.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.10.5.tgz", + "integrity": "sha512-xPHwUj5RdFV8l1wuYiu5S9fqWGM2DrYc24TMvUiRrPVm+SM3XeqU9BcokQX/kEUe+p2RBwy+yoiR1w/Blq6ubw==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.10.4", + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-transform-property-literals": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.10.4.tgz", + "integrity": "sha512-ofsAcKiUxQ8TY4sScgsGeR2vJIsfrzqvFb9GvJ5UdXDzl+MyYCaBj/FGzXuv7qE0aJcjWMILny1epqelnFlz8g==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-transform-react-jsx": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.10.4.tgz", + "integrity": "sha512-L+MfRhWjX0eI7Js093MM6MacKU4M6dnCRa/QPDwYMxjljzSCzzlzKzj9Pk4P3OtrPcxr2N3znR419nr3Xw+65A==", + "dev": true, + "requires": { + "@babel/helper-builder-react-jsx": "^7.10.4", + "@babel/helper-builder-react-jsx-experimental": "^7.10.4", + "@babel/helper-plugin-utils": "^7.10.4", + "@babel/plugin-syntax-jsx": "^7.10.4" + } + }, + "@babel/plugin-transform-regenerator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.10.4.tgz", + "integrity": "sha512-3thAHwtor39A7C04XucbMg17RcZ3Qppfxr22wYzZNcVIkPHfpM9J0SO8zuCV6SZa265kxBJSrfKTvDCYqBFXGw==", + "dev": true, + "requires": { + "regenerator-transform": "^0.14.2" + } + }, + "@babel/plugin-transform-reserved-words": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.10.4.tgz", + "integrity": "sha512-hGsw1O6Rew1fkFbDImZIEqA8GoidwTAilwCyWqLBM9f+e/u/sQMQu7uX6dyokfOayRuuVfKOW4O7HvaBWM+JlQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-transform-shorthand-properties": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.10.4.tgz", + "integrity": "sha512-AC2K/t7o07KeTIxMoHneyX90v3zkm5cjHJEokrPEAGEy3UCp8sLKfnfOIGdZ194fyN4wfX/zZUWT9trJZ0qc+Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-transform-spread": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.11.0.tgz", + "integrity": "sha512-UwQYGOqIdQJe4aWNyS7noqAnN2VbaczPLiEtln+zPowRNlD+79w3oi2TWfYe0eZgd+gjZCbsydN7lzWysDt+gw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4", + "@babel/helper-skip-transparent-expression-wrappers": "^7.11.0" + } + }, + "@babel/plugin-transform-sticky-regex": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.10.4.tgz", + "integrity": "sha512-Ddy3QZfIbEV0VYcVtFDCjeE4xwVTJWTmUtorAJkn6u/92Z/nWJNV+mILyqHKrUxXYKA2EoCilgoPePymKL4DvQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4", + "@babel/helper-regex": "^7.10.4" + } + }, + "@babel/plugin-transform-template-literals": { + "version": "7.10.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.10.5.tgz", + "integrity": "sha512-V/lnPGIb+KT12OQikDvgSuesRX14ck5FfJXt6+tXhdkJ+Vsd0lDCVtF6jcB4rNClYFzaB2jusZ+lNISDk2mMMw==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.10.4", + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-transform-typeof-symbol": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.10.4.tgz", + "integrity": "sha512-QqNgYwuuW0y0H+kUE/GWSR45t/ccRhe14Fs/4ZRouNNQsyd4o3PG4OtHiIrepbM2WKUBDAXKCAK/Lk4VhzTaGA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-transform-unicode-escapes": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.10.4.tgz", + "integrity": "sha512-y5XJ9waMti2J+e7ij20e+aH+fho7Wb7W8rNuu72aKRwCHFqQdhkdU2lo3uZ9tQuboEJcUFayXdARhcxLQ3+6Fg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-transform-unicode-regex": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.10.4.tgz", + "integrity": "sha512-wNfsc4s8N2qnIwpO/WP2ZiSyjfpTamT2C9V9FDH/Ljub9zw6P3SjkXcFmc0RQUt96k2fmIvtla2MMjgTwIAC+A==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.10.4", + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/preset-env": { + "version": "7.11.5", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.11.5.tgz", + "integrity": "sha512-kXqmW1jVcnB2cdueV+fyBM8estd5mlNfaQi6lwLgRwCby4edpavgbFhiBNjmWA3JpB/yZGSISa7Srf+TwxDQoA==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.11.0", + "@babel/helper-compilation-targets": "^7.10.4", + "@babel/helper-module-imports": "^7.10.4", + "@babel/helper-plugin-utils": "^7.10.4", + "@babel/plugin-proposal-async-generator-functions": "^7.10.4", + "@babel/plugin-proposal-class-properties": "^7.10.4", + "@babel/plugin-proposal-dynamic-import": "^7.10.4", + "@babel/plugin-proposal-export-namespace-from": "^7.10.4", + "@babel/plugin-proposal-json-strings": "^7.10.4", + "@babel/plugin-proposal-logical-assignment-operators": "^7.11.0", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.10.4", + "@babel/plugin-proposal-numeric-separator": "^7.10.4", + "@babel/plugin-proposal-object-rest-spread": "^7.11.0", + "@babel/plugin-proposal-optional-catch-binding": "^7.10.4", + "@babel/plugin-proposal-optional-chaining": "^7.11.0", + "@babel/plugin-proposal-private-methods": "^7.10.4", + "@babel/plugin-proposal-unicode-property-regex": "^7.10.4", + "@babel/plugin-syntax-async-generators": "^7.8.0", + "@babel/plugin-syntax-class-properties": "^7.10.4", + "@babel/plugin-syntax-dynamic-import": "^7.8.0", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3", + "@babel/plugin-syntax-json-strings": "^7.8.0", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.0", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.0", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.0", + "@babel/plugin-syntax-optional-chaining": "^7.8.0", + "@babel/plugin-syntax-top-level-await": "^7.10.4", + "@babel/plugin-transform-arrow-functions": "^7.10.4", + "@babel/plugin-transform-async-to-generator": "^7.10.4", + "@babel/plugin-transform-block-scoped-functions": "^7.10.4", + "@babel/plugin-transform-block-scoping": "^7.10.4", + "@babel/plugin-transform-classes": "^7.10.4", + "@babel/plugin-transform-computed-properties": "^7.10.4", + "@babel/plugin-transform-destructuring": "^7.10.4", + "@babel/plugin-transform-dotall-regex": "^7.10.4", + "@babel/plugin-transform-duplicate-keys": "^7.10.4", + "@babel/plugin-transform-exponentiation-operator": "^7.10.4", + "@babel/plugin-transform-for-of": "^7.10.4", + "@babel/plugin-transform-function-name": "^7.10.4", + "@babel/plugin-transform-literals": "^7.10.4", + "@babel/plugin-transform-member-expression-literals": "^7.10.4", + "@babel/plugin-transform-modules-amd": "^7.10.4", + "@babel/plugin-transform-modules-commonjs": "^7.10.4", + "@babel/plugin-transform-modules-systemjs": "^7.10.4", + "@babel/plugin-transform-modules-umd": "^7.10.4", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.10.4", + "@babel/plugin-transform-new-target": "^7.10.4", + "@babel/plugin-transform-object-super": "^7.10.4", + "@babel/plugin-transform-parameters": "^7.10.4", + "@babel/plugin-transform-property-literals": "^7.10.4", + "@babel/plugin-transform-regenerator": "^7.10.4", + "@babel/plugin-transform-reserved-words": "^7.10.4", + "@babel/plugin-transform-shorthand-properties": "^7.10.4", + "@babel/plugin-transform-spread": "^7.11.0", + "@babel/plugin-transform-sticky-regex": "^7.10.4", + "@babel/plugin-transform-template-literals": "^7.10.4", + "@babel/plugin-transform-typeof-symbol": "^7.10.4", + "@babel/plugin-transform-unicode-escapes": "^7.10.4", + "@babel/plugin-transform-unicode-regex": "^7.10.4", + "@babel/preset-modules": "^0.1.3", + "@babel/types": "^7.11.5", + "browserslist": "^4.12.0", + "core-js-compat": "^3.6.2", + "invariant": "^2.2.2", + "levenary": "^1.1.1", + "semver": "^5.5.0" + } + }, + "@babel/preset-modules": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.4.tgz", + "integrity": "sha512-J36NhwnfdzpmH41M1DrnkkgAqhZaqr/NBdPfQ677mLzlaXo+oDiv1deyCDtgAhz8p328otdob0Du7+xgHGZbKg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-proposal-unicode-property-regex": "^7.4.4", + "@babel/plugin-transform-dotall-regex": "^7.4.4", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + } + }, + "@babel/runtime": { + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.11.2.tgz", + "integrity": "sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw==", + "dev": true, + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "@babel/template": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.10.4.tgz", + "integrity": "sha512-ZCjD27cGJFUB6nmCB1Enki3r+L5kJveX9pq1SvAUKoICy6CZ9yD8xO086YXdYhvNjBdnekm4ZnaP5yC8Cs/1tA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/parser": "^7.10.4", + "@babel/types": "^7.10.4" + } + }, + "@babel/traverse": { + "version": "7.11.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.11.5.tgz", + "integrity": "sha512-EjiPXt+r7LiCZXEfRpSJd+jUMnBd4/9OUv7Nx3+0u9+eimMwJmG0Q98lw4/289JCoxSE8OolDMNZaaF/JZ69WQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/generator": "^7.11.5", + "@babel/helper-function-name": "^7.10.4", + "@babel/helper-split-export-declaration": "^7.11.0", + "@babel/parser": "^7.11.5", + "@babel/types": "^7.11.5", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.19" + } + }, + "@babel/types": { + "version": "7.11.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.11.5.tgz", + "integrity": "sha512-bvM7Qz6eKnJVFIn+1LPtjlBFPVN5jNDc1XmN15vWe7Q3DPBufWWsLiIvUu7xW87uTG6QoggpIDnUgLQvPheU+Q==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.10.4", + "lodash": "^4.17.19", + "to-fast-properties": "^2.0.0" + } + }, + "@cnakazawa/watch": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@cnakazawa/watch/-/watch-1.0.4.tgz", + "integrity": "sha512-v9kIhKwjeZThiWrLmj0y17CWoyddASLj9O2yvbZkbvw/N3rWOYy9zkV66ursAoVr0mV15bL8g0c4QZUE6cdDoQ==", + "dev": true, + "requires": { + "exec-sh": "^0.3.2", + "minimist": "^1.2.0" + } + }, + "@jest/console": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-24.9.0.tgz", + "integrity": "sha512-Zuj6b8TnKXi3q4ymac8EQfc3ea/uhLeCGThFqXeC8H9/raaH8ARPUTdId+XyGd03Z4In0/VjD2OYFcBF09fNLQ==", + "dev": true, + "requires": { + "@jest/source-map": "^24.9.0", + "chalk": "^2.0.1", + "slash": "^2.0.0" + } + }, + "@jest/fake-timers": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-24.9.0.tgz", + "integrity": "sha512-eWQcNa2YSwzXWIMC5KufBh3oWRIijrQFROsIqt6v/NS9Io/gknw1jsAC9c+ih/RQX4A3O7SeWAhQeN0goKhT9A==", + "dev": true, + "requires": { + "@jest/types": "^24.9.0", + "jest-message-util": "^24.9.0", + "jest-mock": "^24.9.0" + } + }, + "@jest/source-map": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-24.9.0.tgz", + "integrity": "sha512-/Xw7xGlsZb4MJzNDgB7PW5crou5JqWiBQaz6xyPd3ArOg2nfn/PunV8+olXbbEZzNl591o5rWKE9BRDaFAuIBg==", + "dev": true, + "requires": { + "callsites": "^3.0.0", + "graceful-fs": "^4.1.15", + "source-map": "^0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "@jest/test-result": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-24.9.0.tgz", + "integrity": "sha512-XEFrHbBonBJ8dGp2JmF8kP/nQI/ImPpygKHwQ/SY+es59Z3L5PI4Qb9TQQMAEeYsThG1xF0k6tmG0tIKATNiiA==", + "dev": true, + "requires": { + "@jest/console": "^24.9.0", + "@jest/types": "^24.9.0", + "@types/istanbul-lib-coverage": "^2.0.0" + } + }, + "@jest/transform": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-24.9.0.tgz", + "integrity": "sha512-TcQUmyNRxV94S0QpMOnZl0++6RMiqpbH/ZMccFB/amku6Uwvyb1cjYX7xkp5nGNkbX4QPH/FcB6q1HBTHynLmQ==", + "dev": true, + "requires": { + "@babel/core": "^7.1.0", + "@jest/types": "^24.9.0", + "babel-plugin-istanbul": "^5.1.0", + "chalk": "^2.0.1", + "convert-source-map": "^1.4.0", + "fast-json-stable-stringify": "^2.0.0", + "graceful-fs": "^4.1.15", + "jest-haste-map": "^24.9.0", + "jest-regex-util": "^24.9.0", + "jest-util": "^24.9.0", + "micromatch": "^3.1.10", + "pirates": "^4.0.1", + "realpath-native": "^1.1.0", + "slash": "^2.0.0", + "source-map": "^0.6.1", + "write-file-atomic": "2.4.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "@jest/types": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-24.9.0.tgz", + "integrity": "sha512-XKK7ze1apu5JWQ5eZjHITP66AX+QsLlbaJRBGYr8pNzwcAE2JVkwnf0yqjHTsDRcjR0mujy/NmZMXw5kl+kGBw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^1.1.1", + "@types/yargs": "^13.0.0" + } + }, + "@types/babel__core": { + "version": "7.1.9", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.9.tgz", + "integrity": "sha512-sY2RsIJ5rpER1u3/aQ8OFSI7qGIy8o1NEEbgb2UaJcvOtXOMpd39ko723NBpjQFg9SIX7TXtjejZVGeIMLhoOw==", + "dev": true, + "requires": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "@types/babel__generator": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.1.tgz", + "integrity": "sha512-bBKm+2VPJcMRVwNhxKu8W+5/zT7pwNEqeokFOmbvVSqGzFneNxYcEBro9Ac7/N9tlsaPYnZLK8J1LWKkMsLAew==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@types/babel__template": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.0.2.tgz", + "integrity": "sha512-/K6zCpeW7Imzgab2bLkLEbz0+1JlFSrUMdw7KoIIu+IUdu51GWaBZpd3y1VXGVXzynvGa4DaIaxNZHiON3GXUg==", + "dev": true, + "requires": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "@types/babel__traverse": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.0.14.tgz", + "integrity": "sha512-8w9szzKs14ZtBVuP6Wn7nMLRJ0D6dfB0VEBEyRgxrZ/Ln49aNMykrghM2FaNn4FJRzNppCSa0Rv9pBRM5Xc3wg==", + "dev": true, + "requires": { + "@babel/types": "^7.3.0" + } + }, + "@types/istanbul-lib-coverage": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz", + "integrity": "sha512-sz7iLqvVUg1gIedBOvlkxPlc8/uVzyS5OwGz1cKjXzkl3FpL3al0crU8YGU1WoHkxn0Wxbw5tyi6hvzJKNzFsw==", + "dev": true + }, + "@types/istanbul-lib-report": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "*" + } + }, + "@types/istanbul-reports": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-1.1.2.tgz", + "integrity": "sha512-P/W9yOX/3oPZSpaYOCQzGqgCQRXn0FFO/V8bWrCQs+wLmvVVxk6CRBXALEvNs9OHIatlnlFokfhuDo2ug01ciw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "*", + "@types/istanbul-lib-report": "*" + } + }, + "@types/stack-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz", + "integrity": "sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==", + "dev": true + }, + "@types/yargs": { + "version": "13.0.10", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-13.0.10.tgz", + "integrity": "sha512-MU10TSgzNABgdzKvQVW1nuuT+sgBMWeXNc3XOs5YXV5SDAK+PPja2eUuBNB9iqElu03xyEDqlnGw0jgl4nbqGQ==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "@types/yargs-parser": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-15.0.0.tgz", + "integrity": "sha512-FA/BWv8t8ZWJ+gEOnLLd8ygxH/2UFbAvgEonyfN6yWGLKc7zVjbpl2Y4CTjid9h2RfgPP6SEt6uHwEOply00yw==", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "anymatch": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz", + "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==", + "dev": true, + "requires": { + "micromatch": "^3.1.4", + "normalize-path": "^2.1.1" + } + }, + "arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=", + "dev": true + }, + "arr-flatten": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", + "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==", + "dev": true + }, + "arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=", + "dev": true + }, + "array-unique": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", + "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", + "dev": true + }, + "assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=", + "dev": true + }, + "atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "dev": true + }, + "babel-jest": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-24.9.0.tgz", + "integrity": "sha512-ntuddfyiN+EhMw58PTNL1ph4C9rECiQXjI4nMMBKBaNjXvqLdkXpPRcMSr4iyBrJg/+wz9brFUD6RhOAT6r4Iw==", + "dev": true, + "requires": { + "@jest/transform": "^24.9.0", + "@jest/types": "^24.9.0", + "@types/babel__core": "^7.1.0", + "babel-plugin-istanbul": "^5.1.0", + "babel-preset-jest": "^24.9.0", + "chalk": "^2.4.2", + "slash": "^2.0.0" + } + }, + "babel-plugin-dynamic-import-node": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", + "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==", + "dev": true, + "requires": { + "object.assign": "^4.1.0" + } + }, + "babel-plugin-istanbul": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-5.2.0.tgz", + "integrity": "sha512-5LphC0USA8t4i1zCtjbbNb6jJj/9+X6P37Qfirc/70EQ34xKlMW+a1RHGwxGI+SwWpNwZ27HqvzAobeqaXwiZw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "find-up": "^3.0.0", + "istanbul-lib-instrument": "^3.3.0", + "test-exclude": "^5.2.3" + } + }, + "babel-plugin-jest-hoist": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-24.9.0.tgz", + "integrity": "sha512-2EMA2P8Vp7lG0RAzr4HXqtYwacfMErOuv1U3wrvxHX6rD1sV6xS3WXG3r8TRQ2r6w8OhvSdWt+z41hQNwNm3Xw==", + "dev": true, + "requires": { + "@types/babel__traverse": "^7.0.6" + } + }, + "babel-preset-jest": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-24.9.0.tgz", + "integrity": "sha512-izTUuhE4TMfTRPF92fFwD2QfdXaZW08qvWTFCI51V8rW5x00UuPgc3ajRoWofXOuxjfcOM5zzSYsQS3H8KGCAg==", + "dev": true, + "requires": { + "@babel/plugin-syntax-object-rest-spread": "^7.0.0", + "babel-plugin-jest-hoist": "^24.9.0" + } + }, + "babylon": { + "version": "7.0.0-beta.47", + "resolved": "https://registry.npmjs.org/babylon/-/babylon-7.0.0-beta.47.tgz", + "integrity": "sha512-+rq2cr4GDhtToEzKFD6KZZMDBXhjFAr9JjPw9pAppZACeEWqNM294j+NdBzkSHYXwzzBmVjZ3nEVJlOhbR2gOQ==", + "dev": true + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "base": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz", + "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==", + "dev": true, + "requires": { + "cache-base": "^1.0.1", + "class-utils": "^0.3.5", + "component-emitter": "^1.2.1", + "define-property": "^1.0.0", + "isobject": "^3.0.1", + "mixin-deep": "^1.2.0", + "pascalcase": "^0.1.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "optional": true, + "requires": { + "file-uri-to-path": "1.0.0" + } + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "requires": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "browserslist": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.14.3.tgz", + "integrity": "sha512-GcZPC5+YqyPO4SFnz48/B0YaCwS47Q9iPChRGi6t7HhflKBcINzFrJvRfC+jp30sRMKxF+d4EHGs27Z0XP1NaQ==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30001131", + "electron-to-chromium": "^1.3.570", + "escalade": "^3.1.0", + "node-releases": "^1.1.61" + } + }, + "bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "requires": { + "node-int64": "^0.4.0" + } + }, + "cache-base": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", + "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==", + "dev": true, + "requires": { + "collection-visit": "^1.0.0", + "component-emitter": "^1.2.1", + "get-value": "^2.0.6", + "has-value": "^1.0.0", + "isobject": "^3.0.1", + "set-value": "^2.0.0", + "to-object-path": "^0.3.0", + "union-value": "^1.0.0", + "unset-value": "^1.0.0" + } + }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true + }, + "caniuse-lite": { + "version": "1.0.30001131", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001131.tgz", + "integrity": "sha512-4QYi6Mal4MMfQMSqGIRPGbKIbZygeN83QsWq1ixpUwvtfgAZot5BrCKzGygvZaV+CnELdTwD0S4cqUNozq7/Cw==", + "dev": true + }, + "capture-exit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/capture-exit/-/capture-exit-2.0.0.tgz", + "integrity": "sha512-PiT/hQmTonHhl/HFGN+Lx3JJUznrVYJ3+AQsnthneZbvW7x+f08Tk7yLJTLEOUvBTbduLeeBkxEaYXUOUrRq6g==", + "dev": true, + "requires": { + "rsvp": "^4.8.4" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true + }, + "class-utils": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", + "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==", + "dev": true, + "requires": { + "arr-union": "^3.1.0", + "define-property": "^0.2.5", + "isobject": "^3.0.0", + "static-extend": "^0.1.1" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + } + } + }, + "collection-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", + "integrity": "sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=", + "dev": true, + "requires": { + "map-visit": "^1.0.0", + "object-visit": "^1.0.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "component-emitter": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", + "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "convert-source-map": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", + "integrity": "sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.1" + } + }, + "copy-descriptor": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", + "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=", + "dev": true + }, + "core-js-compat": { + "version": "3.6.5", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.6.5.tgz", + "integrity": "sha512-7ItTKOhOZbznhXAQ2g/slGg1PJV5zDO/WdkTwi7UEOJmkvsE32PWvx6mKtDjiMpjnR2CNf6BAD6sSxIlv7ptng==", + "dev": true, + "requires": { + "browserslist": "^4.8.5", + "semver": "7.0.0" + }, + "dependencies": { + "semver": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", + "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", + "dev": true + } + } + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "decode-uri-component": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", + "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", + "dev": true + }, + "define-properties": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", + "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "dev": true, + "requires": { + "object-keys": "^1.0.12" + } + }, + "define-property": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", + "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", + "dev": true, + "requires": { + "is-descriptor": "^1.0.2", + "isobject": "^3.0.1" + }, + "dependencies": { + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "electron-to-chromium": { + "version": "1.3.570", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.570.tgz", + "integrity": "sha512-Y6OCoVQgFQBP5py6A/06+yWxUZHDlNr/gNDGatjH8AZqXl8X0tE4LfjLJsXGz/JmWJz8a6K7bR1k+QzZ+k//fg==", + "dev": true + }, + "end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "requires": { + "once": "^1.4.0" + } + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "es-abstract": { + "version": "1.18.0-next.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.0.tgz", + "integrity": "sha512-elZXTZXKn51hUBdJjSZGYRujuzilgXo8vSPQzjGYXLvSlGiCo8VO8ZGV3kjo9a0WNJJ57hENagwbtlRuHuzkcQ==", + "dev": true, + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.2.0", + "is-negative-zero": "^2.0.0", + "is-regex": "^1.1.1", + "object-inspect": "^1.8.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.0", + "string.prototype.trimend": "^1.0.1", + "string.prototype.trimstart": "^1.0.1" + } + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "escalade": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.0.tgz", + "integrity": "sha512-mAk+hPSO8fLDkhV7V0dXazH5pDc6MrjBTPyD3VeKzxnVFjH1MIxbCdqGZB9O8+EwWakZs3ZCbDS4IpRt79V1ig==", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true + }, + "exec-sh": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.3.4.tgz", + "integrity": "sha512-sEFIkc61v75sWeOe72qyrqg2Qg0OuLESziUDk/O/z2qgS15y2gWVFrI6f2Qn/qw/0/NCfCEsmNA4zOjkwEZT1A==", + "dev": true + }, + "execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "expand-brackets": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", + "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=", + "dev": true, + "requires": { + "debug": "^2.3.3", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "posix-character-classes": "^0.1.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=", + "dev": true, + "requires": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "extglob": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", + "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", + "dev": true, + "requires": { + "array-unique": "^0.3.2", + "define-property": "^1.0.0", + "expand-brackets": "^2.1.4", + "extend-shallow": "^2.0.1", + "fragment-cache": "^0.2.1", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "fast-async": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/fast-async/-/fast-async-7.0.6.tgz", + "integrity": "sha512-/iUa3eSQC+Xh5tN6QcVLsEsN7b1DaPIoTZo++VpLLIxtdNW2tEmMZex4TcrMeRnBwMOpZwue2CB171wjt5Kgqg==", + "dev": true, + "requires": { + "@babel/generator": "^7.0.0-beta.44", + "@babel/helper-module-imports": "^7.0.0-beta.44", + "babylon": "^7.0.0-beta.44", + "nodent-runtime": "^3.2.1", + "nodent-transform": "^3.2.4" + } + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "fb-watchman": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.1.tgz", + "integrity": "sha512-DkPJKQeY6kKwmuMretBhr7G6Vodr7bFwDYTXIkfG1gjvNpaxBTQV3PbXg6bR1c1UP4jPOX0jHUbbHANL9vRjVg==", + "dev": true, + "requires": { + "bser": "2.1.1" + } + }, + "file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "optional": true + }, + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=", + "dev": true + }, + "fragment-cache": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", + "integrity": "sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=", + "dev": true, + "requires": { + "map-cache": "^0.2.2" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "fsevents": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", + "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", + "dev": true, + "optional": true, + "requires": { + "bindings": "^1.5.0", + "nan": "^2.12.1" + } + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "gensync": { + "version": "1.0.0-beta.1", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.1.tgz", + "integrity": "sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg==", + "dev": true + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=", + "dev": true + }, + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "graceful-fs": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", + "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", + "dev": true + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "has-symbols": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", + "dev": true + }, + "has-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", + "integrity": "sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=", + "dev": true, + "requires": { + "get-value": "^2.0.6", + "has-values": "^1.0.0", + "isobject": "^3.0.0" + } + }, + "has-values": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz", + "integrity": "sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "kind-of": "^4.0.0" + }, + "dependencies": { + "kind-of": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", + "integrity": "sha1-IIE989cSkosgc3hpGkUGb65y3Vc=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "hosted-git-info": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz", + "integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==", + "dev": true + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dev": true, + "requires": { + "loose-envify": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true + }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true + }, + "is-callable": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.1.tgz", + "integrity": "sha512-wliAfSzx6V+6WfMOmus1xy0XvSgf/dlStkvTfq7F0g4bOIW0PSUbnyse3NhDwdyYS1ozfUtAAySqTws3z9Eqgg==", + "dev": true + }, + "is-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dev": true, + "requires": { + "ci-info": "^2.0.0" + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-date-object": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz", + "integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==", + "dev": true + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^0.1.6", + "is-data-descriptor": "^0.1.4", + "kind-of": "^5.0.0" + }, + "dependencies": { + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + } + } + }, + "is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=", + "dev": true + }, + "is-negative-zero": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.0.tgz", + "integrity": "sha1-lVOxIbD6wohp2p7UWeIMdUN4hGE=", + "dev": true + }, + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "is-regex": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.1.tgz", + "integrity": "sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg==", + "dev": true, + "requires": { + "has-symbols": "^1.0.1" + } + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", + "dev": true + }, + "is-symbol": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", + "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", + "dev": true, + "requires": { + "has-symbols": "^1.0.1" + } + }, + "is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + }, + "istanbul-lib-coverage": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz", + "integrity": "sha512-8aXznuEPCJvGnMSRft4udDRDtb1V3pkQkMMI5LI+6HuQz5oQ4J2UFn1H82raA3qJtyOLkkwVqICBQkjnGtn5mA==", + "dev": true + }, + "istanbul-lib-instrument": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-3.3.0.tgz", + "integrity": "sha512-5nnIN4vo5xQZHdXno/YDXJ0G+I3dAm4XgzfSVTPLQpj/zAV2dV6Juy0yaf10/zrJOJeHoN3fraFe+XRq2bFVZA==", + "dev": true, + "requires": { + "@babel/generator": "^7.4.0", + "@babel/parser": "^7.4.3", + "@babel/template": "^7.4.0", + "@babel/traverse": "^7.4.3", + "@babel/types": "^7.4.0", + "istanbul-lib-coverage": "^2.0.5", + "semver": "^6.0.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "jest-haste-map": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-24.9.0.tgz", + "integrity": "sha512-kfVFmsuWui2Sj1Rp1AJ4D9HqJwE4uwTlS/vO+eRUaMmd54BFpli2XhMQnPC2k4cHFVbB2Q2C+jtI1AGLgEnCjQ==", + "dev": true, + "requires": { + "@jest/types": "^24.9.0", + "anymatch": "^2.0.0", + "fb-watchman": "^2.0.0", + "fsevents": "^1.2.7", + "graceful-fs": "^4.1.15", + "invariant": "^2.2.4", + "jest-serializer": "^24.9.0", + "jest-util": "^24.9.0", + "jest-worker": "^24.9.0", + "micromatch": "^3.1.10", + "sane": "^4.0.3", + "walker": "^1.0.7" + } + }, + "jest-message-util": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-24.9.0.tgz", + "integrity": "sha512-oCj8FiZ3U0hTP4aSui87P4L4jC37BtQwUMqk+zk/b11FR19BJDeZsZAvIHutWnmtw7r85UmR3CEWZ0HWU2mAlw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@jest/test-result": "^24.9.0", + "@jest/types": "^24.9.0", + "@types/stack-utils": "^1.0.1", + "chalk": "^2.0.1", + "micromatch": "^3.1.10", + "slash": "^2.0.0", + "stack-utils": "^1.0.1" + } + }, + "jest-mock": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-24.9.0.tgz", + "integrity": "sha512-3BEYN5WbSq9wd+SyLDES7AHnjH9A/ROBwmz7l2y+ol+NtSFO8DYiEBzoO1CeFc9a8DYy10EO4dDFVv/wN3zl1w==", + "dev": true, + "requires": { + "@jest/types": "^24.9.0" + } + }, + "jest-regex-util": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-24.9.0.tgz", + "integrity": "sha512-05Cmb6CuxaA+Ys6fjr3PhvV3bGQmO+2p2La4hFbU+W5uOc479f7FdLXUWXw4pYMAhhSZIuKHwSXSu6CsSBAXQA==", + "dev": true + }, + "jest-serializer": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-24.9.0.tgz", + "integrity": "sha512-DxYipDr8OvfrKH3Kel6NdED3OXxjvxXZ1uIY2I9OFbGg+vUkkg7AGvi65qbhbWNPvDckXmzMPbK3u3HaDO49bQ==", + "dev": true + }, + "jest-util": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-24.9.0.tgz", + "integrity": "sha512-x+cZU8VRmOJxbA1K5oDBdxQmdq0OIdADarLxk0Mq+3XS4jgvhG/oKGWcIDCtPG0HgjxOYvF+ilPJQsAyXfbNOg==", + "dev": true, + "requires": { + "@jest/console": "^24.9.0", + "@jest/fake-timers": "^24.9.0", + "@jest/source-map": "^24.9.0", + "@jest/test-result": "^24.9.0", + "@jest/types": "^24.9.0", + "callsites": "^3.0.0", + "chalk": "^2.0.1", + "graceful-fs": "^4.1.15", + "is-ci": "^2.0.0", + "mkdirp": "^0.5.1", + "slash": "^2.0.0", + "source-map": "^0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "jest-worker": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-24.9.0.tgz", + "integrity": "sha512-51PE4haMSXcHohnSMdM42anbvZANYTqMrr52tVKPqqsPJMzoP6FYYDVqahX/HrAoKEKz3uUPzSvKs9A3qR4iVw==", + "dev": true, + "requires": { + "merge-stream": "^2.0.0", + "supports-color": "^6.1.0" + }, + "dependencies": { + "supports-color": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz", + "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true + }, + "json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "json5": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz", + "integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==", + "dev": true, + "requires": { + "minimist": "^1.2.5" + } + }, + "kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true + }, + "leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true + }, + "levenary": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/levenary/-/levenary-1.1.1.tgz", + "integrity": "sha512-mkAdOIt79FD6irqjYSs4rdbnlT5vRonMEvBVPVb3XmevfS8kgRXwfes0dhPdEtzTWD/1eNE/Bm/G1iRt6DcnQQ==", + "dev": true, + "requires": { + "leven": "^3.1.0" + } + }, + "load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha1-L19Fq5HjMhYjT9U62rZo607AmTs=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==", + "dev": true + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "makeerror": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.11.tgz", + "integrity": "sha1-4BpckQnyr3lmDk6LlYd5AYT1qWw=", + "dev": true, + "requires": { + "tmpl": "1.0.x" + } + }, + "map-cache": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", + "integrity": "sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=", + "dev": true + }, + "map-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz", + "integrity": "sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=", + "dev": true, + "requires": { + "object-visit": "^1.0.0" + } + }, + "merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + } + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true + }, + "mixin-deep": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", + "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", + "dev": true, + "requires": { + "for-in": "^1.0.2", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "dev": true, + "requires": { + "minimist": "^1.2.5" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "nan": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz", + "integrity": "sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw==", + "dev": true, + "optional": true + }, + "nanomatch": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", + "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "fragment-cache": "^0.2.1", + "is-windows": "^1.0.2", + "kind-of": "^6.0.2", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + } + }, + "nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs=", + "dev": true + }, + "node-modules-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz", + "integrity": "sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA=", + "dev": true + }, + "node-releases": { + "version": "1.1.61", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.61.tgz", + "integrity": "sha512-DD5vebQLg8jLCOzwupn954fbIiZht05DAZs0k2u8NStSe6h9XdsuIQL8hSRKYiU8WUQRznmSDrKGbv3ObOmC7g==", + "dev": true + }, + "nodent-runtime": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/nodent-runtime/-/nodent-runtime-3.2.1.tgz", + "integrity": "sha512-7Ws63oC+215smeKJQCxzrK21VFVlCFBkwl0MOObt0HOpVQXs3u483sAmtkF33nNqZ5rSOQjB76fgyPBmAUrtCA==", + "dev": true + }, + "nodent-transform": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/nodent-transform/-/nodent-transform-3.2.9.tgz", + "integrity": "sha512-4a5FH4WLi+daH/CGD5o/JWRR8W5tlCkd3nrDSkxbOzscJTyTUITltvOJeQjg3HJ1YgEuNyiPhQbvbtRjkQBByQ==", + "dev": true + }, + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", + "dev": true, + "requires": { + "remove-trailing-separator": "^1.0.1" + } + }, + "npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=", + "dev": true, + "requires": { + "path-key": "^2.0.0" + } + }, + "object-copy": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", + "integrity": "sha1-fn2Fi3gb18mRpBupde04EnVOmYw=", + "dev": true, + "requires": { + "copy-descriptor": "^0.1.0", + "define-property": "^0.2.5", + "kind-of": "^3.0.3" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "object-inspect": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.8.0.tgz", + "integrity": "sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA==", + "dev": true + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true + }, + "object-visit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", + "integrity": "sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=", + "dev": true, + "requires": { + "isobject": "^3.0.0" + } + }, + "object.assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.1.tgz", + "integrity": "sha512-VT/cxmx5yaoHSOTSyrCygIDFco+RsibY2NM0a4RdEeY/4KgqezwFtK1yr3U67xYhqJSlASm2pKhLVzPj2lr4bA==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.18.0-next.0", + "has-symbols": "^1.0.1", + "object-keys": "^1.1.1" + } + }, + "object.getownpropertydescriptors": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.0.tgz", + "integrity": "sha512-Z53Oah9A3TdLoblT7VKJaTDdXdT+lQO+cNpKVnya5JDe9uLvzu1YyY1yFDFrcxrlRgWrEFH0jJtD/IbuwjcEVg==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.0-next.1" + }, + "dependencies": { + "es-abstract": { + "version": "1.17.6", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.6.tgz", + "integrity": "sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw==", + "dev": true, + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.2.0", + "is-regex": "^1.1.0", + "object-inspect": "^1.7.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.0", + "string.prototype.trimend": "^1.0.1", + "string.prototype.trimstart": "^1.0.1" + } + } + } + }, + "object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", + "dev": true + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "pascalcase": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", + "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=", + "dev": true + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", + "dev": true + }, + "path-parse": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", + "dev": true + }, + "path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "requires": { + "pify": "^3.0.0" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + }, + "pirates": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.1.tgz", + "integrity": "sha512-WuNqLTbMI3tmfef2TKxlQmAiLHKtFhlsCZnPIpuv2Ow0RDVO8lfy1Opf4NUzlMXLjPl+Men7AuVdX6TA+s+uGA==", + "dev": true, + "requires": { + "node-modules-regexp": "^1.0.0" + } + }, + "posix-character-classes": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", + "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=", + "dev": true + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=", + "dev": true, + "requires": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + } + }, + "read-pkg-up": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-4.0.0.tgz", + "integrity": "sha512-6etQSH7nJGsK0RbG/2TeDzZFa8shjQ1um+SwQQ5cwKy0dhSXdOncEhb1CPpvQG4h7FyOV6EB6YlV0yJvZQNAkA==", + "dev": true, + "requires": { + "find-up": "^3.0.0", + "read-pkg": "^3.0.0" + } + }, + "realpath-native": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/realpath-native/-/realpath-native-1.1.0.tgz", + "integrity": "sha512-wlgPA6cCIIg9gKz0fgAPjnzh4yR/LnXovwuo9hvyGvx3h8nX4+/iLZplfUWasXpqD8BdnGnP5njOFjkUwPzvjA==", + "dev": true, + "requires": { + "util.promisify": "^1.0.0" + } + }, + "regenerate": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.1.tgz", + "integrity": "sha512-j2+C8+NtXQgEKWk49MMP5P/u2GhnahTtVkRIHr5R5lVRlbKvmQ+oS+A5aLKWp2ma5VkT8sh6v+v4hbH0YHR66A==", + "dev": true + }, + "regenerate-unicode-properties": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-8.2.0.tgz", + "integrity": "sha512-F9DjY1vKLo/tPePDycuH3dn9H1OTPIkVD9Kz4LODu+F2C75mgjAJ7x/gwy6ZcSNRAAkhNlJSOHRe8k3p+K9WhA==", + "dev": true, + "requires": { + "regenerate": "^1.4.0" + } + }, + "regenerator-runtime": { + "version": "0.13.7", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", + "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==", + "dev": true + }, + "regenerator-transform": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.14.5.tgz", + "integrity": "sha512-eOf6vka5IO151Jfsw2NO9WpGX58W6wWmefK3I1zEGr0lOD0u8rwPaNqQL1aRxUaxLeKO3ArNh3VYg1KbaD+FFw==", + "dev": true, + "requires": { + "@babel/runtime": "^7.8.4" + } + }, + "regex-not": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", + "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==", + "dev": true, + "requires": { + "extend-shallow": "^3.0.2", + "safe-regex": "^1.1.0" + } + }, + "regexpu-core": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-4.7.1.tgz", + "integrity": "sha512-ywH2VUraA44DZQuRKzARmw6S66mr48pQVva4LBeRhcOltJ6hExvWly5ZjFLYo67xbIxb6W1q4bAGtgfEl20zfQ==", + "dev": true, + "requires": { + "regenerate": "^1.4.0", + "regenerate-unicode-properties": "^8.2.0", + "regjsgen": "^0.5.1", + "regjsparser": "^0.6.4", + "unicode-match-property-ecmascript": "^1.0.4", + "unicode-match-property-value-ecmascript": "^1.2.0" + } + }, + "regjsgen": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.5.2.tgz", + "integrity": "sha512-OFFT3MfrH90xIW8OOSyUrk6QHD5E9JOTeGodiJeBS3J6IwlgzJMNE/1bZklWz5oTg+9dCMyEetclvCVXOPoN3A==", + "dev": true + }, + "regjsparser": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.6.4.tgz", + "integrity": "sha512-64O87/dPDgfk8/RQqC4gkZoGyyWFIEUTTh80CU6CWuK5vkCGyekIx+oKcEIYtP/RAxSQltCZHCNu/mdd7fqlJw==", + "dev": true, + "requires": { + "jsesc": "~0.5.0" + }, + "dependencies": { + "jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=", + "dev": true + } + } + }, + "remove-trailing-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", + "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8=", + "dev": true + }, + "repeat-element": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.3.tgz", + "integrity": "sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g==", + "dev": true + }, + "repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", + "dev": true + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "resolve": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz", + "integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==", + "dev": true, + "requires": { + "path-parse": "^1.0.6" + } + }, + "resolve-url": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", + "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=", + "dev": true + }, + "ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "dev": true + }, + "rsvp": { + "version": "4.8.5", + "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz", + "integrity": "sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA==", + "dev": true + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "safe-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", + "dev": true, + "requires": { + "ret": "~0.1.10" + } + }, + "sane": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/sane/-/sane-4.1.0.tgz", + "integrity": "sha512-hhbzAgTIX8O7SHfp2c8/kREfEn4qO/9q8C9beyY6+tvZ87EpoZ3i1RIEvp27YBswnNbY9mWd6paKVmKbAgLfZA==", + "dev": true, + "requires": { + "@cnakazawa/watch": "^1.0.3", + "anymatch": "^2.0.0", + "capture-exit": "^2.0.0", + "exec-sh": "^0.3.2", + "execa": "^1.0.0", + "fb-watchman": "^2.0.0", + "micromatch": "^3.1.4", + "minimist": "^1.1.1", + "walker": "~1.0.5" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "set-value": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", + "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", + "dev": true, + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", + "dev": true + }, + "signal-exit": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", + "dev": true + }, + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + }, + "snapdragon": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", + "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==", + "dev": true, + "requires": { + "base": "^0.11.1", + "debug": "^2.2.0", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "map-cache": "^0.2.2", + "source-map": "^0.5.6", + "source-map-resolve": "^0.5.0", + "use": "^3.1.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "snapdragon-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz", + "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==", + "dev": true, + "requires": { + "define-property": "^1.0.0", + "isobject": "^3.0.0", + "snapdragon-util": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "snapdragon-util": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz", + "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==", + "dev": true, + "requires": { + "kind-of": "^3.2.0" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + }, + "source-map-resolve": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz", + "integrity": "sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==", + "dev": true, + "requires": { + "atob": "^2.1.2", + "decode-uri-component": "^0.2.0", + "resolve-url": "^0.2.1", + "source-map-url": "^0.4.0", + "urix": "^0.1.0" + } + }, + "source-map-url": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.0.tgz", + "integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=", + "dev": true + }, + "spdx-correct": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz", + "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==", + "dev": true, + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", + "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "dev": true + }, + "spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.6.tgz", + "integrity": "sha512-+orQK83kyMva3WyPf59k1+Y525csj5JejicWut55zeTWANuN17qSiSLUXWtzHeNWORSvT7GLDJ/E/XiIWoXBTw==", + "dev": true + }, + "split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "dev": true, + "requires": { + "extend-shallow": "^3.0.0" + } + }, + "stack-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-1.0.2.tgz", + "integrity": "sha512-MTX+MeG5U994cazkjd/9KNAapsHnibjMLnfXodlkXw76JEea0UiNzrqidzo1emMwk7w5Qhc9jd4Bn9TBb1MFwA==", + "dev": true + }, + "static-extend": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", + "integrity": "sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=", + "dev": true, + "requires": { + "define-property": "^0.2.5", + "object-copy": "^0.1.0" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + } + } + }, + "string.prototype.trimend": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz", + "integrity": "sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + }, + "dependencies": { + "es-abstract": { + "version": "1.17.6", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.6.tgz", + "integrity": "sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw==", + "dev": true, + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.2.0", + "is-regex": "^1.1.0", + "object-inspect": "^1.7.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.0", + "string.prototype.trimend": "^1.0.1", + "string.prototype.trimstart": "^1.0.1" + } + } + } + }, + "string.prototype.trimstart": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz", + "integrity": "sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + }, + "dependencies": { + "es-abstract": { + "version": "1.17.6", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.6.tgz", + "integrity": "sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw==", + "dev": true, + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.2.0", + "is-regex": "^1.1.0", + "object-inspect": "^1.7.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.0", + "string.prototype.trimend": "^1.0.1", + "string.prototype.trimstart": "^1.0.1" + } + } + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + }, + "strip-eof": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "test-exclude": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-5.2.3.tgz", + "integrity": "sha512-M+oxtseCFO3EDtAaGH7iiej3CBkzXqFMbzqYAACdzKui4eZA+pq3tZEwChvOdNfa7xxy8BfbmgJSIr43cC/+2g==", + "dev": true, + "requires": { + "glob": "^7.1.3", + "minimatch": "^3.0.4", + "read-pkg-up": "^4.0.0", + "require-main-filename": "^2.0.0" + } + }, + "tmpl": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz", + "integrity": "sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE=", + "dev": true + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "dev": true + }, + "to-object-path": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", + "integrity": "sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "to-regex": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz", + "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==", + "dev": true, + "requires": { + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "regex-not": "^1.0.2", + "safe-regex": "^1.1.0" + } + }, + "to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + } + }, + "unicode-canonical-property-names-ecmascript": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz", + "integrity": "sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ==", + "dev": true + }, + "unicode-match-property-ecmascript": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-1.0.4.tgz", + "integrity": "sha512-L4Qoh15vTfntsn4P1zqnHulG0LdXgjSO035fEpdtp6YxXhMT51Q6vgM5lYdG/5X3MjS+k/Y9Xw4SFCY9IkR0rg==", + "dev": true, + "requires": { + "unicode-canonical-property-names-ecmascript": "^1.0.4", + "unicode-property-aliases-ecmascript": "^1.0.4" + } + }, + "unicode-match-property-value-ecmascript": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-1.2.0.tgz", + "integrity": "sha512-wjuQHGQVofmSJv1uVISKLE5zO2rNGzM/KCYZch/QQvez7C1hUhBIuZ701fYXExuufJFMPhv2SyL8CyoIfMLbIQ==", + "dev": true + }, + "unicode-property-aliases-ecmascript": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.1.0.tgz", + "integrity": "sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg==", + "dev": true + }, + "union-value": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", + "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", + "dev": true, + "requires": { + "arr-union": "^3.1.0", + "get-value": "^2.0.6", + "is-extendable": "^0.1.1", + "set-value": "^2.0.1" + } + }, + "unset-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", + "integrity": "sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=", + "dev": true, + "requires": { + "has-value": "^0.3.1", + "isobject": "^3.0.0" + }, + "dependencies": { + "has-value": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz", + "integrity": "sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=", + "dev": true, + "requires": { + "get-value": "^2.0.3", + "has-values": "^0.1.4", + "isobject": "^2.0.0" + }, + "dependencies": { + "isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", + "dev": true, + "requires": { + "isarray": "1.0.0" + } + } + } + }, + "has-values": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz", + "integrity": "sha1-bWHeldkd/Km5oCCJrThL/49it3E=", + "dev": true + } + } + }, + "urix": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", + "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=", + "dev": true + }, + "use": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", + "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", + "dev": true + }, + "util.promisify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.1.tgz", + "integrity": "sha512-g9JpC/3He3bm38zsLupWryXHoEcS22YHthuPQSJdMy6KNrzIRzWqcsHzD/WUnqe45whVou4VIsPew37DoXWNrA==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.2", + "has-symbols": "^1.0.1", + "object.getownpropertydescriptors": "^2.1.0" + }, + "dependencies": { + "es-abstract": { + "version": "1.17.6", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.6.tgz", + "integrity": "sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw==", + "dev": true, + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.2.0", + "is-regex": "^1.1.0", + "object-inspect": "^1.7.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.0", + "string.prototype.trimend": "^1.0.1", + "string.prototype.trimstart": "^1.0.1" + } + } + } + }, + "validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "walker": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.7.tgz", + "integrity": "sha1-L3+bj9ENZ3JisYqITijRlhjgKPs=", + "dev": true, + "requires": { + "makeerror": "1.0.x" + } + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "write-file-atomic": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.4.1.tgz", + "integrity": "sha512-TGHFeZEZMnv+gBFRfjAcxL5bPHrsGKtnb4qsFAws7/vlh+QfwAaySIw4AXP9ZskTTh5GWu3FLuJhsWVdiJPGvg==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.11", + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.2" + } + } + } + }, + "@types/color-name": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", + "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", + "dev": true + }, + "@types/estree": { + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.45.tgz", + "integrity": "sha512-jnqIUKDUqJbDIUxm0Uj7bnlMnRm1T/eZ9N+AVMqhPgzrba2GhGG5o/jCTwmdPK709nEZsGoMzXEDUjcXHa3W0g==", + "dev": true + }, + "@types/node": { + "version": "14.11.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.11.1.tgz", + "integrity": "sha512-oTQgnd0hblfLsJ6BvJzzSL+Inogp3lq9fGgqRkMB/ziKMgEUaFl801OncOzUmalfzt14N0oPHMK47ipl+wbTIw==", + "dev": true + }, + "@types/q": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.4.tgz", + "integrity": "sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug==", + "dev": true + }, + "acorn": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.0.tgz", + "integrity": "sha512-+G7P8jJmCHr+S+cLfQxygbWhXy+8YTVGzAkpEbcLo2mLoL7tij/VG41QSHACSf5QgYRhMZYHuNc6drJaO0Da+w==", + "dev": true + }, + "alphanum-sort": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/alphanum-sort/-/alphanum-sort-1.0.2.tgz", + "integrity": "sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM=", + "dev": true + }, + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true + }, + "boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=", + "dev": true + }, + "browserslist": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.14.3.tgz", + "integrity": "sha512-GcZPC5+YqyPO4SFnz48/B0YaCwS47Q9iPChRGi6t7HhflKBcINzFrJvRfC+jp30sRMKxF+d4EHGs27Z0XP1NaQ==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30001131", + "electron-to-chromium": "^1.3.570", + "escalade": "^3.1.0", + "node-releases": "^1.1.61" + } + }, + "caller-callsite": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/caller-callsite/-/caller-callsite-2.0.0.tgz", + "integrity": "sha1-hH4PzgoiN1CpoCfFSzNzGtMVQTQ=", + "dev": true, + "requires": { + "callsites": "^2.0.0" + } + }, + "caller-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-2.0.0.tgz", + "integrity": "sha1-Ro+DBE42mrIBD6xfBs7uFbsssfQ=", + "dev": true, + "requires": { + "caller-callsite": "^2.0.0" + } + }, + "callsites": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-2.0.0.tgz", + "integrity": "sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA=", + "dev": true + }, + "caniuse-api": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", + "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", + "dev": true, + "requires": { + "browserslist": "^4.0.0", + "caniuse-lite": "^1.0.0", + "lodash.memoize": "^4.1.2", + "lodash.uniq": "^4.5.0" + } + }, + "caniuse-lite": { + "version": "1.0.30001131", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001131.tgz", + "integrity": "sha512-4QYi6Mal4MMfQMSqGIRPGbKIbZygeN83QsWq1ixpUwvtfgAZot5BrCKzGygvZaV+CnELdTwD0S4cqUNozq7/Cw==", + "dev": true + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "coa": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/coa/-/coa-2.0.2.tgz", + "integrity": "sha512-q5/jG+YQnSy4nRTV4F7lPepBJZ8qBNJJDBuJdoejDyLXgmL7IEo+Le2JDZudFTFt7mrCqIRaSjws4ygRCTCAXA==", + "dev": true, + "requires": { + "@types/q": "^1.5.1", + "chalk": "^2.4.1", + "q": "^1.1.2" + } + }, + "color": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/color/-/color-3.1.2.tgz", + "integrity": "sha512-vXTJhHebByxZn3lDvDJYw4lR5+uB3vuoHsuYA5AKuxRVn5wzzIfQKGLBmgdVRHKTJYeK5rvJcHnrd0Li49CFpg==", + "dev": true, + "requires": { + "color-convert": "^1.9.1", + "color-string": "^1.5.2" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "color-string": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.3.tgz", + "integrity": "sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw==", + "dev": true, + "requires": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "concat-with-sourcemaps": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/concat-with-sourcemaps/-/concat-with-sourcemaps-1.1.0.tgz", + "integrity": "sha512-4gEjHJFT9e+2W/77h/DS5SGUgwDaOwprX8L/gl5+3ixnzkVJJsZWDSelmN3Oilw3LNDZjZV0yqH1hLG3k6nghg==", + "dev": true, + "requires": { + "source-map": "^0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "convert-source-map": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", + "integrity": "sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.1" + } + }, + "cosmiconfig": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz", + "integrity": "sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA==", + "dev": true, + "requires": { + "import-fresh": "^2.0.0", + "is-directory": "^0.3.1", + "js-yaml": "^3.13.1", + "parse-json": "^4.0.0" + } + }, + "css-color-names": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/css-color-names/-/css-color-names-0.0.4.tgz", + "integrity": "sha1-gIrcLnnPhHOAabZGyyDsJ762KeA=", + "dev": true + }, + "css-declaration-sorter": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-4.0.1.tgz", + "integrity": "sha512-BcxQSKTSEEQUftYpBVnsH4SF05NTuBokb19/sBt6asXGKZ/6VP7PLG1CBCkFDYOnhXhPh0jMhO6xZ71oYHXHBA==", + "dev": true, + "requires": { + "postcss": "^7.0.1", + "timsort": "^0.3.0" + } + }, + "css-modules-loader-core": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/css-modules-loader-core/-/css-modules-loader-core-1.1.0.tgz", + "integrity": "sha1-WQhmgpShvs0mGuCkziGwtVHyHRY=", + "dev": true, + "requires": { + "icss-replace-symbols": "1.1.0", + "postcss": "6.0.1", + "postcss-modules-extract-imports": "1.1.0", + "postcss-modules-local-by-default": "1.2.0", + "postcss-modules-scope": "1.1.0", + "postcss-modules-values": "1.3.0" + }, + "dependencies": { + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + }, + "dependencies": { + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + } + } + }, + "has-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz", + "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=", + "dev": true + }, + "postcss": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.1.tgz", + "integrity": "sha1-AA29H47vIXqjaLmiEsX8QLKo8/I=", + "dev": true, + "requires": { + "chalk": "^1.1.3", + "source-map": "^0.5.6", + "supports-color": "^3.2.3" + } + }, + "supports-color": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz", + "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=", + "dev": true, + "requires": { + "has-flag": "^1.0.0" + } + } + } + }, + "css-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-2.1.0.tgz", + "integrity": "sha512-Dqk7LQKpwLoH3VovzZnkzegqNSuAziQyNZUcrdDM401iY+R5NkGBXGmtO05/yaXQziALuPogeG0b7UAgjnTJTQ==", + "dev": true, + "requires": { + "boolbase": "^1.0.0", + "css-what": "^3.2.1", + "domutils": "^1.7.0", + "nth-check": "^1.0.2" + } + }, + "css-select-base-adapter": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz", + "integrity": "sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w==", + "dev": true + }, + "css-selector-tokenizer": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.7.3.tgz", + "integrity": "sha512-jWQv3oCEL5kMErj4wRnK/OPoBi0D+P1FR2cDCKYPaMeD2eW3/mttav8HT4hT1CKopiJI/psEULjkClhvJo4Lvg==", + "dev": true, + "requires": { + "cssesc": "^3.0.0", + "fastparse": "^1.1.2" + } + }, + "css-tree": { + "version": "1.0.0-alpha.37", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.37.tgz", + "integrity": "sha512-DMxWJg0rnz7UgxKT0Q1HU/L9BeJI0M6ksor0OgqOnF+aRCDWg/N2641HmVyU9KVIu0OVVWOb2IpC9A+BJRnejg==", + "dev": true, + "requires": { + "mdn-data": "2.0.4", + "source-map": "^0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "css-what": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-3.3.0.tgz", + "integrity": "sha512-pv9JPyatiPaQ6pf4OvD/dbfm0o5LviWmwxNWzblYf/1u9QZd0ihV+PMwy5jdQWQ3349kZmKEx9WXuSka2dM4cg==", + "dev": true + }, + "cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true + }, + "cssnano": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-4.1.10.tgz", + "integrity": "sha512-5wny+F6H4/8RgNlaqab4ktc3e0/blKutmq8yNlBFXA//nSFFAqAngjNVRzUvCgYROULmZZUoosL/KSoZo5aUaQ==", + "dev": true, + "requires": { + "cosmiconfig": "^5.0.0", + "cssnano-preset-default": "^4.0.7", + "is-resolvable": "^1.0.0", + "postcss": "^7.0.0" + } + }, + "cssnano-preset-default": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-4.0.7.tgz", + "integrity": "sha512-x0YHHx2h6p0fCl1zY9L9roD7rnlltugGu7zXSKQx6k2rYw0Hi3IqxcoAGF7u9Q5w1nt7vK0ulxV8Lo+EvllGsA==", + "dev": true, + "requires": { + "css-declaration-sorter": "^4.0.1", + "cssnano-util-raw-cache": "^4.0.1", + "postcss": "^7.0.0", + "postcss-calc": "^7.0.1", + "postcss-colormin": "^4.0.3", + "postcss-convert-values": "^4.0.1", + "postcss-discard-comments": "^4.0.2", + "postcss-discard-duplicates": "^4.0.2", + "postcss-discard-empty": "^4.0.1", + "postcss-discard-overridden": "^4.0.1", + "postcss-merge-longhand": "^4.0.11", + "postcss-merge-rules": "^4.0.3", + "postcss-minify-font-values": "^4.0.2", + "postcss-minify-gradients": "^4.0.2", + "postcss-minify-params": "^4.0.2", + "postcss-minify-selectors": "^4.0.2", + "postcss-normalize-charset": "^4.0.1", + "postcss-normalize-display-values": "^4.0.2", + "postcss-normalize-positions": "^4.0.2", + "postcss-normalize-repeat-style": "^4.0.2", + "postcss-normalize-string": "^4.0.2", + "postcss-normalize-timing-functions": "^4.0.2", + "postcss-normalize-unicode": "^4.0.1", + "postcss-normalize-url": "^4.0.1", + "postcss-normalize-whitespace": "^4.0.2", + "postcss-ordered-values": "^4.1.2", + "postcss-reduce-initial": "^4.0.3", + "postcss-reduce-transforms": "^4.0.2", + "postcss-svgo": "^4.0.2", + "postcss-unique-selectors": "^4.0.1" + } + }, + "cssnano-util-get-arguments": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cssnano-util-get-arguments/-/cssnano-util-get-arguments-4.0.0.tgz", + "integrity": "sha1-7ToIKZ8h11dBsg87gfGU7UnMFQ8=", + "dev": true + }, + "cssnano-util-get-match": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cssnano-util-get-match/-/cssnano-util-get-match-4.0.0.tgz", + "integrity": "sha1-wOTKB/U4a7F+xeUiULT1lhNlFW0=", + "dev": true + }, + "cssnano-util-raw-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cssnano-util-raw-cache/-/cssnano-util-raw-cache-4.0.1.tgz", + "integrity": "sha512-qLuYtWK2b2Dy55I8ZX3ky1Z16WYsx544Q0UWViebptpwn/xDBmog2TLg4f+DBMg1rJ6JDWtn96WHbOKDWt1WQA==", + "dev": true, + "requires": { + "postcss": "^7.0.0" + } + }, + "cssnano-util-same-parent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cssnano-util-same-parent/-/cssnano-util-same-parent-4.0.1.tgz", + "integrity": "sha512-WcKx5OY+KoSIAxBW6UBBRay1U6vkYheCdjyVNDm85zt5K9mHoGOfsOsqIszfAqrQQFIIKgjh2+FDgIj/zsl21Q==", + "dev": true + }, + "csso": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/csso/-/csso-4.0.3.tgz", + "integrity": "sha512-NL3spysxUkcrOgnpsT4Xdl2aiEiBG6bXswAABQVHcMrfjjBisFOKwLDOmf4wf32aPdcJws1zds2B0Rg+jqMyHQ==", + "dev": true, + "requires": { + "css-tree": "1.0.0-alpha.39" + }, + "dependencies": { + "css-tree": { + "version": "1.0.0-alpha.39", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.39.tgz", + "integrity": "sha512-7UvkEYgBAHRG9Nt980lYxjsTrCyHFN53ky3wVsDkiMdVqylqRt+Zc+jm5qw7/qyOvN2dHSYtX0e4MbCCExSvnA==", + "dev": true, + "requires": { + "mdn-data": "2.0.6", + "source-map": "^0.6.1" + } + }, + "mdn-data": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.6.tgz", + "integrity": "sha512-rQvjv71olwNHgiTbfPZFkJtjNMciWgswYeciZhtvWLO8bmX3TnhyA62I6sTWOyZssWHJJjY6/KiWwqQsWWsqOA==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "define-properties": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", + "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "dev": true, + "requires": { + "object-keys": "^1.0.12" + } + }, + "dom-serializer": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", + "integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==", + "dev": true, + "requires": { + "domelementtype": "^2.0.1", + "entities": "^2.0.0" + }, + "dependencies": { + "domelementtype": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.0.2.tgz", + "integrity": "sha512-wFwTwCVebUrMgGeAwRL/NhZtHAUyT9n9yg4IMDwf10+6iCMxSkVq9MGCVEH+QZWo1nNidy8kNvwmv4zWHDTqvA==", + "dev": true + } + } + }, + "domelementtype": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", + "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==", + "dev": true + }, + "domutils": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz", + "integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==", + "dev": true, + "requires": { + "dom-serializer": "0", + "domelementtype": "1" + } + }, + "dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, + "requires": { + "is-obj": "^2.0.0" + } + }, + "electron-to-chromium": { + "version": "1.3.570", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.570.tgz", + "integrity": "sha512-Y6OCoVQgFQBP5py6A/06+yWxUZHDlNr/gNDGatjH8AZqXl8X0tE4LfjLJsXGz/JmWJz8a6K7bR1k+QzZ+k//fg==", + "dev": true + }, + "emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true + }, + "entities": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.0.3.tgz", + "integrity": "sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ==", + "dev": true + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "es-abstract": { + "version": "1.17.6", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.6.tgz", + "integrity": "sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw==", + "dev": true, + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.2.0", + "is-regex": "^1.1.0", + "object-inspect": "^1.7.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.0", + "string.prototype.trimend": "^1.0.1", + "string.prototype.trimstart": "^1.0.1" + } + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "escalade": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.0.tgz", + "integrity": "sha512-mAk+hPSO8fLDkhV7V0dXazH5pDc6MrjBTPyD3VeKzxnVFjH1MIxbCdqGZB9O8+EwWakZs3ZCbDS4IpRt79V1ig==", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + }, + "estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true + }, + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true + }, + "fastparse": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz", + "integrity": "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==", + "dev": true + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "generic-names": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generic-names/-/generic-names-2.0.1.tgz", + "integrity": "sha512-kPCHWa1m9wGG/OwQpeweTwM/PYiQLrUIxXbt/P4Nic3LbGjCP0YwrALHW1uNLKZ0LIMg+RF+XRlj2ekT9ZlZAQ==", + "dev": true, + "requires": { + "loader-utils": "^1.1.0" + } + }, + "gensync": { + "version": "1.0.0-beta.1", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.1.tgz", + "integrity": "sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg==", + "dev": true + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "has-symbols": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", + "dev": true + }, + "hex-color-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz", + "integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==", + "dev": true + }, + "hsl-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hsl-regex/-/hsl-regex-1.0.0.tgz", + "integrity": "sha1-1JMwx4ntgZ4nakwNJy3/owsY/m4=", + "dev": true + }, + "hsla-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hsla-regex/-/hsla-regex-1.0.0.tgz", + "integrity": "sha1-wc56MWjIxmFAM6S194d/OyJfnDg=", + "dev": true + }, + "html-comment-regex": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/html-comment-regex/-/html-comment-regex-1.1.2.tgz", + "integrity": "sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ==", + "dev": true + }, + "icss-replace-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz", + "integrity": "sha1-Bupvg2ead0njhs/h/oEq5dsiPe0=", + "dev": true + }, + "import-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-3.0.0.tgz", + "integrity": "sha512-4pnzH16plW+hgvRECbDWpQl3cqtvSofHWh44met7ESfZ8UZOWWddm8hEyDTqREJ9RbYHY8gi8DqmaelApoOGMg==", + "dev": true, + "requires": { + "import-from": "^3.0.0" + } + }, + "import-fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz", + "integrity": "sha1-2BNVwVYS04bGH53dOSLUMEgipUY=", + "dev": true, + "requires": { + "caller-path": "^2.0.0", + "resolve-from": "^3.0.0" + } + }, + "import-from": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/import-from/-/import-from-3.0.0.tgz", + "integrity": "sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ==", + "dev": true, + "requires": { + "resolve-from": "^5.0.0" + }, + "dependencies": { + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + } + } + }, + "indexes-of": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/indexes-of/-/indexes-of-1.0.1.tgz", + "integrity": "sha1-8w9xbI4r00bHtn0985FVZqfAVgc=", + "dev": true + }, + "is-absolute-url": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-2.1.0.tgz", + "integrity": "sha1-UFMN+4T8yap9vnhS6Do3uTufKqY=", + "dev": true + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true + }, + "is-callable": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.1.tgz", + "integrity": "sha512-wliAfSzx6V+6WfMOmus1xy0XvSgf/dlStkvTfq7F0g4bOIW0PSUbnyse3NhDwdyYS1ozfUtAAySqTws3z9Eqgg==", + "dev": true + }, + "is-color-stop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-color-stop/-/is-color-stop-1.1.0.tgz", + "integrity": "sha1-z/9HGu5N1cnhWFmPvhKWe1za00U=", + "dev": true, + "requires": { + "css-color-names": "^0.0.4", + "hex-color-regex": "^1.1.0", + "hsl-regex": "^1.0.0", + "hsla-regex": "^1.0.0", + "rgb-regex": "^1.0.1", + "rgba-regex": "^1.0.0" + } + }, + "is-date-object": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz", + "integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==", + "dev": true + }, + "is-directory": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/is-directory/-/is-directory-0.3.1.tgz", + "integrity": "sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE=", + "dev": true + }, + "is-negative-zero": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.0.tgz", + "integrity": "sha1-lVOxIbD6wohp2p7UWeIMdUN4hGE=", + "dev": true + }, + "is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true + }, + "is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "requires": { + "@types/estree": "*" + } + }, + "is-regex": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.1.tgz", + "integrity": "sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg==", + "dev": true, + "requires": { + "has-symbols": "^1.0.1" + } + }, + "is-resolvable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-resolvable/-/is-resolvable-1.1.0.tgz", + "integrity": "sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg==", + "dev": true + }, + "is-svg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-svg/-/is-svg-3.0.0.tgz", + "integrity": "sha512-gi4iHK53LR2ujhLVVj+37Ykh9GLqYHX6JOVXbLAucaG/Cqw9xwdFOjDM2qeifLs1sF1npXXFvDu0r5HNgCMrzQ==", + "dev": true, + "requires": { + "html-comment-regex": "^1.1.0" + } + }, + "is-symbol": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", + "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", + "dev": true, + "requires": { + "has-symbols": "^1.0.1" + } + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "js-yaml": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz", + "integrity": "sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true + }, + "json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "json5": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz", + "integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==", + "dev": true, + "requires": { + "minimist": "^1.2.5" + } + }, + "loader-utils": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz", + "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^1.0.1" + }, + "dependencies": { + "json5": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + } + } + }, + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==", + "dev": true + }, + "lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY=", + "dev": true + }, + "lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=", + "dev": true + }, + "lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=", + "dev": true + }, + "log-symbols": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz", + "integrity": "sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==", + "dev": true, + "requires": { + "chalk": "^2.4.2" + } + }, + "magic-string": { + "version": "0.25.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz", + "integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==", + "dev": true, + "requires": { + "sourcemap-codec": "^1.4.4" + } + }, + "mdn-data": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz", + "integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==", + "dev": true + }, + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true + }, + "mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "dev": true, + "requires": { + "minimist": "^1.2.5" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node-releases": { + "version": "1.1.61", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.61.tgz", + "integrity": "sha512-DD5vebQLg8jLCOzwupn954fbIiZht05DAZs0k2u8NStSe6h9XdsuIQL8hSRKYiU8WUQRznmSDrKGbv3ObOmC7g==", + "dev": true + }, + "normalize-url": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-3.3.0.tgz", + "integrity": "sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg==", + "dev": true + }, + "nth-check": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz", + "integrity": "sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==", + "dev": true, + "requires": { + "boolbase": "~1.0.0" + } + }, + "object-inspect": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.8.0.tgz", + "integrity": "sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA==", + "dev": true + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true + }, + "object.assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.1.tgz", + "integrity": "sha512-VT/cxmx5yaoHSOTSyrCygIDFco+RsibY2NM0a4RdEeY/4KgqezwFtK1yr3U67xYhqJSlASm2pKhLVzPj2lr4bA==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.18.0-next.0", + "has-symbols": "^1.0.1", + "object-keys": "^1.1.1" + }, + "dependencies": { + "es-abstract": { + "version": "1.18.0-next.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.0.tgz", + "integrity": "sha512-elZXTZXKn51hUBdJjSZGYRujuzilgXo8vSPQzjGYXLvSlGiCo8VO8ZGV3kjo9a0WNJJ57hENagwbtlRuHuzkcQ==", + "dev": true, + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.2.0", + "is-negative-zero": "^2.0.0", + "is-regex": "^1.1.1", + "object-inspect": "^1.8.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.0", + "string.prototype.trimend": "^1.0.1", + "string.prototype.trimstart": "^1.0.1" + } + } + } + }, + "object.getownpropertydescriptors": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.0.tgz", + "integrity": "sha512-Z53Oah9A3TdLoblT7VKJaTDdXdT+lQO+cNpKVnya5JDe9uLvzu1YyY1yFDFrcxrlRgWrEFH0jJtD/IbuwjcEVg==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.0-next.1" + } + }, + "object.values": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.1.tgz", + "integrity": "sha512-WTa54g2K8iu0kmS/us18jEmdv1a4Wi//BZ/DTVYEcH0XhLM5NYdpDHja3gt57VrZLcNAO2WGA+KpWsDBaHt6eA==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.0-next.1", + "function-bind": "^1.1.1", + "has": "^1.0.3" + } + }, + "p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", + "dev": true + }, + "p-queue": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.1.tgz", + "integrity": "sha512-miQiSxLYPYBxGkrldecZC18OTLjdUqnlRebGzPRiVxB8mco7usCmm7hFuxiTvp93K18JnLtE4KMMycjAu/cQQg==", + "dev": true, + "requires": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.1.0" + } + }, + "p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "dev": true, + "requires": { + "p-finally": "^1.0.0" + } + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "path-parse": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", + "dev": true + }, + "pify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz", + "integrity": "sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==", + "dev": true + }, + "postcss": { + "version": "7.0.34", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.34.tgz", + "integrity": "sha512-H/7V2VeNScX9KE83GDrDZNiGT1m2H+UTnlinIzhjlLX9hfMUn1mHNnGeX81a1c8JSBdBvqk7c2ZOG6ZPn5itGw==", + "dev": true, + "requires": { + "chalk": "^2.4.2", + "source-map": "^0.6.1", + "supports-color": "^6.1.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "supports-color": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz", + "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "postcss-calc": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-7.0.4.tgz", + "integrity": "sha512-0I79VRAd1UTkaHzY9w83P39YGO/M3bG7/tNLrHGEunBolfoGM0hSjrGvjoeaj0JE/zIw5GsI2KZ0UwDJqv5hjw==", + "dev": true, + "requires": { + "postcss": "^7.0.27", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.0.2" + } + }, + "postcss-colormin": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-4.0.3.tgz", + "integrity": "sha512-WyQFAdDZpExQh32j0U0feWisZ0dmOtPl44qYmJKkq9xFWY3p+4qnRzCHeNrkeRhwPHz9bQ3mo0/yVkaply0MNw==", + "dev": true, + "requires": { + "browserslist": "^4.0.0", + "color": "^3.0.0", + "has": "^1.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + }, + "dependencies": { + "postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", + "dev": true + } + } + }, + "postcss-convert-values": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-4.0.1.tgz", + "integrity": "sha512-Kisdo1y77KUC0Jmn0OXU/COOJbzM8cImvw1ZFsBgBgMgb1iL23Zs/LXRe3r+EZqM3vGYKdQ2YJVQ5VkJI+zEJQ==", + "dev": true, + "requires": { + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + }, + "dependencies": { + "postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", + "dev": true + } + } + }, + "postcss-discard-comments": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-4.0.2.tgz", + "integrity": "sha512-RJutN259iuRf3IW7GZyLM5Sw4GLTOH8FmsXBnv8Ab/Tc2k4SR4qbV4DNbyyY4+Sjo362SyDmW2DQ7lBSChrpkg==", + "dev": true, + "requires": { + "postcss": "^7.0.0" + } + }, + "postcss-discard-duplicates": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-4.0.2.tgz", + "integrity": "sha512-ZNQfR1gPNAiXZhgENFfEglF93pciw0WxMkJeVmw8eF+JZBbMD7jp6C67GqJAXVZP2BWbOztKfbsdmMp/k8c6oQ==", + "dev": true, + "requires": { + "postcss": "^7.0.0" + } + }, + "postcss-discard-empty": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-4.0.1.tgz", + "integrity": "sha512-B9miTzbznhDjTfjvipfHoqbWKwd0Mj+/fL5s1QOz06wufguil+Xheo4XpOnc4NqKYBCNqqEzgPv2aPBIJLox0w==", + "dev": true, + "requires": { + "postcss": "^7.0.0" + } + }, + "postcss-discard-overridden": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-4.0.1.tgz", + "integrity": "sha512-IYY2bEDD7g1XM1IDEsUT4//iEYCxAmP5oDSFMVU/JVvT7gh+l4fmjciLqGgwjdWpQIdb0Che2VX00QObS5+cTg==", + "dev": true, + "requires": { + "postcss": "^7.0.0" + } + }, + "postcss-load-config": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-2.1.1.tgz", + "integrity": "sha512-D2ENobdoZsW0+BHy4x1CAkXtbXtYWYRIxL/JbtRBqrRGOPtJ2zoga/bEZWhV/ShWB5saVxJMzbMdSyA/vv4tXw==", + "dev": true, + "requires": { + "cosmiconfig": "^5.0.0", + "import-cwd": "^2.0.0" + }, + "dependencies": { + "import-cwd": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz", + "integrity": "sha1-qmzzbnInYShcs3HsZRn1PiQ1sKk=", + "dev": true, + "requires": { + "import-from": "^2.1.0" + } + }, + "import-from": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/import-from/-/import-from-2.1.0.tgz", + "integrity": "sha1-M1238qev/VOqpHHUuAId7ja387E=", + "dev": true, + "requires": { + "resolve-from": "^3.0.0" + } + } + } + }, + "postcss-merge-longhand": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-4.0.11.tgz", + "integrity": "sha512-alx/zmoeXvJjp7L4mxEMjh8lxVlDFX1gqWHzaaQewwMZiVhLo42TEClKaeHbRf6J7j82ZOdTJ808RtN0ZOZwvw==", + "dev": true, + "requires": { + "css-color-names": "0.0.4", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0", + "stylehacks": "^4.0.0" + }, + "dependencies": { + "postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", + "dev": true + } + } + }, + "postcss-merge-rules": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-4.0.3.tgz", + "integrity": "sha512-U7e3r1SbvYzO0Jr3UT/zKBVgYYyhAz0aitvGIYOYK5CPmkNih+WDSsS5tvPrJ8YMQYlEMvsZIiqmn7HdFUaeEQ==", + "dev": true, + "requires": { + "browserslist": "^4.0.0", + "caniuse-api": "^3.0.0", + "cssnano-util-same-parent": "^4.0.0", + "postcss": "^7.0.0", + "postcss-selector-parser": "^3.0.0", + "vendors": "^1.0.0" + }, + "dependencies": { + "postcss-selector-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-3.1.2.tgz", + "integrity": "sha512-h7fJ/5uWuRVyOtkO45pnt1Ih40CEleeyCHzipqAZO2e5H20g25Y48uYnFUiShvY4rZWNJ/Bib/KVPmanaCtOhA==", + "dev": true, + "requires": { + "dot-prop": "^5.2.0", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1" + } + } + } + }, + "postcss-minify-font-values": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-4.0.2.tgz", + "integrity": "sha512-j85oO6OnRU9zPf04+PZv1LYIYOprWm6IA6zkXkrJXyRveDEuQggG6tvoy8ir8ZwjLxLuGfNkCZEQG7zan+Hbtg==", + "dev": true, + "requires": { + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + }, + "dependencies": { + "postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", + "dev": true + } + } + }, + "postcss-minify-gradients": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-4.0.2.tgz", + "integrity": "sha512-qKPfwlONdcf/AndP1U8SJ/uzIJtowHlMaSioKzebAXSG4iJthlWC9iSWznQcX4f66gIWX44RSA841HTHj3wK+Q==", + "dev": true, + "requires": { + "cssnano-util-get-arguments": "^4.0.0", + "is-color-stop": "^1.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + }, + "dependencies": { + "postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", + "dev": true + } + } + }, + "postcss-minify-params": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-4.0.2.tgz", + "integrity": "sha512-G7eWyzEx0xL4/wiBBJxJOz48zAKV2WG3iZOqVhPet/9geefm/Px5uo1fzlHu+DOjT+m0Mmiz3jkQzVHe6wxAWg==", + "dev": true, + "requires": { + "alphanum-sort": "^1.0.0", + "browserslist": "^4.0.0", + "cssnano-util-get-arguments": "^4.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0", + "uniqs": "^2.0.0" + }, + "dependencies": { + "postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", + "dev": true + } + } + }, + "postcss-minify-selectors": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-4.0.2.tgz", + "integrity": "sha512-D5S1iViljXBj9kflQo4YutWnJmwm8VvIsU1GeXJGiG9j8CIg9zs4voPMdQDUmIxetUOh60VilsNzCiAFTOqu3g==", + "dev": true, + "requires": { + "alphanum-sort": "^1.0.0", + "has": "^1.0.0", + "postcss": "^7.0.0", + "postcss-selector-parser": "^3.0.0" + }, + "dependencies": { + "postcss-selector-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-3.1.2.tgz", + "integrity": "sha512-h7fJ/5uWuRVyOtkO45pnt1Ih40CEleeyCHzipqAZO2e5H20g25Y48uYnFUiShvY4rZWNJ/Bib/KVPmanaCtOhA==", + "dev": true, + "requires": { + "dot-prop": "^5.2.0", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1" + } + } + } + }, + "postcss-modules": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules/-/postcss-modules-2.0.0.tgz", + "integrity": "sha512-eqp+Bva+U2cwQO7dECJ8/V+X+uH1HduNeITB0CPPFAu6d/8LKQ32/j+p9rQ2YL1QytVcrNU0X+fBqgGmQIA1Rw==", + "dev": true, + "requires": { + "css-modules-loader-core": "^1.1.0", + "generic-names": "^2.0.1", + "lodash.camelcase": "^4.3.0", + "postcss": "^7.0.1", + "string-hash": "^1.1.1" + } + }, + "postcss-modules-extract-imports": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-1.1.0.tgz", + "integrity": "sha1-thTJcgvmgW6u41+zpfqh26agXds=", + "dev": true, + "requires": { + "postcss": "^6.0.1" + }, + "dependencies": { + "postcss": { + "version": "6.0.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", + "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "source-map": "^0.6.1", + "supports-color": "^5.4.0" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "postcss-modules-local-by-default": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-1.2.0.tgz", + "integrity": "sha1-99gMOYxaOT+nlkRmvRlQCn1hwGk=", + "dev": true, + "requires": { + "css-selector-tokenizer": "^0.7.0", + "postcss": "^6.0.1" + }, + "dependencies": { + "postcss": { + "version": "6.0.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", + "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "source-map": "^0.6.1", + "supports-color": "^5.4.0" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "postcss-modules-scope": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-1.1.0.tgz", + "integrity": "sha1-1upkmUx5+XtipytCb75gVqGUu5A=", + "dev": true, + "requires": { + "css-selector-tokenizer": "^0.7.0", + "postcss": "^6.0.1" + }, + "dependencies": { + "postcss": { + "version": "6.0.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", + "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "source-map": "^0.6.1", + "supports-color": "^5.4.0" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "postcss-modules-values": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-1.3.0.tgz", + "integrity": "sha1-7P+p1+GSUYOJ9CrQ6D9yrsRW6iA=", + "dev": true, + "requires": { + "icss-replace-symbols": "^1.1.0", + "postcss": "^6.0.1" + }, + "dependencies": { + "postcss": { + "version": "6.0.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", + "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "source-map": "^0.6.1", + "supports-color": "^5.4.0" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "postcss-normalize-charset": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-4.0.1.tgz", + "integrity": "sha512-gMXCrrlWh6G27U0hF3vNvR3w8I1s2wOBILvA87iNXaPvSNo5uZAMYsZG7XjCUf1eVxuPfyL4TJ7++SGZLc9A3g==", + "dev": true, + "requires": { + "postcss": "^7.0.0" + } + }, + "postcss-normalize-display-values": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-4.0.2.tgz", + "integrity": "sha512-3F2jcsaMW7+VtRMAqf/3m4cPFhPD3EFRgNs18u+k3lTJJlVe7d0YPO+bnwqo2xg8YiRpDXJI2u8A0wqJxMsQuQ==", + "dev": true, + "requires": { + "cssnano-util-get-match": "^4.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + }, + "dependencies": { + "postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", + "dev": true + } + } + }, + "postcss-normalize-positions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-4.0.2.tgz", + "integrity": "sha512-Dlf3/9AxpxE+NF1fJxYDeggi5WwV35MXGFnnoccP/9qDtFrTArZ0D0R+iKcg5WsUd8nUYMIl8yXDCtcrT8JrdA==", + "dev": true, + "requires": { + "cssnano-util-get-arguments": "^4.0.0", + "has": "^1.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + }, + "dependencies": { + "postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", + "dev": true + } + } + }, + "postcss-normalize-repeat-style": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-4.0.2.tgz", + "integrity": "sha512-qvigdYYMpSuoFs3Is/f5nHdRLJN/ITA7huIoCyqqENJe9PvPmLhNLMu7QTjPdtnVf6OcYYO5SHonx4+fbJE1+Q==", + "dev": true, + "requires": { + "cssnano-util-get-arguments": "^4.0.0", + "cssnano-util-get-match": "^4.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + }, + "dependencies": { + "postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", + "dev": true + } + } + }, + "postcss-normalize-string": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-4.0.2.tgz", + "integrity": "sha512-RrERod97Dnwqq49WNz8qo66ps0swYZDSb6rM57kN2J+aoyEAJfZ6bMx0sx/F9TIEX0xthPGCmeyiam/jXif0eA==", + "dev": true, + "requires": { + "has": "^1.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + }, + "dependencies": { + "postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", + "dev": true + } + } + }, + "postcss-normalize-timing-functions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-4.0.2.tgz", + "integrity": "sha512-acwJY95edP762e++00Ehq9L4sZCEcOPyaHwoaFOhIwWCDfik6YvqsYNxckee65JHLKzuNSSmAdxwD2Cud1Z54A==", + "dev": true, + "requires": { + "cssnano-util-get-match": "^4.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + }, + "dependencies": { + "postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", + "dev": true + } + } + }, + "postcss-normalize-unicode": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-4.0.1.tgz", + "integrity": "sha512-od18Uq2wCYn+vZ/qCOeutvHjB5jm57ToxRaMeNuf0nWVHaP9Hua56QyMF6fs/4FSUnVIw0CBPsU0K4LnBPwYwg==", + "dev": true, + "requires": { + "browserslist": "^4.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + }, + "dependencies": { + "postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", + "dev": true + } + } + }, + "postcss-normalize-url": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-4.0.1.tgz", + "integrity": "sha512-p5oVaF4+IHwu7VpMan/SSpmpYxcJMtkGppYf0VbdH5B6hN8YNmVyJLuY9FmLQTzY3fag5ESUUHDqM+heid0UVA==", + "dev": true, + "requires": { + "is-absolute-url": "^2.0.0", + "normalize-url": "^3.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + }, + "dependencies": { + "postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", + "dev": true + } + } + }, + "postcss-normalize-whitespace": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-4.0.2.tgz", + "integrity": "sha512-tO8QIgrsI3p95r8fyqKV+ufKlSHh9hMJqACqbv2XknufqEDhDvbguXGBBqxw9nsQoXWf0qOqppziKJKHMD4GtA==", + "dev": true, + "requires": { + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + }, + "dependencies": { + "postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", + "dev": true + } + } + }, + "postcss-ordered-values": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-4.1.2.tgz", + "integrity": "sha512-2fCObh5UanxvSxeXrtLtlwVThBvHn6MQcu4ksNT2tsaV2Fg76R2CV98W7wNSlX+5/pFwEyaDwKLLoEV7uRybAw==", + "dev": true, + "requires": { + "cssnano-util-get-arguments": "^4.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + }, + "dependencies": { + "postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", + "dev": true + } + } + }, + "postcss-reduce-initial": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-4.0.3.tgz", + "integrity": "sha512-gKWmR5aUulSjbzOfD9AlJiHCGH6AEVLaM0AV+aSioxUDd16qXP1PCh8d1/BGVvpdWn8k/HiK7n6TjeoXN1F7DA==", + "dev": true, + "requires": { + "browserslist": "^4.0.0", + "caniuse-api": "^3.0.0", + "has": "^1.0.0", + "postcss": "^7.0.0" + } + }, + "postcss-reduce-transforms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-4.0.2.tgz", + "integrity": "sha512-EEVig1Q2QJ4ELpJXMZR8Vt5DQx8/mo+dGWSR7vWXqcob2gQLyQGsionYcGKATXvQzMPn6DSN1vTN7yFximdIAg==", + "dev": true, + "requires": { + "cssnano-util-get-match": "^4.0.0", + "has": "^1.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + }, + "dependencies": { + "postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", + "dev": true + } + } + }, + "postcss-selector-parser": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.2.tgz", + "integrity": "sha512-36P2QR59jDTOAiIkqEprfJDsoNrvwFei3eCqKd1Y0tUsBimsq39BLp7RD+JWny3WgB1zGhJX8XVePwm9k4wdBg==", + "dev": true, + "requires": { + "cssesc": "^3.0.0", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1" + } + }, + "postcss-svgo": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-4.0.2.tgz", + "integrity": "sha512-C6wyjo3VwFm0QgBy+Fu7gCYOkCmgmClghO+pjcxvrcBKtiKt0uCF+hvbMO1fyv5BMImRK90SMb+dwUnfbGd+jw==", + "dev": true, + "requires": { + "is-svg": "^3.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0", + "svgo": "^1.0.0" + }, + "dependencies": { + "postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", + "dev": true + } + } + }, + "postcss-unique-selectors": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-4.0.1.tgz", + "integrity": "sha512-+JanVaryLo9QwZjKrmJgkI4Fn8SBgRO6WXQBJi7KiAVPlmxikB5Jzc4EvXMT2H0/m0RjrVVm9rGNhZddm/8Spg==", + "dev": true, + "requires": { + "alphanum-sort": "^1.0.0", + "postcss": "^7.0.0", + "uniqs": "^2.0.0" + } + }, + "postcss-value-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz", + "integrity": "sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ==", + "dev": true + }, + "promise.series": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/promise.series/-/promise.series-0.2.0.tgz", + "integrity": "sha1-LMfr6Vn8OmYZwEq029yeRS2GS70=", + "dev": true + }, + "q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=", + "dev": true + }, + "resolve": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz", + "integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==", + "dev": true, + "requires": { + "path-parse": "^1.0.6" + } + }, + "resolve-from": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz", + "integrity": "sha1-six699nWiBvItuZTM17rywoYh0g=", + "dev": true + }, + "rgb-regex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgb-regex/-/rgb-regex-1.0.1.tgz", + "integrity": "sha1-wODWiC3w4jviVKR16O3UGRX+rrE=", + "dev": true + }, + "rgba-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rgba-regex/-/rgba-regex-1.0.0.tgz", + "integrity": "sha1-QzdOLiyglosO8VI0YLfXMP8i7rM=", + "dev": true + }, + "rollup": { + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-1.32.1.tgz", + "integrity": "sha512-/2HA0Ec70TvQnXdzynFffkjA6XN+1e2pEv/uKS5Ulca40g2L7KuOE3riasHoNVHOsFD5KKZgDsMk1CP3Tw9s+A==", + "dev": true, + "requires": { + "@types/estree": "*", + "@types/node": "*", + "acorn": "^7.1.0" + } + }, + "rollup-plugin-babel": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-babel/-/rollup-plugin-babel-4.4.0.tgz", + "integrity": "sha512-Lek/TYp1+7g7I+uMfJnnSJ7YWoD58ajo6Oarhlex7lvUce+RCKRuGRSgztDO3/MF/PuGKmUL5iTHKf208UNszw==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.0.0", + "rollup-pluginutils": "^2.8.1" + } + }, + "rollup-plugin-commonjs": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-commonjs/-/rollup-plugin-commonjs-10.1.0.tgz", + "integrity": "sha512-jlXbjZSQg8EIeAAvepNwhJj++qJWNJw1Cl0YnOqKtP5Djx+fFGkp3WRh+W0ASCaFG5w1jhmzDxgu3SJuVxPF4Q==", + "dev": true, + "requires": { + "estree-walker": "^0.6.1", + "is-reference": "^1.1.2", + "magic-string": "^0.25.2", + "resolve": "^1.11.0", + "rollup-pluginutils": "^2.8.1" + } + }, + "rollup-plugin-postcss": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-postcss/-/rollup-plugin-postcss-2.9.0.tgz", + "integrity": "sha512-Y7qDwlqjZMBexbB1kRJf+jKIQL8HR6C+ay53YzN+nNJ64hn1PNZfBE3c61hFUhD//zrMwmm7uBW30RuTi+CD0w==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "concat-with-sourcemaps": "^1.1.0", + "cssnano": "^4.1.10", + "import-cwd": "^3.0.0", + "p-queue": "^6.3.0", + "pify": "^5.0.0", + "postcss": "^7.0.27", + "postcss-load-config": "^2.1.0", + "postcss-modules": "^2.0.0", + "promise.series": "^0.2.0", + "resolve": "^1.16.0", + "rollup-pluginutils": "^2.8.2", + "safe-identifier": "^0.4.1", + "style-inject": "^0.3.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", + "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", + "dev": true, + "requires": { + "@types/color-name": "^1.1.1", + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "rollup-pluginutils": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", + "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "dev": true, + "requires": { + "estree-walker": "^0.6.1" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "safe-identifier": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/safe-identifier/-/safe-identifier-0.4.2.tgz", + "integrity": "sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==", + "dev": true + }, + "sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", + "dev": true + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=", + "dev": true, + "requires": { + "is-arrayish": "^0.3.1" + }, + "dependencies": { + "is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "dev": true + } + } + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + }, + "sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "dev": true + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + }, + "stable": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", + "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", + "dev": true + }, + "string-hash": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/string-hash/-/string-hash-1.1.3.tgz", + "integrity": "sha1-6Kr8CsGFW0Zmkp7X3RJ1311sgRs=", + "dev": true + }, + "string.prototype.trimend": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz", + "integrity": "sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "string.prototype.trimstart": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz", + "integrity": "sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "style-inject": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/style-inject/-/style-inject-0.3.0.tgz", + "integrity": "sha512-IezA2qp+vcdlhJaVm5SOdPPTUu0FCEqfNSli2vRuSIBbu5Nq5UvygTk/VzeCqfLz2Atj3dVII5QBKGZRZ0edzw==", + "dev": true + }, + "stylehacks": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-4.0.3.tgz", + "integrity": "sha512-7GlLk9JwlElY4Y6a/rmbH2MhVlTyVmiJd1PfTCqFaIBEGMYNsrO/v3SeGTdhBThLg4Z+NbOk/qFMwCa+J+3p/g==", + "dev": true, + "requires": { + "browserslist": "^4.0.0", + "postcss": "^7.0.0", + "postcss-selector-parser": "^3.0.0" + }, + "dependencies": { + "postcss-selector-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-3.1.2.tgz", + "integrity": "sha512-h7fJ/5uWuRVyOtkO45pnt1Ih40CEleeyCHzipqAZO2e5H20g25Y48uYnFUiShvY4rZWNJ/Bib/KVPmanaCtOhA==", + "dev": true, + "requires": { + "dot-prop": "^5.2.0", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1" + } + } + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "svgo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-1.3.2.tgz", + "integrity": "sha512-yhy/sQYxR5BkC98CY7o31VGsg014AKLEPxdfhora76l36hD9Rdy5NZA/Ocn6yayNPgSamYdtX2rFJdcv07AYVw==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "coa": "^2.0.2", + "css-select": "^2.0.0", + "css-select-base-adapter": "^0.1.1", + "css-tree": "1.0.0-alpha.37", + "csso": "^4.0.2", + "js-yaml": "^3.13.1", + "mkdirp": "~0.5.1", + "object.values": "^1.1.0", + "sax": "~1.2.4", + "stable": "^0.1.8", + "unquote": "~1.1.1", + "util.promisify": "~1.0.0" + } + }, + "timsort": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz", + "integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=", + "dev": true + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "dev": true + }, + "uniq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz", + "integrity": "sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8=", + "dev": true + }, + "uniqs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/uniqs/-/uniqs-2.0.0.tgz", + "integrity": "sha1-/+3ks2slKQaW5uFl1KWe25mOawI=", + "dev": true + }, + "unquote": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", + "integrity": "sha1-j97XMk7G6IoP+LkF58CYzcCG1UQ=", + "dev": true + }, + "util.promisify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.1.tgz", + "integrity": "sha512-g9JpC/3He3bm38zsLupWryXHoEcS22YHthuPQSJdMy6KNrzIRzWqcsHzD/WUnqe45whVou4VIsPew37DoXWNrA==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.2", + "has-symbols": "^1.0.1", + "object.getownpropertydescriptors": "^2.1.0" + } + }, + "vendors": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/vendors/-/vendors-1.0.4.tgz", + "integrity": "sha512-/juG65kTL4Cy2su4P8HjtkTxk6VmJDiOPBufWniqQ6wknac6jNiXS9vU+hO3wgusiyqWlzTbVHi0dyJqRONg3w==", + "dev": true + } + } + }, + "dateformat": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz", + "integrity": "sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==" + } + } +} diff --git a/components/x-teaser/package.json b/components/x-teaser/package.json index 5852abacb..2099b8dff 100644 --- a/components/x-teaser/package.json +++ b/components/x-teaser/package.json @@ -2,12 +2,12 @@ "name": "@financial-times/x-teaser", "version": "0.0.0", "description": "This module provides templates for use with o-teaser. Teasers are used to present content.", + "source": "src/Teaser.jsx", "main": "dist/Teaser.cjs.js", "module": "dist/Teaser.esm.js", "browser": "dist/Teaser.es5.js", "types": "Props.d.ts", "scripts": { - "prepare": "npm run build", "build": "node rollup.js", "start": "node rollup.js --watch" }, @@ -18,7 +18,8 @@ "license": "ISC", "dependencies": { "@financial-times/x-engine": "file:../../packages/x-engine", - "dateformat": "^3.0.3" + "dateformat": "^3.0.3", + "date-fns": "^1.29.0" }, "devDependencies": { "@financial-times/x-rollup": "file:../../packages/x-rollup" @@ -27,9 +28,9 @@ "type": "git", "url": "https://github.com/Financial-Times/x-dash.git" }, - "homepage": "https://github.com/Financial-Times/x-dash/tree/master/components/x-teaser", + "homepage": "https://github.com/Financial-Times/x-dash/tree/HEAD/components/x-teaser", "engines": { - "node": ">= 6.0.0" + "node": "12.x" }, "publishConfig": { "access": "public" diff --git a/components/x-teaser/readme.md b/components/x-teaser/readme.md index 223c844f3..0e2247012 100644 --- a/components/x-teaser/readme.md +++ b/components/x-teaser/readme.md @@ -4,7 +4,7 @@ This module provides templates for use with [o-teaser](https://github.com/Financ ## Installation -This module is compatible with Node 6+ and is distributed on npm. +This module is supported on Node 12 and is distributed on npm. ```bash npm install --save @financial-times/x-teaser @@ -13,7 +13,7 @@ bower install --save o-teaser The [`x-engine`][engine] module is used to inject your chosen runtime into the component. Please read the `x-engine` documentation first if you are consuming `x-` components for the first time in your application. -[engine]: https://github.com/Financial-Times/x-dash/tree/master/packages/x-engine +[engine]: https://github.com/Financial-Times/x-dash/tree/HEAD/packages/x-engine ## Concepts @@ -117,18 +117,20 @@ Feature | Type | Notes `showImage` | Boolean | `showHeadshot` | Boolean | Takes precedence over image `showVideo` | Boolean | Takes precedence over image or headshot +`showGuidance` | Boolean | Show video captions guidance `showRelatedLinks` | Boolean | `showCustomSlot` | Boolean | #### General Props -Property | Type | Notes ---------------|--------------------------------|------------------------------------------- -`id` | String | Content UUID -`url` | String | Canonical URL -`relativeUrl` | String | URL path, will take precendence over `url` -`type` | String | Content type (article, video, etc.) -`indicators` | [indicators](#indicator-props) | +Property | Type | Notes +----------------|--------------------------------|------------------------------------------- +`id` | String | Content UUID +`url` | String | Canonical URL +`relativeUrl` | String | URL path, will take precendence over `url` +`type` | String | Content type (article, video, etc.) +`indicators` | [indicators](#indicator-props) | +`dataTrackable` | String | Tracking data for the teaser #### Meta Props @@ -166,13 +168,13 @@ Property | Type | Notes #### Image Props -Property | Type | Notes -----------------|-----------------------|-------------------------------- -`image` | [media](#media-props) | -`imageSize` | String | XS, Small, Medium, Large, XL or XXL -`imageLazyload` | Boolean, String | Output image with `data-src` attribute. If this is a string it will be appended to the image as a class name. +Property | Type | Notes +---------------------|-----------------------|-------------------------------- +`image` | [media](#media-props) | +`imageSize` | String | XS, Small, Medium, Large, XL or XXL +`imageLazyLoad` | Boolean, String | Output image with `data-src` attribute. If this is a string it will be appended to the image as a class name. +`imageHighestQuality`| Boolean | Calls image service with "quality=highest" option, works only with XXL images -[nimg]: https://github.com/Financial-Times/n-image/ #### Headshot Props @@ -185,10 +187,11 @@ Property | Type | Notes #### Video Props -Property | Type | Notes ----------|-----------------------|------------------------------------------------ -`video` | [media](#media-props) | Requires [o-video][ov] to create a video player - +Property | Type | Notes +-------------|-----------------------|------------------------------------------------ +`video` | [media](#media-props) | Requires [o-video][ov] to create a video player +`systemCode` | String | Required by o-video to pass with requests. + | | Should be the Biz-Ops code for the implementing system [ov]: https://github.com/Financial-Times/o-video #### Related Links Props @@ -220,7 +223,7 @@ Property | Type | Notes --------------|--------|-------------- `prefLabel` | String | `url` | String | Canonical URL -`relativeUrl` | String | URL path, will take precendence over `url` +`relativeUrl` | String | URL path, will take precedence over `url` #### Link Props @@ -228,7 +231,7 @@ Property | Type | Notes --------------|--------|------------------------------------------- `id` | String | Content UUID `url` | String | Canonical URL -`relativeUrl` | String | URL path, will take precendence over `url` +`relativeUrl` | String | URL path, will take precedence over `url` `type` | String | Content type (article, video, etc.) `title` | String | @@ -236,7 +239,7 @@ Property | Type | Notes Property | Type | Notes ---------|--------|-------------- -`url` | String | Content UUID +`url` | String | Content UUID or, in the case of images, `data:` or `blob:` URL `width` | Number | `height` | Number | diff --git a/components/x-teaser/rollup.js b/components/x-teaser/rollup.js index 6bd63c6aa..defac3883 100644 --- a/components/x-teaser/rollup.js +++ b/components/x-teaser/rollup.js @@ -1,4 +1,4 @@ -const xRollup = require('@financial-times/x-rollup'); -const pkg = require('./package.json'); +const xRollup = require('@financial-times/x-rollup') +const pkg = require('./package.json') -xRollup({ input: './src/Teaser.jsx', pkg }); +xRollup({ input: './src/Teaser.jsx', pkg }) diff --git a/components/x-teaser/src/AlwaysShowTimestamp.jsx b/components/x-teaser/src/AlwaysShowTimestamp.jsx new file mode 100644 index 000000000..a447c4fbc --- /dev/null +++ b/components/x-teaser/src/AlwaysShowTimestamp.jsx @@ -0,0 +1,20 @@ +import { h } from '@financial-times/x-engine' +import TimeStamp from './TimeStamp' +import RelativeTime from './RelativeTime' +import { differenceInCalendarDays } from 'date-fns' + +/** + * Timestamp shown always, the default 4h limit does not apply here + * If same calendar day, we show relative time e.g. X hours ago or Updated X min ago + * If different calendar day, we show full Date time e.g. June 9, 2021 + */ +export default (props) => { + const localTodayDate = new Date().toISOString().substr(0, 10) // keep only the date bit + const dateToCompare = new Date(props.publishedDate).toISOString().substr(0, 10) + + if (differenceInCalendarDays(localTodayDate, dateToCompare) >= 1) { + return <TimeStamp {...props} /> + } else { + return <RelativeTime {...props} showAlways={true} /> + } +} diff --git a/components/x-teaser/src/Container.jsx b/components/x-teaser/src/Container.jsx index c167bf9cf..8a4e66c5e 100644 --- a/components/x-teaser/src/Container.jsx +++ b/components/x-teaser/src/Container.jsx @@ -1,37 +1,40 @@ -import { h } from '@financial-times/x-engine'; -import { media, theme } from './concerns/rules'; +import { h } from '@financial-times/x-engine' +import { media, theme } from './concerns/rules' const dynamicModifiers = (props) => { - const modifiers = []; + const modifiers = [] - const mediaRule = media(props); + const mediaRule = media(props) if (mediaRule) { - modifiers.push(`has-${mediaRule}`); + modifiers.push(`has-${mediaRule}`) } - const themeRule = theme(props); + const themeRule = theme(props) if (themeRule) { - modifiers.push(themeRule); + modifiers.push(themeRule) } - return modifiers; -}; + return modifiers +} export default (props) => { - const computed = dynamicModifiers(props); + const computed = dynamicModifiers(props) // Modifier props may be a string rather than a string[] so concat, don't spread. - const variants = [props.type, props.layout].concat(props.modifiers, computed); + const variants = [props.type, props.layout].concat(props.modifiers, computed) const classNames = variants .filter(Boolean) .map((mod) => `o-teaser--${mod}`) - .join(' '); + .join(' ') return ( - <div className={`o-teaser ${classNames} js-teaser`} data-id={props.id}> + <div + className={`o-teaser ${classNames} js-teaser`} + data-id={props.id} + data-trackable={props.dataTrackable}> {props.children} </div> - ); -}; + ) +} diff --git a/components/x-teaser/src/Content.jsx b/components/x-teaser/src/Content.jsx index 0e40b54f8..aa573c29c 100644 --- a/components/x-teaser/src/Content.jsx +++ b/components/x-teaser/src/Content.jsx @@ -1,5 +1,3 @@ -import { h } from '@financial-times/x-engine'; +import { h } from '@financial-times/x-engine' -export default ({ children = [] }) => ( - <div className="o-teaser__content">{children}</div> -); +export default ({ children = [] }) => <div className="o-teaser__content">{children}</div> diff --git a/components/x-teaser/src/CustomSlot.jsx b/components/x-teaser/src/CustomSlot.jsx index 02cfc9900..dc898cfc7 100644 --- a/components/x-teaser/src/CustomSlot.jsx +++ b/components/x-teaser/src/CustomSlot.jsx @@ -1,4 +1,4 @@ -import { h } from '@financial-times/x-engine'; +import { h } from '@financial-times/x-engine' /** * Render @@ -8,12 +8,11 @@ import { h } from '@financial-times/x-engine'; const render = (action) => { // Allow parent components to pass raw HTML strings if (typeof action === 'string') { - return <span dangerouslySetInnerHTML={{ __html: action }} />; + return <span dangerouslySetInnerHTML={{ __html: action }} /> } else { - return action; + return action } -}; +} -export default ({ customSlot }) => ( +export default ({ customSlot }) => customSlot ? <div className="o-teaser__action">{render(customSlot)}</div> : null -); diff --git a/components/x-teaser/src/Headshot.jsx b/components/x-teaser/src/Headshot.jsx index 0e7fe4fac..813cb4aca 100644 --- a/components/x-teaser/src/Headshot.jsx +++ b/components/x-teaser/src/Headshot.jsx @@ -1,11 +1,12 @@ -import { h } from '@financial-times/x-engine'; -import { ImageSizes } from './concerns/constants'; -import imageService from './concerns/image-service'; +import { h } from '@financial-times/x-engine' +import { ImageSizes } from './concerns/constants' +import imageService from './concerns/image-service' -const DEFAULT_TINT = '054593,d6d5d3'; +// these colours are tweaked from o-colors palette colours to make headshots look less washed out +const DEFAULT_TINT = '054593,d6d5d3' export default ({ headshot, headshotTint }) => { - const options = [`tint=${DEFAULT_TINT || headshotTint}`, 'dpr=2'].join('&'); + const options = { tint: `${headshotTint || DEFAULT_TINT}` } return headshot ? ( <img @@ -16,5 +17,5 @@ export default ({ headshot, headshotTint }) => { aria-hidden="true" src={imageService(headshot, ImageSizes.Headshot, options)} /> - ) : null; -}; + ) : null +} diff --git a/components/x-teaser/src/Image.jsx b/components/x-teaser/src/Image.jsx index 193e22229..18988565d 100644 --- a/components/x-teaser/src/Image.jsx +++ b/components/x-teaser/src/Image.jsx @@ -1,7 +1,7 @@ -import { h } from '@financial-times/x-engine'; -import { ImageSizes } from './concerns/constants'; -import imageService from './concerns/image-service'; -import Link from './Link'; +import { h } from '@financial-times/x-engine' +import { ImageSizes } from './concerns/constants' +import imageService from './concerns/image-service' +import Link from './Link' /** * Aspect Ratio @@ -10,38 +10,44 @@ import Link from './Link'; */ const aspectRatio = ({ width, height }) => { if (typeof width === 'number' && typeof height === 'number') { - const ratio = (100 / width) * height; - return ratio.toFixed(4) + '%'; + const ratio = (100 / width) * height + return ratio.toFixed(4) + '%' } - return null; -}; + return null +} -const NormalImage = ({ src }) => ( - <img className="o-teaser__image" src={src} alt="" /> -); +const NormalImage = ({ src }) => <img className="o-teaser__image" src={src} alt="" /> const LazyImage = ({ src, lazyLoad }) => { - const lazyClassName = typeof lazyLoad === 'string' ? lazyLoad : ''; - return <img className={`o-teaser__image ${lazyClassName}`} data-src={src} alt="" />; -}; + const lazyClassName = typeof lazyLoad === 'string' ? lazyLoad : '' + return <img className={`o-teaser__image ${lazyClassName}`} data-src={src} alt="" /> +} -export default ({ relativeUrl, url, image, imageSize, imageLazyLoad, ...props }) => { - const displayUrl = relativeUrl || url; - const imageSrc = imageService(image.url, ImageSizes[imageSize]); - const ImageComponent = imageLazyLoad ? LazyImage : NormalImage; +export default ({ relativeUrl, url, image, imageSize, imageLazyLoad, imageHighestQuality, ...props }) => { + if (!image || (image && !image.url)) { + return null + } + const displayUrl = relativeUrl || url + const useImageService = !(image.url.startsWith('data:') || image.url.startsWith('blob:')) + const options = imageSize === 'XXL' && imageHighestQuality ? { quality: 'highest' } : {} + const imageSrc = useImageService ? imageService(image.url, ImageSizes[imageSize], options) : image.url + const ImageComponent = imageLazyLoad ? LazyImage : NormalImage - return image ? ( + return ( <div className="o-teaser__image-container js-teaser-image-container"> - <div className="o-teaser__image-placeholder" style={{ paddingBottom: aspectRatio(image) }}> - <Link {...props} url={displayUrl} attrs={{ + <Link + {...props} + url={displayUrl} + attrs={{ 'data-trackable': 'image-link', - 'tab-index': '-1', - 'aria-hidden': 'true', + tabIndex: '-1', + 'aria-hidden': 'true' }}> + <div className="o-teaser__image-placeholder" style={{ paddingBottom: aspectRatio(image) }}> <ImageComponent src={imageSrc} lazyLoad={imageLazyLoad} /> - </Link> - </div> + </div> + </Link> </div> - ) : null; -}; + ) +} diff --git a/components/x-teaser/src/Link.jsx b/components/x-teaser/src/Link.jsx index 9f4baa741..049797ed3 100644 --- a/components/x-teaser/src/Link.jsx +++ b/components/x-teaser/src/Link.jsx @@ -1,14 +1,18 @@ -import { h } from '@financial-times/x-engine'; +import { h } from '@financial-times/x-engine' const BaseLink = ({ url, attrs = {}, children }) => { if (url) { - return <a href={url} {...attrs}>{children}</a>; + return ( + <a href={url} {...attrs}> + {children} + </a> + ) } else { - return <span {...attrs}>{children}</span>; + return <span {...attrs}>{children}</span> } -}; +} export default ({ customElements = {}, ...props }) => { - const Link = customElements.Link || BaseLink; - return <Link {...props} />; -}; + const Link = customElements.Link || BaseLink + return <Link {...props} /> +} diff --git a/components/x-teaser/src/LiveBlogStatus.jsx b/components/x-teaser/src/LiveBlogStatus.jsx index 30df7854c..a8221b734 100644 --- a/components/x-teaser/src/LiveBlogStatus.jsx +++ b/components/x-teaser/src/LiveBlogStatus.jsx @@ -1,21 +1,20 @@ -import { h } from '@financial-times/x-engine'; +import { h } from '@financial-times/x-engine' const LiveBlogLabels = { inprogress: 'Live', comingsoon: 'Coming Soon', closed: '' -}; +} const LiveBlogModifiers = { inprogress: 'live', comingsoon: 'pending', closed: 'closed' -}; +} -export default ({ status }) => ( +export default ({ status }) => status && status !== 'closed' ? ( <div className={`o-teaser__timestamp o-teaser__timestamp--${LiveBlogModifiers[status]}`}> - <span className="o-teaser__timestamp-prefix">{LiveBlogLabels[status]}</span> + <span className="o-teaser__timestamp-prefix">{` ${LiveBlogLabels[status]} `}</span> </div> ) : null -); diff --git a/components/x-teaser/src/Meta.jsx b/components/x-teaser/src/Meta.jsx index c63fd2a6e..2977c4099 100644 --- a/components/x-teaser/src/Meta.jsx +++ b/components/x-teaser/src/Meta.jsx @@ -1,13 +1,9 @@ -import { h } from '@financial-times/x-engine'; -import MetaLink from './MetaLink'; -import Promoted from './Promoted'; +import { h } from '@financial-times/x-engine' +import MetaLink from './MetaLink' +import Promoted from './Promoted' export default (props) => { - const showPromoted = props.promotedPrefixText && props.promotedSuffixText; + const showPromoted = props.promotedPrefixText && props.promotedSuffixText - return ( - <div className="o-teaser__meta"> - {showPromoted ? <Promoted {...props} /> : <MetaLink {...props} />} - </div> - ); -}; + return showPromoted ? <Promoted {...props} /> : <MetaLink {...props} /> +} diff --git a/components/x-teaser/src/MetaLink.jsx b/components/x-teaser/src/MetaLink.jsx index 819791e07..93def3b90 100644 --- a/components/x-teaser/src/MetaLink.jsx +++ b/components/x-teaser/src/MetaLink.jsx @@ -1,33 +1,34 @@ -import { h } from '@financial-times/x-engine'; +import { h } from '@financial-times/x-engine' const sameId = (context = {}, id) => { - return id && context.parentId && id === context.parentId; -}; + return id && context && context.parentId && id === context.parentId +} const sameLabel = (context = {}, label) => { - return label && context.parentLabel && label === context.parentLabel; -}; + return label && context && context.parentLabel && label === context.parentLabel +} export default ({ metaPrefixText, metaLink, metaAltLink, metaSuffixText, context }) => { - const showPrefixText = metaPrefixText && !sameLabel(context, metaPrefixText); - const showSuffixText = metaSuffixText && !sameLabel(context, metaSuffixText); - const linkId = metaLink && metaLink.id; - const linkLabel = metaLink && metaLink.prefLabel; - const useAltLink = sameId(context, linkId) || sameLabel(context, linkLabel); - const displayLink = useAltLink ? metaAltLink : metaLink; + const showPrefixText = metaPrefixText && !sameLabel(context, metaPrefixText) + const showSuffixText = metaSuffixText && !sameLabel(context, metaSuffixText) + const linkId = metaLink && metaLink.id + const linkLabel = metaLink && metaLink.prefLabel + const useAltLink = sameId(context, linkId) || sameLabel(context, linkLabel) + const displayLink = useAltLink ? metaAltLink : metaLink return ( - <div className="o-teaser__meta-tag"> + <div className="o-teaser__meta"> {showPrefixText ? <span className="o-teaser__tag-prefix">{metaPrefixText}</span> : null} {displayLink ? ( <a className="o-teaser__tag" data-trackable="teaser-tag" - href={displayLink.relativeUrl || displayLink.url}> + href={displayLink.relativeUrl || displayLink.url} + aria-label={`Category: ${displayLink.prefLabel}`}> {displayLink.prefLabel} </a> ) : null} {showSuffixText ? <span className="o-teaser__tag-suffix">{metaSuffixText}</span> : null} </div> - ); -}; + ) +} diff --git a/components/x-teaser/src/Promoted.jsx b/components/x-teaser/src/Promoted.jsx index ca5ef997c..79a1378c6 100644 --- a/components/x-teaser/src/Promoted.jsx +++ b/components/x-teaser/src/Promoted.jsx @@ -1,8 +1,8 @@ -import { h } from '@financial-times/x-engine'; +import { h } from '@financial-times/x-engine' export default ({ promotedPrefixText, promotedSuffixText }) => ( - <div className="o-teaser__meta-promoted"> - <span className="o-teaser__promoted-prefix">{promotedPrefixText}</span> + <div className="o-teaser__meta"> + <span className="o-teaser__promoted-prefix">{promotedPrefixText}</span> by <span className="o-teaser__promoted-by">{` ${promotedSuffixText} `}</span> </div> -); +) diff --git a/components/x-teaser/src/RelatedLinks.jsx b/components/x-teaser/src/RelatedLinks.jsx index 1facf8deb..4c954b387 100644 --- a/components/x-teaser/src/RelatedLinks.jsx +++ b/components/x-teaser/src/RelatedLinks.jsx @@ -1,18 +1,20 @@ -import { h } from '@financial-times/x-engine'; +import { h } from '@financial-times/x-engine' -const renderLink = ({ id, url, type, title }, i) => ( - <li - key={`related-${i}`} - data-content-id={id} - className={`o-teaser__related-item o-teaser__related-item--${type}`}> - <a data-trackable="related" href={url}> - {title} - </a> - </li> -); +const renderLink = ({ id, type, title, url, relativeUrl }, i) => { + const displayUrl = relativeUrl || url + return ( + <li + key={`related-${i}`} + data-content-id={id} + className={`o-teaser__related-item o-teaser__related-item--${type}`}> + <a data-trackable="related" href={displayUrl}> + {title} + </a> + </li> + ) +} -export default ({ relatedLinks = [] }) => ( +export default ({ relatedLinks = [] }) => relatedLinks && relatedLinks.length ? ( <ul className="o-teaser__related">{relatedLinks.map(renderLink)}</ul> ) : null -); diff --git a/components/x-teaser/src/RelativeTime.jsx b/components/x-teaser/src/RelativeTime.jsx index 60f868d6a..6efd9911e 100644 --- a/components/x-teaser/src/RelativeTime.jsx +++ b/components/x-teaser/src/RelativeTime.jsx @@ -1,6 +1,6 @@ -import { h } from '@financial-times/x-engine'; -import { isRecent, getRelativeDate, getStatus } from './concerns/date-time'; -import dateformat from 'dateformat'; +import { h } from '@financial-times/x-engine' +import { isRecent, getRelativeDate, getStatus } from './concerns/date-time' +import dateformat from 'dateformat' /** * Display Time @@ -8,28 +8,28 @@ import dateformat from 'dateformat'; * @returns {String} */ const displayTime = (date) => { - const hours = Math.floor(Math.abs(date / 3600000)); - const plural = hours === 1 ? 'hour' : 'hours'; - const suffix = hours === 0 ? '' : `${plural} ago`; + const hours = Math.floor(Math.abs(date / 3600000)) + const plural = hours === 1 ? 'hour' : 'hours' + const suffix = hours === 0 ? '' : `${plural} ago` - return `${hours} ${suffix}`; -}; + return `${hours} ${suffix}` +} -export default ({ publishedDate, firstPublishedDate }) => { - const relativeDate = getRelativeDate(publishedDate); - const status = getStatus(publishedDate, firstPublishedDate); +export default ({ publishedDate, firstPublishedDate, showAlways = false }) => { + const relativeDate = getRelativeDate(publishedDate) + const status = getStatus(publishedDate, firstPublishedDate) - return isRecent(relativeDate) ? ( + return showAlways === true || isRecent(relativeDate) ? ( <div className={`o-teaser__timestamp o-teaser__timestamp--${status}`}> - {status ? <span className="o-teaser__timestamp-prefix">{`${status} `} </span> : null} + {status ? <span className="o-teaser__timestamp-prefix">{` ${status} `} </span> : null} <time className="o-teaser__timestamp-date o-date" data-o-component="o-date" - data-o-date-format="time-ago-limit-4-hours" + data-o-date-format={showAlways ? 'time-ago-limit-24-hours' : 'time-ago-limit-4-hours'} dateTime={dateformat(publishedDate, dateformat.masks.isoDateTime, true)}> {/* Let o-date handle anything < 1 hour on the client */} {status ? '' : displayTime(relativeDate)} </time> </div> - ) : null; -}; + ) : null +} diff --git a/components/x-teaser/src/Standfirst.jsx b/components/x-teaser/src/Standfirst.jsx index 1bb1b42de..b03880983 100644 --- a/components/x-teaser/src/Standfirst.jsx +++ b/components/x-teaser/src/Standfirst.jsx @@ -1,19 +1,21 @@ -import { h } from '@financial-times/x-engine'; -import Link from './Link'; - +import { h } from '@financial-times/x-engine' +import Link from './Link' export default ({ standfirst, altStandfirst, headlineTesting, relativeUrl, url, ...props }) => { - const displayStandfirst = headlineTesting && altStandfirst ? altStandfirst : standfirst; - const displayUrl = relativeUrl || url; - return displayStandfirst ? - <p className="o-teaser__standfirst"> - <Link {...props} url={displayUrl} attrs={{ - 'data-trackable': 'standfirst-link', - tabIndex:-1, - className: 'js-teaser-standfirst-link', - }}> - {displayStandfirst} - </Link> - </p> - : null; -}; + const displayStandfirst = headlineTesting && altStandfirst ? altStandfirst : standfirst + const displayUrl = relativeUrl || url + return displayStandfirst ? ( + <p className="o-teaser__standfirst"> + <Link + {...props} + url={displayUrl} + attrs={{ + 'data-trackable': 'standfirst-link', + tabIndex: -1, + className: 'js-teaser-standfirst-link' + }}> + {displayStandfirst} + </Link> + </p> + ) : null +} diff --git a/components/x-teaser/src/Status.jsx b/components/x-teaser/src/Status.jsx index 2b2275466..7c3cc497a 100644 --- a/components/x-teaser/src/Status.jsx +++ b/components/x-teaser/src/Status.jsx @@ -1,20 +1,23 @@ -import { h } from '@financial-times/x-engine'; -import TimeStamp from './TimeStamp'; -import RelativeTime from './RelativeTime'; -import LiveBlogStatus from './LiveBlogStatus'; +import { h } from '@financial-times/x-engine' +import TimeStamp from './TimeStamp' +import RelativeTime from './RelativeTime' +import LiveBlogStatus from './LiveBlogStatus' +import AlwaysShowTimestamp from './AlwaysShowTimestamp' export default (props) => { if (props.status) { - return <LiveBlogStatus {...props} />; + return <LiveBlogStatus {...props} /> } if (props.publishedDate) { - if (props.useRelativeTime) { - return <RelativeTime {...props} />; + if (props.useRelativeTimeIfToday) { + return <AlwaysShowTimestamp {...props} /> + } else if (props.useRelativeTime) { + return <RelativeTime {...props} /> } else { - return <TimeStamp {...props} />; + return <TimeStamp {...props} /> } } - return null; -}; + return null +} diff --git a/components/x-teaser/src/Teaser.jsx b/components/x-teaser/src/Teaser.jsx index 15076487d..7d3a9841e 100644 --- a/components/x-teaser/src/Teaser.jsx +++ b/components/x-teaser/src/Teaser.jsx @@ -1,17 +1,17 @@ -import { h } from '@financial-times/x-engine'; -import Container from './Container'; -import Content from './Content'; -import CustomSlot from './CustomSlot'; -import Headshot from './Headshot'; -import Image from './Image'; -import Meta from './Meta'; -import RelatedLinks from './RelatedLinks'; -import Status from './Status'; -import Standfirst from './Standfirst'; -import Title from './Title'; -import Video from './Video'; -import { media } from './concerns/rules'; -import presets from './concerns/presets'; +import { h } from '@financial-times/x-engine' +import Container from './Container' +import Content from './Content' +import CustomSlot from './CustomSlot' +import Headshot from './Headshot' +import Image from './Image' +import Meta from './Meta' +import RelatedLinks from './RelatedLinks' +import Status from './Status' +import Standfirst from './Standfirst' +import Title from './Title' +import Video from './Video' +import { media } from './concerns/rules' +import presets from './concerns/presets' const Teaser = (props) => ( <Container {...props}> @@ -27,7 +27,7 @@ const Teaser = (props) => ( {media(props) === 'image' ? <Image {...props} /> : null} {props.showRelatedLinks ? <RelatedLinks {...props} /> : null} </Container> -); +) export { Container, @@ -43,4 +43,4 @@ export { Title, Video, presets -}; +} diff --git a/components/x-teaser/src/TimeStamp.jsx b/components/x-teaser/src/TimeStamp.jsx index 4a2677091..a10535f4d 100644 --- a/components/x-teaser/src/TimeStamp.jsx +++ b/components/x-teaser/src/TimeStamp.jsx @@ -1,5 +1,5 @@ -import { h } from '@financial-times/x-engine'; -import dateformat from 'dateformat'; +import { h } from '@financial-times/x-engine' +import dateformat from 'dateformat' export default ({ publishedDate }) => ( <div className="o-teaser__timestamp"> @@ -9,4 +9,4 @@ export default ({ publishedDate }) => ( {dateformat(publishedDate, dateformat.masks.longDate, true)} </time> </div> -); +) diff --git a/components/x-teaser/src/Title.jsx b/components/x-teaser/src/Title.jsx index f5cb3a23a..1a7a0495e 100644 --- a/components/x-teaser/src/Title.jsx +++ b/components/x-teaser/src/Title.jsx @@ -1,26 +1,37 @@ -import { h } from '@financial-times/x-engine'; -import Link from './Link'; +import { h } from '@financial-times/x-engine' +import Link from './Link' export default ({ title, altTitle, headlineTesting, relativeUrl, url, indicators, ...props }) => { - const displayTitle = headlineTesting && altTitle ? altTitle : title; - const displayUrl = relativeUrl || url; + const displayTitle = headlineTesting && altTitle ? altTitle : title + const displayUrl = relativeUrl || url // o-labels--premium left for backwards compatibility for o-labels v3 - const premiumClass = 'o-labels o-labels--premium o-labels--content-premium'; + const premiumClass = 'o-labels o-labels--premium o-labels--content-premium' + let ariaLabel + if (props.type === 'video') { + ariaLabel = `Watch video ${displayTitle}` + } else if (props.type === 'audio') { + ariaLabel = `Listen to podcast ${displayTitle}` + } return ( <div className="o-teaser__heading"> - <Link {...props} url={displayUrl} attrs={{ - 'data-trackable': 'heading-link', - className: 'js-teaser-heading-link', - }}> + <Link + {...props} + url={displayUrl} + attrs={{ + 'data-trackable': 'heading-link', + className: 'js-teaser-heading-link', + 'aria-label': ariaLabel + }}> {displayTitle} - {' '} </Link> {indicators && indicators.accessLevel === 'premium' ? ( - <span className={premiumClass} aria-label="Premium content"> - Premium + <span> + {' '} + <span className={premiumClass}>Premium</span> + <span className="o-normalise-visually-hidden"> content</span> </span> ) : null} </div> - ); -}; + ) +} diff --git a/components/x-teaser/src/Video.jsx b/components/x-teaser/src/Video.jsx index 383dc22d5..eda359113 100644 --- a/components/x-teaser/src/Video.jsx +++ b/components/x-teaser/src/Video.jsx @@ -1,28 +1,37 @@ -import { h } from '@financial-times/x-engine'; -import Image from './Image'; +import { h } from '@financial-times/x-engine' +import Image from './Image' // Re-format the data for use with o-video -const formatData = (props) => JSON.stringify({ - renditions: [ props.video ], - mainImageUrl: props.image ? props.image.url : null, -}); +const formatData = (props) => + JSON.stringify({ + renditions: [props.video], + mainImageUrl: props.image ? props.image.url : null + }) // To prevent React from touching the DOM after mounting… return an empty <div /> // <https://reactjs.org/docs/integrating-with-other-libraries.html> -const Embed = (props) => ( - <div className="o-teaser__image-container js-image-container"> - <div - className="o-video" - data-o-component="o-video" - data-o-video-id={props.id} - data-o-video-data={formatData(props)} - data-o-video-autorender="true" - data-o-video-playsinline="true" - data-o-video-placeholder="true" - data-o-video-placeholder-info="[]" - data-o-video-placeholder-hint="Play video" /> - </div> -); +const Embed = (props) => { + const showGuidance = typeof props.showGuidance === 'boolean' ? props.showGuidance.toString() : 'true' + return ( + <div className="o-teaser__image-container js-image-container"> + <div + className="o-video" + data-o-component="o-video" + data-o-video-id={props.id} + data-o-video-data={formatData(props)} + data-o-video-optimumwidth="640" + data-o-video-optimumvideowidth="640" + data-o-video-autorender="true" + data-o-video-playsinline="true" + data-o-video-show-guidance={showGuidance} + data-o-video-placeholder="true" + data-o-video-placeholder-info="[]" + data-o-video-placeholder-hint="Play video" + data-o-video-systemcode={props.systemCode} + /> + </div> + ) +} export default (props) => ( <div className="o-teaser__video"> @@ -33,4 +42,4 @@ export default (props) => ( <Image {...props} /> </div> </div> -); +) diff --git a/components/x-teaser/src/concerns/constants.js b/components/x-teaser/src/concerns/constants.js index f388e68d0..eb95ed2a8 100644 --- a/components/x-teaser/src/concerns/constants.js +++ b/components/x-teaser/src/concerns/constants.js @@ -6,15 +6,15 @@ export const ImageSizes = { Large: 420, XL: 640, XXL: 1180 // max width of FT.com page -}; +} export const Layouts = { Small: 'small', Large: 'large', Hero: 'hero', TopStory: 'top-story' -}; +} -export const Newish = 1000 * 60 * 60; +export const Newish = 1000 * 60 * 60 -export const Recent = 1000 * 60 * 60 * 4; +export const Recent = 1000 * 60 * 60 * 4 diff --git a/components/x-teaser/src/concerns/date-time.js b/components/x-teaser/src/concerns/date-time.js index 6273f6cd9..8debc5330 100644 --- a/components/x-teaser/src/concerns/date-time.js +++ b/components/x-teaser/src/concerns/date-time.js @@ -1,4 +1,4 @@ -import { Newish, Recent } from './constants'; +import { Newish, Recent } from './constants' /** * To Date @@ -7,14 +7,14 @@ import { Newish, Recent } from './constants'; */ export function toDate(date) { if (typeof date === 'string') { - return new Date(date); + return new Date(date) } if (typeof date === 'number') { - return new Date(date); + return new Date(date) } - return date; + return date } /** @@ -23,7 +23,7 @@ export function toDate(date) { * @returns {Number} */ export function getRelativeDate(date) { - return Date.now() - toDate(date).getTime(); + return Date.now() - toDate(date).getTime() } /** @@ -35,13 +35,13 @@ export function getRelativeDate(date) { export function getStatus(publishedDate, firstPublishedDate) { if (getRelativeDate(publishedDate) < Newish) { if (publishedDate === firstPublishedDate) { - return 'new'; + return 'new' } else { - return 'updated'; + return 'updated' } } - return ''; + return '' } /** @@ -50,5 +50,5 @@ export function getStatus(publishedDate, firstPublishedDate) { * @returns {Boolean} */ export function isRecent(relativeDate) { - return relativeDate < Recent; + return relativeDate < Recent } diff --git a/components/x-teaser/src/concerns/image-service.js b/components/x-teaser/src/concerns/image-service.js index ee0e06a35..e77037dfd 100644 --- a/components/x-teaser/src/concerns/image-service.js +++ b/components/x-teaser/src/concerns/image-service.js @@ -1,5 +1,5 @@ -const BASE_URL = 'https://www.ft.com/__origami/service/image/v2/images/raw'; -const OPTIONS = ['source=next', 'fit=scale-down', 'compression=best']; +const BASE_URL = 'https://www.ft.com/__origami/service/image/v2/images/raw' +const OPTIONS = { source: 'next', fit: 'scale-down', dpr: 2 } /** * Image Service @@ -8,7 +8,8 @@ const OPTIONS = ['source=next', 'fit=scale-down', 'compression=best']; * @param {String} options */ export default function imageService(url, width, options) { - const encoded = encodeURIComponent(url); - const href = `${BASE_URL}/${encoded}?${OPTIONS.join('&')}&width=${width}`; - return options ? href + '&' + options : href; + const imageSrc = new URL(`${BASE_URL}/${encodeURIComponent(url)}`) + imageSrc.search = new URLSearchParams({ ...OPTIONS, ...options }) + imageSrc.searchParams.set('width', width) + return imageSrc.href } diff --git a/components/x-teaser/src/concerns/presets.js b/components/x-teaser/src/concerns/presets.js index 4b624628f..b358dec1b 100644 --- a/components/x-teaser/src/concerns/presets.js +++ b/components/x-teaser/src/concerns/presets.js @@ -1,4 +1,4 @@ -import { Layouts } from './constants'; +import { Layouts } from './constants' const Small = { layout: Layouts.Small, @@ -6,7 +6,7 @@ const Small = { showMeta: true, showTitle: true, showStatus: true -}; +} const SmallHeavy = { layout: Layouts.Small, @@ -17,7 +17,7 @@ const SmallHeavy = { showStatus: true, showImage: true, imageSize: 'Small' -}; +} const Large = { layout: Layouts.Large, @@ -28,7 +28,7 @@ const Large = { showStatus: true, showImage: true, imageSize: 'Medium' -}; +} const Hero = { layout: Layouts.Hero, @@ -38,7 +38,7 @@ const Hero = { showStatus: true, showImage: true, imageSize: 'Medium' -}; +} const HeroNarrow = { layout: Layouts.Hero, @@ -47,7 +47,7 @@ const HeroNarrow = { showTitle: true, showStandfirst: true, showStatus: true -}; +} const HeroVideo = { layout: Layouts.Hero, @@ -56,7 +56,7 @@ const HeroVideo = { showTitle: true, showVideo: true, imageSize: 'Large' -}; +} const HeroOverlay = { layout: Layouts.Hero, @@ -67,7 +67,7 @@ const HeroOverlay = { showImage: true, imageSize: 'XL', modifiers: ['hero-image'] -}; +} const TopStory = { layout: Layouts.TopStory, @@ -77,7 +77,7 @@ const TopStory = { showStandfirst: true, showStatus: true, showRelatedLinks: true -}; +} const TopStoryLandscape = { layout: Layouts.TopStory, @@ -90,7 +90,7 @@ const TopStoryLandscape = { imageSize: 'XL', showRelatedLinks: true, modifiers: ['landscape'] -}; +} export default { Small, @@ -102,4 +102,4 @@ export default { HeroOverlay, TopStory, TopStoryLandscape -}; +} diff --git a/components/x-teaser/src/concerns/rules.js b/components/x-teaser/src/concerns/rules.js index ec44de1cf..ed44dfa8a 100644 --- a/components/x-teaser/src/concerns/rules.js +++ b/components/x-teaser/src/concerns/rules.js @@ -6,20 +6,20 @@ const rulesets = { media: (props) => { // If this condition evaluates to true then no headshot nor image will be displayed. if (props.showVideo && props.video && props.video.url) { - return 'video'; + return 'video' } if (props.showHeadshot && props.headshot && props.indicators.isColumn) { - return 'headshot'; + return 'headshot' } if (props.showImage && props.image && props.image.url) { - return 'image'; + return 'image' } }, theme: (props) => { if (props.theme) { - return props.theme; + return props.theme } if (props.status === 'inprogress') { @@ -35,10 +35,10 @@ const rulesets = { } if (props.parentTheme) { - return props.parentTheme; + return props.parentTheme } } -}; +} /** * Rules @@ -48,11 +48,11 @@ const rulesets = { */ export default function rules(rule, props) { if (rulesets.hasOwnProperty(rule)) { - return rulesets[rule](props); + return rulesets[rule](props) } else { - throw Error(`No ruleset available named ${rule}`); + throw Error(`No ruleset available named ${rule}`) } } -export const media = (props) => rules('media', props); -export const theme = (props) => rules('theme', props); +export const media = (props) => rules('media', props) +export const theme = (props) => rules('theme', props) diff --git a/components/x-teaser/stories/article.js b/components/x-teaser/stories/article.js deleted file mode 100644 index 7da5fea98..000000000 --- a/components/x-teaser/stories/article.js +++ /dev/null @@ -1,77 +0,0 @@ -const { presets } = require('../'); - -exports.title = 'Article'; - -// This data will provide defaults for the Knobs defined below and used -// to render examples in the documentation site. -exports.data = Object.assign({ - type: 'article', - id: '', - url: '#', - title: 'Inside charity fundraiser where hostesses are put on show', - altTitle: 'Men Only, the charity fundraiser with hostesses on show', - standfirst: 'FT investigation finds groping and sexual harassment at secretive black-tie dinner', - altStandfirst: 'Groping and sexual harassment at black-tie dinner charity event', - publishedDate: '2018-01-23T15:07:00.000Z', - firstPublishedDate: '2018-01-23T13:53:00.000Z', - metaPrefixText: '', - metaSuffixText: '', - metaLink: { - url: '#', - prefLabel: 'Sexual misconduct allegations' - }, - metaAltLink: { - url: '#', - prefLabel: 'FT Investigations' - }, - image: { - url: 'http://prod-upp-image-read.ft.com/a25832ea-0053-11e8-9650-9c0ad2d7c5b5', - width: 2048, - height: 1152 - }, - indicators: { - isEditorsChoice: true - } -}, presets.SmallHeavy); - -// A list of properties to pass to the component when rendered in Storybook. If a Knob -// exists for the property then it will be editable with the default as defined above. -exports.knobs = [ - 'id', - 'url', - 'type', - // Meta - 'showMeta', - 'metaPrefixText', - 'metaSuffixText', - 'metaLink', - // Title - 'showTitle', - 'title', - 'altTitle', - // Standfirst - 'showStandfirst', - 'standfirst', - 'altStandfirst', - // Status - 'showStatus', - 'publishedDate', - 'firstPublishedDate', - 'useRelativeTime', - 'status', - // Image - 'showImage', - 'image', - 'imageSize', - // Indicators - 'indicators', - // Context - 'headlineTesting', - // Variants - 'layout', - 'modifiers', -]; - -// This reference is only required for hot module loading in development -// <https://webpack.js.org/concepts/hot-module-replacement/> -exports.m = module; diff --git a/components/x-teaser/stories/index.js b/components/x-teaser/stories/index.js deleted file mode 100644 index 63b6fac6d..000000000 --- a/components/x-teaser/stories/index.js +++ /dev/null @@ -1,27 +0,0 @@ -const { Teaser } = require('../'); - -exports.component = Teaser; - -exports.package = require('../package.json'); - -exports.dependencies = { - 'o-normalise': '^1.6.0', - 'o-date': '^2.11.0', - 'o-typography': '^5.5.0', - 'o-teaser': '^3.5.0', - 'o-labels': '^4.2.1', - 'o-video': '^4.1.0', -}; - -exports.stories = [ - require('./article'), - require('./podcast'), - require('./opinion'), - require('./package'), - require('./package-item'), - require('./promoted'), - require('./top-story'), - require('./video'), -]; - -exports.knobs = require('./knobs'); diff --git a/components/x-teaser/stories/knobs.js b/components/x-teaser/stories/knobs.js deleted file mode 100644 index f9f1d9989..000000000 --- a/components/x-teaser/stories/knobs.js +++ /dev/null @@ -1,213 +0,0 @@ -// To ensure that component stories do not need to depend on Storybook themselves we return a -// function that may be passed the required dependencies. -module.exports = (data, { object, text, number, boolean, date, select }) => { - const Groups = { - Meta: 'Meta', - Title: 'Title', - Standfirst: 'Standfirst', - Status: 'Status', - Image: 'Image', - Headshot: 'Headshot', - Video: 'Video', - RelatedLinks: 'Related Links', - Indicators: 'Indicators', - Context: 'Context', - Variant: 'Variant' - }; - - const Features = { - showMeta() { - return boolean('Show meta', data.showMeta, Groups.Meta); - }, - showTitle() { - return boolean('Show title', data.showTitle, Groups.Title); - }, - showStandfirst() { - return boolean('Show standfirst', data.showStandfirst, Groups.Standfirst); - }, - showStatus() { - return boolean('Show status', data.showStatus, Groups.Status); - }, - showImage() { - return boolean('Show image', data.showImage, Groups.Image); - }, - showHeadshot() { - return boolean('Show headshot', data.showHeadshot, Groups.Headshot); - }, - showVideo() { - return boolean('Show video', data.showVideo, Groups.Video); - }, - showRelatedLinks() { - return boolean('Show related links', data.showRelatedLinks, Groups.RelatedLinks); - } - }; - - const Meta = { - metaPrefixText() { - return text('Meta prefix text', data.metaPrefixText, Groups.Meta); - }, - metaSuffixText() { - return text('Meta suffix text', data.metaSuffixText, Groups.Meta); - }, - metaLink() { - return { - url: data.metaLink.url, - prefLabel: text('Meta link', data.metaLink.prefLabel, Groups.Meta) - }; - }, - metaAltLink() { - return { - url: data.metaAltLink.url, - prefLabel: text('Alt meta link', data.metaAltLink.prefLabel, Groups.Meta) - }; - }, - promotedPrefixText() { - return text('Promoted prefix text', data.promotedPrefixText, Groups.Meta); - }, - promotedSuffixText() { - return text('Promoted suffix text', data.promotedSuffixText, Groups.Meta); - } - }; - - const Title = { - title() { - return text('Title', data.title, Groups.Title); - }, - altTitle() { - return text('Alt title', data.altTitle, Groups.Title); - } - }; - - const Standfirst = { - standfirst() { - return text('Standfirst', data.standfirst, Groups.Standfirst); - }, - altStandfirst() { - return text('Alt standfirst', data.altStandfirst, Groups.Standfirst); - } - }; - - const Status = { - publishedDate() { - return date('Published date', new Date(data.publishedDate), Groups.Status); - }, - firstPublishedDate() { - return date('First published date', new Date(data.firstPublishedDate), Groups.Status); - }, - useRelativeTime() { - return boolean('Use relative time', data.useRelativeTime, Groups.Status); - }, - status() { - return select( - 'Live blog status', - { - None: '', - 'Coming soon': 'comingsoon', - 'In progress': 'inprogress', - Closed: 'closed' - }, - '', - Groups.Status - ); - } - }; - - const Image = { - image() { - return { - url: text('Image URL', data.image.url, Groups.Image), - width: number('Image width', data.image.width, {}, Groups.Image), - height: number('Image height', data.image.height, {}, Groups.Image) - }; - }, - imageSize() { - return select('Image size', ['XS', 'Small', 'Medium', 'Large', 'XL', 'XXL'], data.imageSize, Groups.Image); - } - }; - - const Headshot = { - headshot() { - return text('Headshot', data.headshot, Groups.Headshot); - }, - headshotTint() { - return select('Headshot tint', { 'Default': '' }, 'Default', Groups.Headshot); - } - }; - - const Video = { - video() { - return { - url: text('Video URL', data.video.url, Groups.Video), - width: number('Video width', data.video.width, {}, Groups.Video), - height: number('Video height', data.video.height, {}, Groups.Video), - mediaType: data.video.mediaType, - codec: data.video.codec - }; - } - }; - - const RelatedLinks = { - relatedLinks() { - return object('Related links', data.relatedLinks, Groups.RelatedLinks); - } - }; - - const Indicators = { - indicators() { - return { - accessLevel: select('Access level', ['free', 'registered', 'subscribed', 'premium'], data.indicators.accessLevel || 'free', Groups.Indicators), - isOpinion: boolean('Is opinion', data.indicators.isOpinion, Groups.Indicators), - isColumn: boolean('Is column', data.indicators.isColumn, Groups.Indicators), - isPodcast: boolean('Is podcast', data.indicators.isPodcast, Groups.Indicators), - isEditorsChoice: boolean('Is editor\'s choice', data.indicators.isEditorsChoice, Groups.Indicators), - isExclusive: boolean('Is exclusive', data.indicators.isExclusive, Groups.Indicators), - isScoop: boolean('Is scoop', data.indicators.isScoop, Groups.Indicators) - }; - } - }; - - const Context = { - headlineTesting() { - return boolean('Headline testing', false, Groups.Context); - }, - parentId() { - return text('Parent ID', data.context.parentId, Groups.Context); - }, - parentLabel() { - return text('Parent Label', data.context.parentLabel, Groups.Context); - } - }; - - const Variant = { - layout() { - return select('Layout', ['small', 'large', 'hero', 'top-story'], data.layout, Groups.Variant); - }, - theme() { - return select('Theme', { 'None': '', 'Extra': 'extra-article', 'Special Report': 'highlight' }, data.theme, Groups.Variant); - }, - parentTheme() { - return select('Parent theme', { 'None': '', 'Extra': 'extra-article', 'Special Report': 'highlight' }, data.parentTheme, Groups.Variant); - }, - modifiers() { - return select( - 'Modifiers', - { - // Currently no support for optgroups or multiple selections - 'None': '', - 'Small stacked': 'stacked', - 'Small image on right': 'image-on-right', - 'Large portrait': 'large-portrait', - 'Large landscape': 'large-landscape', - 'Hero centre': 'centre', - 'Hero image': 'hero-image', - 'Top story landscape': 'landscape', - 'Top story big': 'big-story' - }, - data.modifiers || '', - Groups.Variant - ); - } - }; - - return Object.assign({}, Features, Meta, Title, Standfirst, Status, Video, Headshot, Image, RelatedLinks, Indicators, Context, Variant); -}; diff --git a/components/x-teaser/stories/opinion.js b/components/x-teaser/stories/opinion.js deleted file mode 100644 index bd6be8047..000000000 --- a/components/x-teaser/stories/opinion.js +++ /dev/null @@ -1,78 +0,0 @@ -const { presets } = require('../'); - -exports.title = 'Opinion Piece'; - -// This data will provide defaults for the Knobs defined below and used -// to render examples in the documentation site. -exports.data = Object.assign({ - type: 'article', - id: '', - url: '#', - title: 'Anti-Semitism and the threat of identity politics', - altTitle: '', - standfirst: 'Today, hatred of Jews is mixed in with fights about Islam and Israel', - altStandfirst: 'Anti-Semitism and identity politics', - publishedDate: '2018-04-02T12:22:01.000Z', - firstPublishedDate: '2018-04-02T12:22:01.000Z', - metaPrefixText: '', - metaSuffixText: '', - metaLink: { - url: '#', - prefLabel: 'Gideon Rachman' - }, - metaAltLink: { - url: '#', - prefLabel: 'Anti-Semitism' - }, - image: { - url: 'http://prod-upp-image-read.ft.com/1005ca96-364b-11e8-8b98-2f31af407cc8', - width: 2048, - height: 1152 - }, - headshot: 'fthead-v1:gideon-rachman', - indicators: { - isOpinion: true, - isColumn: true - } -}, presets.SmallHeavy, { showHeadshot: true }); - -// A list of properties to pass to the component when rendered in Storybook. If a Knob -// exists for the property then it will be editable with the default as defined above. -exports.knobs = [ - 'id', - 'url', - 'type', - // Meta - 'showMeta', - 'metaPrefixText', - 'metaSuffixText', - 'metaLink', - // Title - 'showTitle', - 'title', - // Standfirst - 'showStandfirst', - 'standfirst', - // Status - 'showStatus', - 'publishedDate', - 'firstPublishedDate', - 'useRelativeTime', - 'status', - // Headshot - 'showHeadshot', - 'headshot', - // Image - 'showImage', - 'image', - 'imageSize', - // Variants - 'layout', - 'modifiers', - // Indicators - 'indicators', -]; - -// This reference is only required for hot module loading in development -// <https://webpack.js.org/concepts/hot-module-replacement/> -exports.m = module; diff --git a/components/x-teaser/stories/package-item.js b/components/x-teaser/stories/package-item.js deleted file mode 100644 index 5de22f7a6..000000000 --- a/components/x-teaser/stories/package-item.js +++ /dev/null @@ -1,68 +0,0 @@ -const { presets } = require('../'); - -exports.title = 'Package item'; - -// This data will provide defaults for the Knobs defined below and used -// to render examples in the documentation site. -exports.data = Object.assign({ - type: 'article', - id: '', - url: '#', - title: 'Why so little has changed since the crash', - standfirst: 'Martin Wolf on the power of vested interests in today’s rent-extracting economy', - publishedDate: '2018-09-02T15:07:00.000Z', - firstPublishedDate: '2018-09-02T13:53:00.000Z', - metaPrefixText: 'FT Series', - metaSuffixText: '', - metaLink: { - url: '#', - prefLabel: 'Financial crisis: Are we safer now? ' - }, - image: { - url: 'http://prod-upp-image-read.ft.com/a25832ea-0053-11e8-9650-9c0ad2d7c5b5', - width: 2048, - height: 1152 - }, - indicators: { - isOpinion: true - } -}, presets.Hero, { parentTheme: 'extra-article', modifiers: 'centre' }); - -// A list of properties to pass to the component when rendered in Storybook. If a Knob -// exists for the property then it will be editable with the default as defined above. -exports.knobs = [ - 'id', - 'url', - 'type', - // Meta - 'showMeta', - 'metaPrefixText', - 'metaSuffixText', - 'metaLink', - // Title - 'showTitle', - 'title', - // Standfirst - 'showStandfirst', - 'standfirst', - // Status - 'showStatus', - 'publishedDate', - 'firstPublishedDate', - 'useRelativeTime', - // Image - 'showImage', - 'image', - 'imageSize', - // Indicators - 'indicators', - // Variants - 'layout', - 'theme', - 'parentTheme', - 'modifiers', -]; - -// This reference is only required for hot module loading in development -// <https://webpack.js.org/concepts/hot-module-replacement/> -exports.m = module; diff --git a/components/x-teaser/stories/package.js b/components/x-teaser/stories/package.js deleted file mode 100644 index 05a3e1bbc..000000000 --- a/components/x-teaser/stories/package.js +++ /dev/null @@ -1,66 +0,0 @@ -const { presets } = require('../'); - -exports.title = 'Content Package'; - -// This data will provide defaults for the Knobs defined below and used -// to render examples in the documentation site. -exports.data = Object.assign({ - type: 'package', - id: '', - url: '#', - title: 'The royal wedding', - altTitle: '', - standfirst: 'Prince Harry and Meghan Markle will tie the knot at Windsor Castle', - altStandfirst: '', - publishedDate: '2018-05-14T16:38:49.000Z', - firstPublishedDate: '2018-05-14T16:38:49.000Z', - metaPrefixText: '', - metaSuffixText: '', - metaLink: { - url: '#', - prefLabel: 'FT Magazine' - }, - metaAltLink: { - url: '#', - prefLabel: 'FT Series' - }, - image: { - url: 'http://prod-upp-image-read.ft.com/7e97f5b6-578d-11e8-b8b2-d6ceb45fa9d0', - width: 2048, - height: 1152 - } -}, presets.Hero, { modifiers: 'centre' }); - -// A list of properties to pass to the component when rendered in Storybook. If a Knob -// exists for the property then it will be editable with the default as defined above. -exports.knobs = [ - 'id', - 'url', - 'type', - // Meta - 'showMeta', - 'metaLink', - // Title - 'showTitle', - 'title', - // Standfirst - 'showStandfirst', - 'standfirst', - // Status - 'showStatus', - 'publishedDate', - 'firstPublishedDate', - 'useRelativeTime', - // Image - 'showImage', - 'image', - 'imageSize', - // Variants - 'layout', - 'theme', - 'modifiers', -]; - -// This reference is only required for hot module loading in development -// <https://webpack.js.org/concepts/hot-module-replacement/> -exports.m = module; diff --git a/components/x-teaser/stories/podcast.js b/components/x-teaser/stories/podcast.js deleted file mode 100644 index 4527ba98e..000000000 --- a/components/x-teaser/stories/podcast.js +++ /dev/null @@ -1,72 +0,0 @@ -const { presets } = require('../'); - -exports.title = 'Podcast'; - -// This data will provide defaults for the Knobs defined below and used -// to render examples in the documentation site. -exports.data = Object.assign({ - type: 'audio', - id: 'd1246074-f7d3-4aaf-951c-80a6db495765', - url: 'https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765', - title: 'Who sets the internet standards?', - standfirst: 'Hannah Kuchler talks to American social scientist and cyber security expert Andrea…', - altStandfirst: '', - publishedDate: '2018-10-24T04:00:00.000Z', - firstPublishedDate: '2018-10-24T04:00:00.000Z', - metaSuffixText: '12 mins', - metaLink: { - url: '#', - prefLabel: 'Tech Tonic podcast' - }, - metaAltLink: null, - image: { - url: 'https://www.ft.com/__origami/service/image/v2/images/raw/http%3A%2F%2Fprod-upp-image-read.ft.com%2F5d1a54aa-f49b-11e8-ae55-df4bf40f9d0d?source=next&fit=scale-down&compression=best&width=240', - width: 2048, - height: 1152 - }, - indicators: { - "isPodcast": true - } -}, presets.SmallHeavy); - -// A list of properties to pass to the component when rendered in Storybook. If a Knob -// exists for the property then it will be editable with the default as defined above. -exports.knobs = [ - 'id', - 'url', - 'type', - // Meta - 'showMeta', - 'metaPrefixText', - 'metaSuffixText', - 'metaLink', - // Title - 'showTitle', - 'title', - 'altTitle', - // Standfirst - 'showStandfirst', - 'standfirst', - 'altStandfirst', - // Status - 'showStatus', - 'publishedDate', - 'firstPublishedDate', - 'useRelativeTime', - 'status', - // Image - 'showImage', - 'image', - 'imageSize', - // Indicators - 'indicators', - // Context - 'headlineTesting', - // Variants - 'layout', - 'modifiers', -]; - -// This reference is only required for hot module loading in development -// <https://webpack.js.org/concepts/hot-module-replacement/> -exports.m = module; diff --git a/components/x-teaser/stories/promoted.js b/components/x-teaser/stories/promoted.js deleted file mode 100644 index 3ec41dcd5..000000000 --- a/components/x-teaser/stories/promoted.js +++ /dev/null @@ -1,49 +0,0 @@ -const { presets } = require('../'); - -exports.title = 'Paid Post'; - -// This data will provide defaults for the Knobs defined below and used -// to render examples in the documentation site. -exports.data = Object.assign({ - type: 'paid-post', - id: '', - url: '#', - title: 'Why eSports companies are on a winning streak', - standfirst: 'ESports is big business and about to get bigger: global revenues could hit $1.5bn by 2020', - promotedPrefixText: 'Paid post', - promotedSuffixText: 'by UBS', - image: { - url: 'https://tpc.googlesyndication.com/pagead/imgad?id=CICAgKCrm_3yahABGAEyCMx3RoLss603', - width: 700, - height: 394 - } -}, presets.SmallHeavy); - -// A list of properties to pass to the component when rendered in Storybook. If a Knob -// exists for the property then it will be editable with the default as defined above. -exports.knobs = [ - 'id', - 'url', - 'type', - // Meta - 'showMeta', - 'promotedPrefixText', - 'promotedSuffixText', - // Title - 'showTitle', - 'title', - // Standfirst - 'showStandfirst', - 'standfirst', - // Image - 'showImage', - 'image', - 'imageSize', - // Variants - 'layout', - 'modifiers' -]; - -// This reference is only required for hot module loading in development -// <https://webpack.js.org/concepts/hot-module-replacement/> -exports.m = module; diff --git a/components/x-teaser/stories/top-story.js b/components/x-teaser/stories/top-story.js deleted file mode 100644 index ffc299040..000000000 --- a/components/x-teaser/stories/top-story.js +++ /dev/null @@ -1,90 +0,0 @@ -const { presets } = require('../'); - -exports.title = 'Top Story'; - -// This data will provide defaults for the Knobs defined below and used -// to render examples in the documentation site. -exports.data = Object.assign({ - type: 'article', - id: '', - url: '#', - title: 'Inside charity fundraiser where hostesses are put on show', - altTitle: 'Men Only, the charity fundraiser with hostesses on show', - standfirst: 'FT investigation finds groping and sexual harassment at secretive black-tie dinner', - altStandfirst: 'Groping and sexual harassment at black-tie dinner charity event', - publishedDate: '2018-01-23T15:07:00.000Z', - firstPublishedDate: '2018-01-23T13:53:00.000Z', - metaPrefixText: '', - metaSuffixText: '', - metaLink: { - url: '#', - prefLabel: 'Sexual misconduct allegations' - }, - metaAltLink: { - url: '#', - prefLabel: 'FT Investigations' - }, - image: { - url: 'http://prod-upp-image-read.ft.com/a25832ea-0053-11e8-9650-9c0ad2d7c5b5', - width: 2048, - height: 1152 - }, - relatedLinks: [ - { - id: '', - url: '#', - type: 'article', - title: 'Removing the fig leaf of charity' - }, - { - id: '', - url: '#', - type: 'article', - title: 'A dinner that demeaned both women and men' - }, - { - id: '', - url: '#', - type: 'video', - title: 'PM speaks out after Presidents Club dinner' - } - ] -}, presets.TopStoryLandscape); - -// A list of properties to pass to the component when rendered in Storybook. If a Knob -// exists for the property then it will be editable with the default as defined above. -exports.knobs = [ - 'id', - 'url', - 'type', - // Meta - 'showMeta', - 'metaPrefixText', - 'metaSuffixText', - 'metaLink', - // Title - 'showTitle', - 'title', - // Standfirst - 'showStandfirst', - 'standfirst', - // Status - 'showStatus', - 'publishedDate', - 'firstPublishedDate', - 'useRelativeTime', - // Image - 'showImage', - 'image', - 'imageSize', - // Related - 'showRelatedLinks', - 'relatedLinks', - // Variants - 'layout', - 'modifiers' -]; - -// This reference is only required for hot module loading in development -// <https://webpack.js.org/concepts/hot-module-replacement/> -exports.m = module; diff --git a/components/x-teaser/stories/video.js b/components/x-teaser/stories/video.js deleted file mode 100644 index 28059f0ba..000000000 --- a/components/x-teaser/stories/video.js +++ /dev/null @@ -1,74 +0,0 @@ -const { presets } = require('../'); - -exports.title = 'Video'; - -// This data will provide defaults for the Knobs defined below and used -// to render examples in the documentation site. -exports.data = Object.assign({ - type: 'video', - // The ID is required for the in-situ video demo to work - // NOTE: o-video is not be called on component mount so won't render anyway. - id: '0e89d872-5711-457b-80b1-4ca0d8afea46', - url: '#', - title: 'FT View: Donald Trump, man of steel', - standfirst: 'The FT\'s Rob Armstrong looks at why Donald Trump is pushing trade tariffs', - publishedDate: '2018-03-26T08:12:28.137Z', - firstPublishedDate: '2018-03-26T08:12:28.137Z', - metaPrefixText: '', - metaSuffixText: '02:51min', - metaLink: { - url: '#', - prefLabel: 'Global Trade' - }, - metaAltLink: { - url: '#', - prefLabel: 'US' - }, - image: { - url: 'http://com.ft.imagepublish.upp-prod-eu.s3.amazonaws.com/a27ce49b-85b8-445b-b883-db6e2f533194', - width: 1920, - height: 1080 - }, - video: { - url: 'https://next-media-api.ft.com/renditions/15218247321960/640x360.mp4', - width: 640, - height: 360, - mediaType: 'video/mp4', - codec: 'h264' - } -}, presets.HeroVideo); - -// A list of properties to pass to the component when rendered in Storybook. If a Knob -// exists for the property then it will be editable with the default as defined above. -exports.knobs = [ - 'id', - 'url', - 'type', - // Meta - 'showMeta', - 'metaPrefixText', - 'metaSuffixText', - 'metaLink', - // Title - 'showTitle', - 'title', - // Status - 'showStatus', - 'publishedDate', - 'firstPublishedDate', - 'useRelativeTime', - // Image - 'showImage', - 'image', - 'imageSize', - // Video - 'showVideo', - 'video', - // Variants - 'layout', - 'modifiers', -]; - -// This reference is only required for hot module loading in development -// <https://webpack.js.org/concepts/hot-module-replacement/> -exports.m = module; diff --git a/components/x-teaser/storybook/argTypes.js b/components/x-teaser/storybook/argTypes.js new file mode 100644 index 000000000..228d16bbf --- /dev/null +++ b/components/x-teaser/storybook/argTypes.js @@ -0,0 +1,51 @@ +exports.argTypes = { + status: { + name: 'Live Blog Status', + control: { + type: 'select', + options: { + None: '', + 'Coming soon': 'comingsoon', + 'In progress': 'inprogress', + Closed: 'closed' + } + } + }, + imageSize: { + name: 'Image Size', + control: { type: 'select', options: ['XS', 'Small', 'Medium', 'Large', 'XL', 'XXL'] } + }, + headshotTint: { name: 'Headshot tint', control: { type: 'select', options: { Default: '' } } }, + accessLevel: { + name: 'Access level', + control: { type: 'select', options: ['free', 'registered', 'subscribed', 'premium'] } + }, + layout: { name: 'Layout', control: { type: 'select', options: ['small', 'large', 'hero', 'top-story'] } }, + theme: { + name: 'Theme', + control: { type: 'select', options: { None: '', Extra: 'extra-article', 'Special Report': 'highlight' } } + }, + parentTheme: { + name: 'Parent Theme', + control: { type: 'select', options: { None: '', Extra: 'extra-article', 'Special Report': 'highlight' } } + }, + modifiers: { + name: 'Modifiers', + control: { + type: 'select', + options: { + None: '', + 'Small stacked': 'stacked', + 'Small image on right': 'image-on-right', + 'Large portrait': 'large-portrait', + 'Large landscape': 'large-landscape', + 'Hero centre': 'centre', + 'Hero image': 'hero-image', + 'Top story landscape': 'landscape', + 'Top story big': 'big-story' + } + } + }, + publishedDate: { name: 'Published Date', control: { type: 'date' } }, + firstPublishedDate: { name: 'First Published Date', control: { type: 'date' } } +} diff --git a/components/x-teaser/storybook/article.js b/components/x-teaser/storybook/article.js new file mode 100644 index 000000000..1b3d18b97 --- /dev/null +++ b/components/x-teaser/storybook/article.js @@ -0,0 +1,7 @@ +const { presets } = require('../') + +exports.args = Object.assign(require('../__fixtures__/article.json'), presets.SmallHeavy) + +// This reference is only required for hot module loading in development +// <https://webpack.js.org/concepts/hot-module-replacement/> +exports.m = module diff --git a/components/x-teaser/storybook/content-package.js b/components/x-teaser/storybook/content-package.js new file mode 100644 index 000000000..cbbdd5e57 --- /dev/null +++ b/components/x-teaser/storybook/content-package.js @@ -0,0 +1,7 @@ +const { presets } = require('../') + +exports.args = Object.assign(require('../__fixtures__/content-package.json'), presets.Hero) + +// This reference is only required for hot module loading in development +// <https://webpack.js.org/concepts/hot-module-replacement/> +exports.m = module diff --git a/components/x-teaser/storybook/index.jsx b/components/x-teaser/storybook/index.jsx new file mode 100644 index 000000000..23cc13a35 --- /dev/null +++ b/components/x-teaser/storybook/index.jsx @@ -0,0 +1,114 @@ +import { Teaser } from '../src/Teaser' +import React from 'react' +import BuildService from '../../../.storybook/build-service' + +const dependencies = { + 'o-date': '^4.2.0', + 'o-labels': '^5.2.0', + 'o-normalise': '^2.0.0', + 'o-teaser': '^5.2.3', + 'o-typography': '^6.0.0', + 'o-video': '^6.0.0' +} + +const { argTypes } = require('./argTypes') + +export default { + title: 'x-teaser' +} + +export const Article = (args) => { + return ( + <div className="story-container"> + {dependencies && <BuildService dependencies={dependencies} />} + <Teaser {...args} /> + </div> + ) +} + +Article.args = require('./article').args +Article.argTypes = argTypes + +export const Podcast = (args) => { + return ( + <div className="story-container"> + {dependencies && <BuildService dependencies={dependencies} />} + <Teaser {...args} /> + </div> + ) +} +Podcast.args = require('./podcast').args +Podcast.argTypes = argTypes + +export const Opinion = (args) => { + return ( + <div className="story-container"> + {dependencies && <BuildService dependencies={dependencies} />} + <Teaser {...args} /> + </div> + ) +} +Opinion.args = require('./opinion').args +Opinion.argTypes = argTypes + +export const ContentPackage = (args) => { + return ( + <div className="story-container"> + {dependencies && <BuildService dependencies={dependencies} />} + <Teaser {...args} /> + </div> + ) +} + +ContentPackage.storyName = 'ContentPackage' +ContentPackage.args = require('./content-package').args +ContentPackage.argTypes = argTypes + +export const PackageItem = (args) => { + return ( + <div className="story-container"> + {dependencies && <BuildService dependencies={dependencies} />} + <Teaser {...args} /> + </div> + ) +} + +PackageItem.storyName = 'PackageItem' +PackageItem.args = require('./package-item').args +PackageItem.argTypes = argTypes + +export const Promoted = (args) => { + return ( + <div className="story-container"> + {dependencies && <BuildService dependencies={dependencies} />} + <Teaser {...args} /> + </div> + ) +} + +Promoted.args = require('./promoted').args +Promoted.argTypes = argTypes + +export const TopStory = (args) => { + return ( + <div className="story-container"> + {dependencies && <BuildService dependencies={dependencies} />} + <Teaser {...args} /> + </div> + ) +} + +TopStory.storyName = 'TopStory' +TopStory.args = require('./top-story').args +TopStory.argTypes = argTypes + +export const Video = (args) => { + return ( + <div className="story-container"> + {dependencies && <BuildService dependencies={dependencies} />} + <Teaser {...args} /> + </div> + ) +} +Video.args = require('./video').args +Video.argTypes = argTypes diff --git a/components/x-teaser/storybook/opinion.js b/components/x-teaser/storybook/opinion.js new file mode 100644 index 000000000..1448d1a5b --- /dev/null +++ b/components/x-teaser/storybook/opinion.js @@ -0,0 +1,9 @@ +const { presets } = require('../') + +exports.args = Object.assign(require('../__fixtures__/opinion.json'), presets.SmallHeavy, { + showHeadshot: true +}) + +// This reference is only required for hot module loading in development +// <https://webpack.js.org/concepts/hot-module-replacement/> +exports.m = module diff --git a/components/x-teaser/storybook/package-item.js b/components/x-teaser/storybook/package-item.js new file mode 100644 index 000000000..dab66e3a9 --- /dev/null +++ b/components/x-teaser/storybook/package-item.js @@ -0,0 +1,7 @@ +const { presets } = require('../') + +exports.args = Object.assign(require('../__fixtures__/package-item.json'), presets.Hero) + +// This reference is only required for hot module loading in development +// <https://webpack.js.org/concepts/hot-module-replacement/> +exports.m = module diff --git a/components/x-teaser/storybook/podcast.js b/components/x-teaser/storybook/podcast.js new file mode 100644 index 000000000..ff5ba6fa4 --- /dev/null +++ b/components/x-teaser/storybook/podcast.js @@ -0,0 +1,7 @@ +const { presets } = require('../') + +exports.args = Object.assign(require('../__fixtures__/podcast.json'), presets.SmallHeavy) + +// This reference is only required for hot module loading in development +// <https://webpack.js.org/concepts/hot-module-replacement/> +exports.m = module diff --git a/components/x-teaser/storybook/promoted.js b/components/x-teaser/storybook/promoted.js new file mode 100644 index 000000000..54333da97 --- /dev/null +++ b/components/x-teaser/storybook/promoted.js @@ -0,0 +1,7 @@ +const { presets } = require('../') + +exports.args = Object.assign(require('../__fixtures__/promoted.json'), presets.SmallHeavy) + +// This reference is only required for hot module loading in development +// <https://webpack.js.org/concepts/hot-module-replacement/> +exports.m = module diff --git a/components/x-teaser/storybook/top-story.js b/components/x-teaser/storybook/top-story.js new file mode 100644 index 000000000..73a882196 --- /dev/null +++ b/components/x-teaser/storybook/top-story.js @@ -0,0 +1,7 @@ +const { presets } = require('../') + +exports.args = Object.assign(require('../__fixtures__/top-story.json'), presets.TopStoryLandscape) + +// This reference is only required for hot module loading in development +// <https://webpack.js.org/concepts/hot-module-replacement/> +exports.m = module diff --git a/components/x-teaser/storybook/video.js b/components/x-teaser/storybook/video.js new file mode 100644 index 000000000..1781fe730 --- /dev/null +++ b/components/x-teaser/storybook/video.js @@ -0,0 +1,7 @@ +const { presets } = require('../') + +exports.args = Object.assign(require('../__fixtures__/video.json'), presets.HeroVideo) + +// This reference is only required for hot module loading in development +// <https://webpack.js.org/concepts/hot-module-replacement/> +exports.m = module diff --git a/contribution.md b/contribution.md index 37b51000f..06df649ef 100644 --- a/contribution.md +++ b/contribution.md @@ -7,7 +7,7 @@ So you'd like to contribute some code, report a bug, or request a feature? You'r - [Opening a Pull Request](#opening-a-pull-request) - [Code Style](#code-style) - [Testing](#testing) - - [Releasing/Versioning](#releasingversioning) + - [Releasing/Versioning](/docs/components/release-guidelines.md#user-content-releasingversioning) ## Reporting bugs @@ -60,7 +60,7 @@ Please do! All of the code in `x-dash` is peer-reviewed by members of The App an If you're thinking of opening a pull request that adds a feature, you'll save yourself some time and effort if you [discuss it in a feature request first](#requesting-features). The review is guaranteed to go more smoothly if we've chatted about it beforehand. - ### Check the workflow and release guidelines - The project follows a scheduled release workflow so we encourage the separation of stable, development, and experimental code. See the [Git workflow](#git-workflow) and the [release guidelines](release-guidelines.md) for more information. + The project follows a scheduled release workflow so we encourage the separation of stable, development, and experimental code. See the [Git workflow](#git-workflow) and the [release guidelines](docs/components/release-guidelines.md) for more information. - ### Update the documentation The user documentation should be kept up to date with any changes made. Use inline code comments as developer documentation, focusing more on _why_ your code does something than _what_ it's doing. @@ -85,9 +85,9 @@ Please do! All of the code in `x-dash` is peer-reviewed by members of The App an This project follows a workflow designed around project releases. It is less strict than [Gitflow] but we encourage the separation of stable, development, and experimental branches in order to follow a scheduled release cycle. -- The `master` branch is for the current stable release. Bugfixes are merged into this branch. -- The `development` branch is for upcoming major or minor releases. This branch tracks `master` and new features are merged into it. -- Branches for new features should track and raise pull requests against the `development` branch or `master` branch if there are not any upcoming releases planned. +- The `main` branch is for the current stable release. Bugfixes are merged into this branch. +- The `development` branch is for upcoming major or minor releases. This branch tracks `main` and new features are merged into it. +- Branches for new features should track and raise pull requests against the `development` branch or `main` branch if there are not any upcoming releases planned. [Gitflow]: https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow diff --git a/docs/components/creation.md b/docs/components/creation.md deleted file mode 100644 index e704d5322..000000000 --- a/docs/components/creation.md +++ /dev/null @@ -1,27 +0,0 @@ -# Creating components - -To create a new component, you can start by running the `blueprint` script and providing a component name. This script will initialise a skeleton component with the required files including a readme, package manifest and basic source files. - -You can run the blueprint script from the repository root like this: - -```sh -npm run blueprint -- denshiba -``` - -_Please note: You do not need to prefix the component name with `x-`._ - -When the blueprint script runs it will initialise a new component with the following file structure: - -```sh -├ src/ -│ └ Denshiba.jsx -├ stories/ -│ ├ story.js -│ └ index.js -├ .npmignore -├ package.json -├ readme.md -└ rollup.js -``` - -All JavaScript and CSS source files should be stored in the `src` directory, Storybook configuration for the component must be maintained in the `stories` directory. Every package in x-dash must have a readme and a package manifest and public packages must include a `.npmignore` file. diff --git a/docs/components/interactions.md b/docs/components/interactions.md deleted file mode 100644 index 275568e00..000000000 --- a/docs/components/interactions.md +++ /dev/null @@ -1,3 +0,0 @@ -# Component interactivity - -👷‍♀️ TODO 👷‍♂️ diff --git a/docs/components/javascript.md b/docs/components/javascript.md deleted file mode 100644 index 712efca7d..000000000 --- a/docs/components/javascript.md +++ /dev/null @@ -1,78 +0,0 @@ -# Component JavaScript - - -## Architecture - -Each component is a function which accepts an object containing all of the properties needed to render it and returns information describing what to render. These are called "functional components" or "stateless functional components" (SFCs) by many frameworks. - -Following this pattern means that components authored with x-dash can be compatible with a variety of static and dynamic runtimes including React, Preact, Inferno, VDO, and Hyperons amongst others. - -Interactive components which change state in reaction to events can still be created using the [x-interaction] component. - -[x-interaction]: /components/x-interaction - - -## Syntax - -You may use all syntax up to and including the ECMAScript 2018 specification and any features that may be polyfilled by the [Polyfill Service's default set] in your component source code (this assumes that all FT applications use the service). We do not currently support transpiling any features at proposal, draft, or candidate stages (if you are unsure what this means check out the [TC39 Process document]). - -If you are unsure about what you can or cannot use please check the [Can I Use] website or the [ECMAScript compatibility table]. - -All source code for your components must be authored as [ES Modules] using the `import` and `export` statements so that x-dash can generate and distribute optimised bundles. - -[Babel] is used to transpile component source code and [Rollup] is used to generate the bundles to be consumed by applications. - -[TC39 Process document]: https://tc39.github.io/process-document/ -[ES Modules]: https://ponyfoo.com/articles/es6-modules-in-depth -[Polyfill Service's default set]: https://polyfill.io/v2/docs/features/#default-sets -[Can I Use]: https://caniuse.com/ -[ECMAScript compatibility table]: https://kangax.github.io/compat-table/es6/ -[Babel]: https://babeljs.io/ -[Rollup]: https://rollupjs.org/ - - -## JSX - -Components are written using JSX (if you are not familiar with JSX check out [WTF is JSX] first) which provides special syntax for describing elements and composing components. JSX is an extension of JavaScript providing syntactic sugar to provide both a visual aid and to save writing lengthy function calls. It is implemented by the majority of JavaScript parsers and transpilers. - -_Please note: Files containing JSX should use the `.jsx` extension._ - -[WTF is JSX]:https://jasonformat.com/wtf-is-jsx/ - - -## Source files - -Each component and subcomponent should be authored in a separate file with the name of the component in `PascalCase` as the filename and using a `.jsx` extension, for example a component which shows the publish date of a piece of content may be named `PublishDate.jsx`. - -The `main` entry point of the component package should be the main component file itself, which should be named in the same format as all component files, instead of `index.js` or similar. It should export the main component as a named export, not `default`. - -If a component is made up of several subcomponents, it should export the subcomponents used to assemble the main component as named exports alongside the main component. - - -## Manifest - -There are three separate bundles which will be generated by x-dash for each component to suit different use cases. These map to several properties in the package manifest to provide these options to the component's consumers. - -- `main` - an ES2015 commonJS module for use by the server -- `module` - an ES2015 ES module for use by modern browsers -- `browser` - an ES5 commonJS module for use by older browsers - - -## Example - -Below shows the source code for a fictional x-content-item component. - -```jsx -// ContentItem.jsx -import PublishDate from './PublishDate'; - -const ContentItem = (props) => ( - <article> - <h2>{props.title}</h2> - {props.subtitle ? <p>{props.subtitle}</p> : null} - <PublishDate {...props} /> - </article> -); - -export { PublishDate, ContentItem }; -``` diff --git a/docs/components/overview.md b/docs/components/overview.md deleted file mode 100644 index 01f63ecdf..000000000 --- a/docs/components/overview.md +++ /dev/null @@ -1,61 +0,0 @@ -# Component overview - - -## Structure - -Components should follow the structure outlined below. JavaScript and CSS source files should be stored in the `src` directory, Storybook configuration for the component in the `stories` directory and the root of the component must include an [`.npmignore`][ignore] file, the package manifest, a readme, and the Rollup configuration. - -```sh -├ src/ -│ ├ styles.scss -│ └ Denshiba.jsx -├ stories/ -│ ├ story.js -│ └ index.js -├ .npmignore -├ package.json -├ readme.md -└ rollup.js -``` - -_Please note: Files containing JSX should use the `.jsx` extension._ - -[ignore]: https://docs.npmjs.com/misc/developers#keeping-files-out-of-your-package - - -## Compatibility - -Components written with x-dash are intended to work across varying environments so they must at least meet the FT's browser support requirements. Components should also be able to render on both the server and in the browser which means they must avoid or carefully wrap browser-specific or server-specific code. - -For specific information about compatibility see the [JavaScript] and [CSS] pages. - -[JavaScript]: /docs/components/javascript -[CSS]: /docs/components/styling - - -## Dependencies - -Components should have as few dependencies as possible and when using external dependencies you should always carefully consider the compatibility, file size, and future supportability of them. Where possible try to use dependencies which are already in common use across FT projects to avoid the need for applications to bundle multiple dependencies which provide similar functionality. - -_Please note: External dependencies will not be bundled with your source code._ - - -## Testing - -Tests are run across the whole project from the top level of the repository using the command `make test`. The test runner is [Jest] and [Enzyme] has been made available via the x-test-utils package for writing assertions against interactive components. - -Snapshot tests will be automatically created for each story that is configured for a component. These will fail should the output of the component for that story change. - -In addition it is encouraged to write unit tests for interactive or complex components. See the guide to [testing x-dash components] for more information. - -[Jest]: https://jestjs.io/ -[Enzyme]: http://airbnb.io/enzyme/ -[testing x-dash components]: /docs/components/testing - - -## Publishing - -All x-dash components and packages will be published on the [npm registry] under the `@financial-times` organisation. Components in the `master` or current `development` branches of x-dash will all be published concurrently with the same version number. Experimental components may be published with an unstable version number using a [prerelease tag]. - -[npm registry]: https://www.npmjs.com/ -[prerelease tag]: https://github.com/Financial-Times/x-dash/blob/master/release-guidelines.md diff --git a/docs/components/stories.md b/docs/components/stories.md deleted file mode 100644 index 305d7b35d..000000000 --- a/docs/components/stories.md +++ /dev/null @@ -1,102 +0,0 @@ -# Component stories - -## Setup - -TODO - -## Component configuration - -The Storybook configuration is a module which exports properties about the component to be rendered. - -Property | Description | Required ----------------|-----------------------------------------------------------|---------- -`component` | A reference to the top-level function to render | Yes -`package` | The component's package manifest | Yes -`dependencies` | An object containing the Origami dependencies to load | No -`stories` | An array of story configuration modules | Yes -`knobs` | A module generating dynamically editable knobs | No - -Here is an example component configuration: - -```js -const { Denshiba } = require('../'); - -// Reference to top-level function to render -exports.component = Denshiba; - -// The component's package manifest -exports.package = require('../package.json'); - -// Origami dependencies to load (from the Build Service) -exports.dependencies = { - 'o-normalise': '^1.6.0', - 'o-typography': '^5.5.0' -}; - -// Story configuration modules -exports.stories = [ - require('./food') -]; - -// A module generating dynamically editable knobs -exports.knobs = require('./knobs'); -``` - - -## Story configuration - -Story modules export the configuration required for each component demo. - -| Property | Description | Required | -|-------------|--------------------------------------------------------------------------------------------------------------------------------|----------| -| `title` | The title of the story | Yes | -| `data` | The data to pass as props to the component | Yes | -| `knobs` | An array of data properties to convert to interactive knobs | No | -| `fetchMock` | A function expecting an instance of [fetch-mock]. If your story makes HTTP requests, you can use this to mock their behaviour. | No | - -Here is an example story configuration: - -```js -// Title of the story -exports.title = 'Favourite food'; - -// Data to pass as props to the component -exports.data = { - question: 'What is your favourite food?', - answer: 'Sushi. Like news, I like my food fresh.' -}; - -// Data properties to convert to interactive knobs -exports.knobs = [ - 'question' - 'answer' -]; - -// A function expecting a clean instance of fetch-mock -exports.fetchMock = fetchMock => { - fetchMock.mock('/api/data', { some: 'data' }); -} -``` - -[fetch-mock]: https://www.wheresrhys.co.uk/fetch-mock - -## Knobs configuration - -Knobs wrap the data properties for a story and allow users to dynamically edit them in the UI. It is a function which receives the story data and functions to create different types of knob. See the [Storybook knobs add-on] for more information. - -Here is an example knobs configuration: - -```js -module.exports = (data, createKnob) => { - return { - question() { - return createKnob.text('Question', data.question); - }, - answer() { - return createKnob.text('Answer', data.answer); - } - }; -}; -``` - -[Storybook knobs add-on]: https://github.com/storybooks/storybook/tree/master/addons/knobs diff --git a/docs/components/styling.md b/docs/components/styling.md deleted file mode 100644 index 21f3f694d..000000000 --- a/docs/components/styling.md +++ /dev/null @@ -1,86 +0,0 @@ -# Styling components - - -## Origami components - -Components which provide markup and logic for existing Origami components do not need to provide any styles and should instead provide instructions for [installing the Origami package] in their readme. These components should expect the required class names to be made available globally by the consuming application. - -To include Origami styles in a component's Storybook demos use the `dependencies` option in your component's [Storybook configuration]. - -[installing the Origami package]: https://origami.ft.com/docs/developer-guide/modules/building-modules/#4-set-up-a-package-manifest-to-load-origami-modules -[Storybook configuration]: /docs/components/stories - - -## Component-specific styles - -For components which are not a part of Origami, x-dash provides the tools to author and bundle styles alongside the JavaScript package. Styles can be written using [Sass] and it is strongly encouraged to follow the [Origami SCSS syntax standard] which in short means: - -- _Always_ import Origami dependencies in silent mode and integrate via mixins -- Never modify selectors from another component's namespace -- Do not use ID selectors, `:not()`, and avoid `%extends` -- Use ARIA role attribute selectors where appropriate - -CSS files should be stored in `src` directory, adjacent to the component that will use them and follow the same naming convention. For example, if you have a button component then you may have two files; `Button.jsx` and `Button.css`. - -If your component's styles might be useful outside of the FT.com ecosystem, you should speak to the [Origami team] about creating an Origami component. - -[Sass]: https://sass-lang.com/ -[Origami SCSS syntax standard]: https://origami.ft.com/docs/syntax/scss/ -[Origami team]: http://origami.ft.com/ - - -### CSS Modules - -A [CSS Module] is a self-contained CSS file that can be used with ECMAScript `import`. When a component is built, its CSS Modules are bundled into a single `.css` file, and their class names are obfuscated, so styles from a component cannot interfere with any other part of a page they're included into. The `default` export of a CSS Module is an object containing its class names as keys, and their obfuscated versions as values. This allows you to reference the class names as authored from within your component but output the obfuscated names when the component is used. - - -### Example - -The following CSS defines class names which are short and generic but there is no risk of them interfering with anything else on the page because they'll be obfuscated. Class names should be written in camelCase so they may be more easily referenced in JavaScript. - -```css -/* Button.css */ -.button { - background: steelblue; - color: white; - border-radius: 0.25em; - padding: 0.5rem 1rem; -} - -.large { - font-size: 1.5rem; -} - -.danger { - background: firebrick; -} -``` - -When this CSS file is imported its selectors can be referenced using the object it exports: - -```jsx -// Button.jsx -import { h } from '@financial-times/x-engine'; -import styles from './Button.css'; - -const Button = ({ large, danger }) => { - const classNames = [ - styles.button, - large ? styles.large : '', - danger ? styles.danger : '' - ]; - - return <button className={classNames.join(' ')}>Click me!</button>; -}; -``` - -_Please note: referencing classes and toggling them based on properties can become unwieldy so the [classnames] or [classcat] packages can help avoid some of the formatting hassle._ - -[CSS Module]: https://github.com/css-modules/css-modules -[classnames]: https://npmjs.org/package/classnames -[classcat]: https://github.com/jorgebucaran/classcat - - -### Manifest - -To instruct the x-dash tools where to output styles and provide a hint to consumers where to import styles from, add a `style` property to the component's package manifest. This is the path to the bundled CSS output. diff --git a/docs/components/testing.md b/docs/components/testing.md deleted file mode 100644 index 4eebeb5b1..000000000 --- a/docs/components/testing.md +++ /dev/null @@ -1,45 +0,0 @@ -# Testing components - -Defining [stories] from your components, as well as including them in Storybook, will generate Jest snapshot tests for each story. Your stories should include as many of your component's use cases as possible, so that they're covered by the tests. - -Snapshot tests are useful for ensuring that you don't accidentally change the markup that your component outputs, which may inadvertently break the apps consuming your component. They don't cover any kind of interactive behaviour or changing state. For components using `x-interaction`, you should consider adding test cases for the interactivity. - -[stories]: /docs/components/stories - -## Interactive components - -x-dash comes with Jest and Enzyme pre-configured, to help save boilerplate and setup when testing your component. - -For an example of Jest and Enzyme in use, and standard patterns for writing tests for a component, see the [x-increment tests](https://github.com/Financial-Times/x-dash/tree/master/components/x-increment/__tests__). - -### Setup - -Install `x-test-utils` as a `devDependency` of your component: - -``` -npm install --save-dev ../../packages/x-test-utils -``` - -This module exports a version of Enzyme pre-configured to work with Jest and the version of React in use. Create a folder called `__tests__` in your component's folder. Test files should be placed in this folder, using the filename extension `.test.jsx`. It's up to you how to structure your tests; for simple tests we recommend a single file named after your component, e.g. `x-increment.test.jsx`. - -The standard pattern for testing an interactive component is rendering it using Enzyme's `mount` DOM renderer, using its selector functions to select an element that would trigger an action, and directly calling the event that calls the action. `x-interaction` actions return a promise that resolves once the state changes have taken place, so you should `await` this promise, then use Jest's `expect` to assert that the DOM changes you expect have take place. - -For example, the following is an annotated excerpt from `x-increment`'s tests: - -```jsx -const { h } = require('@financial-times/x-engine'); -const { mount } = require('@financial-times/x-test-utils/enzyme'); - -const { Increment } = require('../'); - -describe('x-increment', () => { - it('should increment when clicked', async () => { - // render the component with the DOM renderer - const subject = mount(<Increment count={1} />); - // find the button that triggers the increment, and call its `onClick` event. `await` state updates - await subject.find('button').prop('onClick')(); - // has the number in the text incremented? - expect(subject.find('span').text()).toEqual('2'); - }); -}); -``` diff --git a/docs/get-started/installation.md b/docs/get-started/installation.md deleted file mode 100644 index ebf1a332e..000000000 --- a/docs/get-started/installation.md +++ /dev/null @@ -1,42 +0,0 @@ -# Installing x-dash - - -## Requirements - -To get started with x-dash, you'll need to make sure you have the following software tools installed. - -1. [Git](https://git-scm.com/) -2. [Make](https://www.gnu.org/software/make/) -3. [Node.js](https://nodejs.org/en/) (version 8 or higher is required) -4. [npm](http://npmjs.com/) - -Please note that x-dash has only been tested in Mac and Linux environments. If you are on a Mac you may find it easiest to install the [Command Line Tools](https://developer.apple.com/download/more/) package which includes Git and Make. - -### Recommended - -To aid the development of interactive components with Storybook it is recommended to install the React Developer Tools for your browser. These addons aid debugging by displaying the rendered component tree and provide access to view and edit their properties: - -- [React Developer Tools for Chrome](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) -- [React Developer Tools for Firefox](https://addons.mozilla.org/en-GB/firefox/addon/react-devtools/) - - -## Project setup - -1. Clone the x-dash Git repository and change to the new directory that has been created: - - ```bash - git clone git@github.com:financial-times/x-dash - cd x-dash - ``` - -2. Install all of the project dependendencies (this may take a few minutes if you are running this for the first time): - - ```bash - make install - ``` - -3. Start Storybook to view the current set of x-dash components: - - ```bash - npm run start-storybook - ``` diff --git a/docs/get-started/what-is-x-dash.md b/docs/get-started/what-is-x-dash.md deleted file mode 100644 index 89d1538c1..000000000 --- a/docs/get-started/what-is-x-dash.md +++ /dev/null @@ -1,44 +0,0 @@ -# What is x-dash? - -This project is an experiment in building new shared UI components for FT.com and the FT App. An alternative introduction for FT developers is also available in [Google Slides] (viewable by employees only.) - -[Google Slides]: https://docs.google.com/presentation/d/1Z8mGsv4JU2TafNPIHw2RcejoNp7AN_v4LfCCGC7qrgw/edit?usp=sharing - - -## What are goals of this project? - -The primary goal is to create a set of UI components that are shareable between The App and FT.com. We aim to provide the patterns and tools required for developers to create high quality code which is suitable for use across both products. - -We want to do this to provide a consistent experience for our users who move between the two and act as an aid the technical evolution of both products. - - -## Why is this a challenge? - -Making components for both The App and FT.com is a technical challenge because the two products have different tech stacks, different architectures, and a different history. This means we must find a new middleground without introducing compromises or new technologies which might prevent teams from adopting it. - -This project is also a tricky cultural challenge because the two products have different life-cycles — the website ships many times a day, whereas an app may remain on a user's phone for several months — and not all existing components may have been originally conceived with both products in mind meaning we are unable to "lift and shift" them. - -And finally, both are enormous projects. Pivoting is not easy and takes a lot of effort. As a [free market] we can only convince teams to adopt x-dash by providing a truly better alternative to their current tools. - -[free market]: http://matt.chadburn.co.uk/notes/teams-as-services.html - - -## What is different about x-dash? - -With x-dash we have introduced a [monorepo] project structure, new [contribution guidelines], and a new [release process]. These have all been carefully considered to help ensure that x-dash components can be responsibly and reliably shipped to both products. - - -[monorepo]: https://en.wikipedia.org/wiki/Monorepo -[contribution guidelines]: https://github.com/Financial-Times/x-dash/blob/master/contribution.md -[release process]: https://github.com/Financial-Times/x-dash/blob/master/release-guidelines.md - - -## Does x-dash mean we can use React? - -This project does not require the use of any particular technology for your application. The aim of x-dash is to ensure all components are compatible with a variety of runtimes for both the server and the client-side, including [React]. - -By ensuring compatibility with React it is possible to take advantage of the fantastic tooling made available by that community, including [Storybook] and [Enzyme]. In addition, adopting these tools means that we are able to position our technology programmes more closely to the prevailing sentiment of the development community and therefore potential employees. - -[React]: https://reactjs.org/ -[Storybook]: https://storybook.js.org/ -[Enzyme]: https://github.com/airbnb/enzyme diff --git a/docs/get-started/working-with-x-dash.md b/docs/get-started/working-with-x-dash.md deleted file mode 100644 index 867be5f91..000000000 --- a/docs/get-started/working-with-x-dash.md +++ /dev/null @@ -1,73 +0,0 @@ -# Working with x-dash - -The project repository is a monorepo which means all of the tools, packages and components are kept in one place and can be worked upon concurrently. - - -## Repository structure - -The repository groups related code together in directories. UI components are stored in the `components` directory, documentation files in the `docs` directory, additional public packages in the `packages` directory and tools to aid working with x-dash are in the `tools` directory. - -``` -├ components/ -│ └ x-component/ -│ ├ readme.md -│ └ package.json -├ docs/ -│ └ page.md -├ packages/ -│ └ x-package/ -│ ├ readme.md -│ └ package.json -├ tools/ -│ ├ x-docs/ -│ │ └ package.json -│ └ x-storybook/ -│ └ package.json -├ readme.md -└ package.json -``` - - -## Writing documentation - -The documentation you're reading right now is generated from a Markdown file stored in the `docs` directory. Other pages to show the packages and components are created dynamically using information inferred from the repository. - -This website is stored in the `tools/x-docs` directory and is built using the static site generator [Gatsby](https://gatsbyjs.org). You don't need to learn Gatsby to get started writing documentation! - -Once you have [installed] the x-dash project you can run this command from the repository root to build and run the documentation website: - -```sh -npm run start-docs -``` - -Using this command the documentation site will be generated and start a server running on [local.ft.com:8000]. Whilst the server is running all of the files used as data sources will be watched and the website will automatically update when they change. - -[installed]: /docs/get-started/installation -[local.ft.com:8000]: http://local.ft.com:8000/ - - -## Using Storybook - -[Storybook] is a development environment and showcase for UI components. It makes working on and sharing UI components easier by providing a richly configurable environment. - -After installing x-dash you can start Storybook by running the following command from the repository root: - -```sh -npm run start-storybook -``` - -This command will start a server running on [local.ft.com:9001] and generate an application presenting all of the components configured to use it. Changes to these components can be updated in real-time speeding up the development process. - -Data properties passed to these component may also be configured in-situ and these changes will be reflected in the URL making it possible to share specific configurations. - -[Storybook]: https://storybook.js.org/ -[local.ft.com:9001]: http://local.ft.com:9001/ - - -## Coding standards - -The best way to ensure you stick to the x-dash code style is to make your work consistent with the code around it. We also provide a [Prettier] configuration to automatically format files and run [ESLint] before any tests. See the [contribution guide] for more information. - -[Prettier]: https://prettier.io/ -[ESLint]: https://eslint.org/ -[contribution guide]: https://github.com/Financial-Times/x-dash/blob/master/contribution.md diff --git a/docs/guides/migrating-to-x-teaser.md b/docs/guides/migrating-to-x-teaser.md deleted file mode 100644 index db80e2ebf..000000000 --- a/docs/guides/migrating-to-x-teaser.md +++ /dev/null @@ -1,153 +0,0 @@ -# Migrating from n-teaser to x-teaser - -This component replaces the existing `@financial-times/n-teaser` package. The design decisions behind x-teaser are different to n-teaser and so the available options and data structures required are also different. However, great care has been taken to ensure that migrating an app to use x-teaser can be done quickly and in most cases you can end up with less code than before. - - -## Major differences - -The n-teaser package provides a set of [Handlebars] templates for use by FT.com within the Handlebars setup provided by [n-ui] and several [GraphQL] fragments to fetch a range of data from [Next API]. Presenter classes are loaded on application startup and called from inside the templates, provising the logic to select and format data. The n-teaser package contains 78kb of source code. - -The x-teaser package provides a single configurable component written in [JSX] which may be rendered in The App or FT.com by any compatible runtime. The component expects the data it receives to be preformatted and therefore contains very little logic. The x-teaser package contains 18kb of source code. - -[Handlebars]: https://handlebarsjs.com/ -[n-ui]: https://github.com/Financial-Times/n-ui -[GraphQL]: https://graphql.org/ -[Next API]: https://github.com/Financial-Times/next-api -[JSX]: https://jasonformat.com/wtf-is-jsx/ - - -## Guide - -### 1. Install dependencies - -In addition to the x-teaser package you will also need to install the [x-handlebars] package which enables x-dash compatible components to be rendered inside your existing templates. - -```diff - "dependencies" { -- "@financial-times/n-teaser": "^4.10.0", -+ "@financial-times/x-handlebars": "^1.0.0", -+ "@financial-times/x-teaser": "^1.0.0", - }, -``` - -[x-handlebars]: /packages/x-handlebars - -### 2. Install and configure a runtime - -There are a number of frameworks and libraries which can render components written with JSX. If you are already using a framework in your application then you should continue to use that where possible, otherwise it is recommend installing the [Hyperons] package which is small and very fast. - -The x-handlebars and x-teaser packages depend on a library called [x-engine]. This is a consolidation library that provides your chosen runtime to x-dash compatible components. The configuration for x-engine needs to be added to your package manifest now to instruct it how to load your runtime. - -```diff - "dependencies" { -+ "hyperons": "^0.4.0", - }, -+ "x-dash": { -+ "engine": { -+ "server": "hyperons" -+ } -+ } -``` - -[x-engine]: /packages/x-engine -[Hyperons]: https://www.npmjs.com/package/hyperons - -### 3. Load Handlebars helpers - -User facing FT.com applications use an Express server provided by n-ui. As part of the server initialisation any Handlebars helpers can be loaded and made available to your templates. - -The n-teaser package uses this functionality to load its internal helper functions and the x-handlebars helpers are loaded in the same way. - -```diff - helpers: { -- nTeaserPresenter: require('@financial-times/n-teaser').presenter -+ x: require('@financial-times/x-handlebars')() - } -``` - -### 4. Fetching the right data - -The data required to render teasers can now be fetched in a format ready for immediate use. Whether your application fetches its data from Next API (using GraphQL queries) or directly from [Elasticsearch] the number of fields required and size of the payload to be transferred has been reduced dramatically. - -[Elasticsearch]: https://github.com/Financial-Times/next-es-interface/ - -#### Changes to GraphQL queries - -With GraphQL every field and sub-field to be retrieved must be explicitly specified. For convenience the n-teaser package provides a range of GraphQL fragments to generate this list of fields for each teaser type. - -To avoid maintaining such a list the teaser data has been made available as a single JSON field named `teaser`. - -```diff -- const { fragments } = require('@financial-times/n-teaser'); - -module.exports = ` -- ${fragments.teaserExtraLight} -- ${fragments.teaserLight} -- ${fragments.teaserStandard} -- ${fragments.teaserHeavy} -- ${fragments.teaserTopStory} -+ teaser -`; -``` - -#### Changes to Elasticsearch queries - -Wherever you specify a list, or include a list, of source fields to retrieve you may now replace this with a single entry of `teaser.*`. - -```diff -fields: [ -- 'id', -- 'url', -- 'relativeUrl', -- 'type', -- -- 'title', -- 'alternativeTitles.promotionalTitle', -- 'alternativeTitles.promotionalTitleVariant', -- -- 'standfirst', -- 'alternativeStandfirsts.promotionalStandfirst', -- 'alternativeStandfirsts.promotionalStandfirstVariant', -- -- 'publishedDate', -- 'firstPublishedDate', -- -- 'mainImage.*', -- 'alternativeImages.promotionalImage.*', - -- 'displayConcept.*', -- 'brandConcept.*', -- 'genreConcept.*', -- 'authorConcepts.*', -- -+ 'teaser.*' -]; -``` - -### 5. Update template includes - -The n-teaser package provides separate templates for each teaser layout. In contrast the x-handlebars package is generic and allows you to render any installed x-dash packages or local components in your Handlebars template. - -Teasers may be configured by providing attributes. Common use cases are provided via [presets] and may be used by specifying the `preset` attribute. - -```diff -- {{>n-teaser/templates/heavy mods=(array 'small') widths="[160]" }} -+ {{{x package="x-teaser" component="Teaser" preset="SmallHeavy"}}} -``` - -[presets]: /components/x-teaser#presets - -### 6. Image lazy loading (optional) - -If you have implemented image lazy loading on your pages using [n-image] or [o-lazy-load] you can continue to use this functionality with x-teaser. Setting the `imageLazyload` property to `true` will instruct the component to render the image with a `data-src` property instead of a `src` property. If you need to set a specific class name to identify these images you can set the `imageLazyload` property to a string, which will be appended to list of image class names. - -```handlebars -<!-- if using o-lazy-load --> -{{{x package="x-teaser" component="Teaser" preset="SmallHeavy" imageLazyLoad="o-lazy-load"}}} - -<!-- if using n-image --> -{{{x package="x-teaser" component="Teaser" preset="SmallHeavy" imageLazyLoad="n-image--lazy-loading"}}} -``` - -[n-image]: https://github.com/Financial-Times/n-image -[o-lazy-load]: https://github.com/Financial-Times/o-lazy-load/ diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 0c4999684..000000000 --- a/docs/index.md +++ /dev/null @@ -1 +0,0 @@ -Learn about x-dash, whether you're getting started or want in-depth information, from the guides in the menu. diff --git a/docs/integration/local-development.md b/docs/integration/local-development.md deleted file mode 100644 index 1771d7263..000000000 --- a/docs/integration/local-development.md +++ /dev/null @@ -1,48 +0,0 @@ -# Local development with x-dash - -When developing an x-dash component it is recommended to use Storybook but it can still be useful to see it in the context of a real app. - - -## Prerequisites - -This guide assumes that: - - - You have a ready to run Node.js application - - You have x-dash set up for development according to the [installation guide] - -The examples are based upon the following directory structure: - -``` -├ projects/ -│ ├ your-application/ -│ └ x-dash/ -``` - -[installation guide]: /docs/get-started/installation - - -## Installing local components - -As an example, we'll install the [x-teaser] component into an app. In the application folder run the `npm install` command using the relative path from the current directory to the required component: - -```sh -npm install --save ../x-dash/components/x-teaser -``` - -If your application previously specified the component as a dependency then you should see that the version specifier in the package manifest has now changed to the path that you specified prefixed with the `file:` protocol. The component should now have been installed into the `node_modules` directory as a symbolic link to within `x-dash`. - -Using the `file:` version specifier for subsequent runs of `npm install` will continue to work so long as the files are kept in the correct locations. Otherwise, npm will return this error: - -``` -npm ERR! code ENOLOCAL -npm ERR! Could not install from "../x-dash/components/x-teaser" as it does not contain a package.json file. -``` - -If you encounter this error, ensure that x-dash is set up correctly in the parent folder of your app, or reinstall `x-teaser` from the npm registry using `npm install --save @financial-times/x-teaser`. - -[x-teaser]: /components/x-teaser - - -## Avoid linking - -Usually, using a locally-installed version of a package is a use case for `npm link`. In practice, we have found it to be brittle, causing problems with peer dependencies and nested transitive dependencies. Using relative `npm install` treats the installed package as any other, ensuring that your `node_modules` directory has the expected structure. diff --git a/docs/integration/using-components.md b/docs/integration/using-components.md deleted file mode 100644 index a5a3d0aa2..000000000 --- a/docs/integration/using-components.md +++ /dev/null @@ -1,99 +0,0 @@ -# Application setup - -Components authored with x-dash are authored using [JSX] and are designed to be compatible with a variety of runtimes to make integrating them into your application as flexible as possible. They can be used with any React-like library, such as Preact or Inferno, or rendered statically. They can also be integrated with Handlebars using the [x-handlebars package]. - -[JSX]: https://jasonformat.com/wtf-is-jsx/ -[x-handlebars package]: /packages/x-handlebars - - -## Choosing a runtime - -There are a number of JavaScript tools and frameworks which support JSX. The most well known of these is [React] and its derivitives including [Preact] and [Inferno]. These tools all implement a similar feature set for rendering interactive interfaces. There are also tools able to render JSX to static HTML such as [Hyperons] and [VDO] which may be a good choice when integrating x-dash components into an existing server-side rendered app. - -When building interactive interfaces for the client-side it is recommended to use Preact as this is already in use around the FT. For rendering static HTML on the server it is recommended to use Hyperons because it has excellent performance. - -[React]: https://reactjs.org/ -[Preact]: https://preactjs.com/ -[Inferno]: https://infernojs.org/ -[Hyperons]: https://github.com/i-like-robots/hyperons -[VDO]: https://www.npmjs.com/package/vdo - - -## Installing components - -Components should be installed and added to your application's dependencies using [npm]. For example, to install the [x-teaser component] you would run: - -``` -npm install --save @financial-times/x-teaser -``` - -_Please note: Remember that you will also need to install your chosen runtime!_ - -[npm]: https://npmjs.org -[x-teaser component]: /components/x-teaser - - -## Configuration - -Because x-dash components cannot interface with the runtime directly a consolidation library named [x-engine] is used to connect them. You must provide x-engine with some configuration added to your application's `package.json` to instruct it how to load the runtime. For example to use the Hyperons module to render x-dash components on the server you may add this: - -```json -{ - "x-dash": { - "engine": { - "server": "hyperons" - } - } -} -``` - -Please refer to the the [x-engine documentation] for further configuration information. - -[x-engine]: /packages/x-engine -[x-engine documentation]: /packages/x-engine - - -## Rendering - -JSX source code is transpiled to a set of function calls for each element. These function calls will usually output a framework specific representation of each element which builds up a data structure describing what to render. Whichever runtime you choose it will provide a method to transform this data structure into HTML. You must call this render method yourself in your application. - -For example, if you are using React you may render an x-dash component like this in your client-side code: - -```jsx -const React = require('react'); -const ReactDOM = require('react-dom'); -const { Teaser } = require('@financial-times/x-teaser'); - -ReactDOM.render( - <Teaser title='Hello world' />, - document.querySelector('#target') -); -``` - -You can also access the x-engine package directly in your own code which may be convenient if you think you may change the runtime in future. For example: - -```jsx -const { render } = require('@financial-times/x-engine'); -const { Teaser } = require('@financial-times/x-teaser'); - -// JSX syntax is sugar for function calls so it is not required! -const html = render(Teaser({ title: 'Hello World' }); -``` - - -### Use component presets - -Components which have many configigurable properties may expose a collection `presets` which are groups of ready configured properties for common use-cases. These can be mixed into your existing data properties to make your code less repetitive. - -For example the x-teaser component can be configured in many different ways and [includes several presets]: - -```js -const { Teaser, presets } = require('@financial-times/x-teaser'); - -Teaser({ - ...presets.Hero, - ...teaserData, -}); -``` - -[includes several presets]: /components/x-teaser#presets diff --git a/e2e/app.js b/e2e/app.js new file mode 100644 index 000000000..28e873a04 --- /dev/null +++ b/e2e/app.js @@ -0,0 +1,5 @@ +// set up app to host main.js file included in server side rendered html +const express = require('express') +const server = express() +server.use(express.static(__dirname)) +exports.app = server diff --git a/e2e/common.js b/e2e/common.js new file mode 100644 index 000000000..d2af05a15 --- /dev/null +++ b/e2e/common.js @@ -0,0 +1,21 @@ +const { withActions, registerComponent } = require('@financial-times/x-interaction') +const { h } = require('@financial-times/x-engine') + +export const greetingActions = withActions({ + actionOne() { + return { greeting: 'world' } + } +}) + +export const GreetingComponent = greetingActions(({ greeting, actions }) => { + return ( + <div className="greeting-text"> + hello {greeting} + <button className="greeting-button" onClick={actions.actionOne}> + click to add to hello + </button> + </div> + ) +}) + +registerComponent(GreetingComponent, 'GreetingComponent') diff --git a/e2e/e2e.test.js b/e2e/e2e.test.js new file mode 100644 index 000000000..c95ac15f3 --- /dev/null +++ b/e2e/e2e.test.js @@ -0,0 +1,62 @@ +/** + * @jest-environment node + */ + +const { h } = require('@financial-times/x-engine') // required for <GreetingComponent> +const { Serialiser, HydrationData } = require('@financial-times/x-interaction') +const puppeteer = require('puppeteer') +const ReactDOMServer = require('react-dom/server') +const express = require('express') +import React from 'react' +import { GreetingComponent } from './common' + +describe('x-interaction-e2e', () => { + let browser + let page + let app + let server + + beforeAll(async () => { + app = express() + server = app.listen(3004) + app.use(express.static(__dirname)) + browser = await puppeteer.launch() + page = await browser.newPage() + }) + + it('attaches the event listener to SSR components on hydration', async () => { + const ClientComponent = () => { + // main.js is the transpiled version of index.js, which contains the registered GreetingComponent, and invokes hydrate + return <script type="module" src="./main.js" charset="utf-8"></script> + } + + const serialiser = new Serialiser() + const htmlString = ReactDOMServer.renderToString( + <> + <GreetingComponent serialiser={serialiser} /> + <HydrationData serialiser={serialiser} /> + <ClientComponent /> + </> + ) + + app.get('/', (req, res) => { + res.send(htmlString) + }) + + // go to page and click button + await page.goto('http://localhost:3004') + await page.waitForSelector('.greeting-button') + await page.click('.greeting-button') + const text = await page.$eval('.greeting-text', (e) => e.textContent) + expect(text).toContain('hello world') + }) + + afterAll(async () => { + try { + ;(await browser) && browser.close() + await server.close() + } catch (e) { + console.log(e) + } + }) +}) diff --git a/e2e/index.js b/e2e/index.js new file mode 100644 index 000000000..a4c4f180f --- /dev/null +++ b/e2e/index.js @@ -0,0 +1,4 @@ +import { hydrate } from '@financial-times/x-interaction' +import './common' + +document.addEventListener('DOMContentLoaded', hydrate) diff --git a/e2e/jest.e2e.config.js b/e2e/jest.e2e.config.js new file mode 100644 index 000000000..12b5a78a7 --- /dev/null +++ b/e2e/jest.e2e.config.js @@ -0,0 +1,11 @@ +module.exports = { + testMatch: ['<rootDir>/e2e.test.js'], + testPathIgnorePatterns: ['/node_modules/', '/bower_components/'], + transform: { + '^.+\\.jsx?$': '../packages/x-babel-config/jest' + }, + moduleNameMapper: { + '^[./a-zA-Z0-9$_-]+\\.scss$': '<rootDir>/__mocks__/styleMock.js' + }, + testEnvironment: 'node' +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 000000000..d11824a31 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,43 @@ +{ + "name": "x-dash-e2e", + "version": "0.0.0", + "private": "true", + "description": "This module enables you to write x-dash components that respond to events and change their own data.", + "keywords": [ + "x-dash" + ], + "author": "", + "license": "ISC", + "x-dash": { + "engine": { + "server": { + "runtime": "react", + "factory": "createElement", + "component": "Component", + "fragment": "Fragment", + "renderModule": "react-dom/server", + "render": "renderToStaticMarkup" + }, + "browser": "react" + } + }, + "repository": { + "type": "git", + "url": "https://github.com/Financial-Times/x-dash.git" + }, + "engines": { + "node": "12.x" + }, + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "puppeteer": "^10.4.0", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "webpack": "^5.54.0", + "webpack-cli": "^4.8.0", + "@financial-times/x-engine": "file:../packages/x-engine", + "@financial-times/x-interaction": "file:../components/x-interaction" + } +} diff --git a/e2e/webpack.config.js b/e2e/webpack.config.js new file mode 100644 index 000000000..3915d2f09 --- /dev/null +++ b/e2e/webpack.config.js @@ -0,0 +1,34 @@ +const path = require('path') +const xEngine = require('../packages/x-engine/src/webpack') +const webpack = require('webpack') + +module.exports = { + entry: './index.js', + output: { + filename: 'main.js', + path: path.resolve(__dirname) + }, + plugins: [ + new webpack.ProvidePlugin({ + React: 'react' + }), + xEngine() + ], + module: { + rules: [ + { + test: /\.(js|jsx)$/, + use: { + loader: 'babel-loader', + options: { + presets: ['@babel/preset-env', '@babel/preset-react'] + } + }, + exclude: /node_modules/ + } + ] + }, + resolve: { + extensions: ['.js', '.jsx', '.json', '.wasm', '.mjs', '*'] + } +} diff --git a/jest.config.js b/jest.config.js index 1008f00bc..108438432 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,6 +3,10 @@ module.exports = { testMatch: ['**/__tests__/**/*.test.js?(x)'], testPathIgnorePatterns: ['/node_modules/', '/bower_components/'], transform: { - '^.+\\.jsx?$': './packages/x-babel-config/jest', + '^.+\\.jsx?$': './packages/x-babel-config/jest' }, -}; + moduleNameMapper: { + '^[./a-zA-Z0-9$_-]+\\.scss$': '<rootDir>/__mocks__/styleMock.js' + }, + modulePathIgnorePatterns: ['<rootDir>/e2e/'] +} diff --git a/makefile b/makefile index 80adfff97..7788f5293 100644 --- a/makefile +++ b/makefile @@ -13,6 +13,9 @@ install: build: npm run build +build-%: + npm run build-only -- --filter $* + test: npm run test diff --git a/package.json b/package.json index e16ae4e2d..598afe8be 100644 --- a/package.json +++ b/package.json @@ -1,43 +1,83 @@ { + "name": "x-dash", "private": true, + "volta": { + "node": "12.22.7", + "npm": "7.24.2" + }, "scripts": { "clean": "git clean -fxdi", - "build": "athloi run build", + "build": "athloi run build --concurrency 3", + "build-only": "athloi run build", "jest": "jest -c jest.config.js", - "pretest": "npm run build", "test": "npm run lint && npm run jest", + "e2e": "cd e2e && ./node_modules/.bin/webpack && jest -c jest.e2e.config.js", "lint": "eslint . --ext=js,jsx", "blueprint": "node private/scripts/blueprint.js", - "start-storybook": "(cd tools/x-storybook && npm start)", - "start-docs": "(cd tools/x-docs && npm start)" + "start-storybook": "start-storybook -p ${STORYBOOK_PORT:-9001} -s .storybook/static -h local.ft.com", + "start-storybook:ci": "start-storybook -p ${STORYBOOK_PORT:-9001} -s .storybook/static -h local.ft.com --ci --smoke-test", + "deploy-storybook:ci": "storybook-to-ghpages --ci --source-branch=main", + "heroku-postbuild": "make install && npm run build", + "prepare": "npx snyk protect || npx snyk protect -d || true" + }, + "lint-staged": { + "**/*.{ts,tsx,js,jsx}": [ + "prettier --write", + "eslint --fix" + ] + }, + "storybook-deployer": { + "gitUsername": "next-team", + "gitEmail": "next.team@ft.com", + "commitMessage": "Deploy Storybook [skip ci]" }, - "dependencies": { - "@financial-times/athloi": "^1.0.0-beta.19", - "acorn": "^6.0.2", - "acorn-jsx": "^5.0.0", - "eslint": "^5.1.0", - "eslint-config-prettier": "^2.9.0", - "eslint-plugin-jest": "^21.17.0", - "eslint-plugin-jsx-a11y": "^6.1.1", - "eslint-plugin-react": "^7.10.0", - "espree": "^4.1.0", - "fetch-mock": "^6.5.2", - "jest": "^22.4.3", - "react": "^16.3.1", - "react-test-renderer": "^16.3.1" + "devDependencies": { + "@babel/core": "^7.4.5", + "@financial-times/athloi": "^1.0.0-beta.26", + "@storybook/addon-essentials": "^6.2.9", + "@storybook/react": "^6.2.9", + "@storybook/storybook-deployer": "^2.8.8", + "@types/jest": "26.0.0", + "@typescript-eslint/parser": "^3.0.0", + "babel-loader": "^8.0.4", + "copy-webpack-plugin": "^5.0.2", + "core-js": "^3.7.0", + "eslint": "^5.16.0", + "eslint-config-prettier": "^5.0.0", + "eslint-plugin-jest": "^22.6.4", + "eslint-plugin-jsx-a11y": "^6.2.0", + "eslint-plugin-react": "^7.13.0", + "fetch-mock": "^7.3.3", + "husky": "^4.0.0", + "jest": "^24.8.0", + "lint-staged": "^10.0.0", + "node-sass": "^4.12.0", + "prettier": "^2.0.2", + "react": "^16.8.6", + "react-dom": "^16.8.6", + "react-helmet": "^5.2.0", + "react-test-renderer": "^16.8.6", + "sass-loader": "^7.1.0", + "snyk": "^1.168.0", + "style-loader": "^0.23.1", + "typescript": "^3.9.5", + "write-file-webpack-plugin": "^4.5.0" }, "x-dash": { "engine": { - "server": { - "runtime": "react", - "factory": "createElement", - "component": "Component" - } + "browser": "react", + "server": "react" + } + }, + "husky": { + "hooks": { + "pre-commit": "lint-staged" } }, "workspaces": [ "components/*", "packages/*", - "tools/*" + "tools/*", + "e2e" ] } diff --git a/packages/x-babel-config/index.js b/packages/x-babel-config/index.js index 8082bd810..78d65fbbd 100644 --- a/packages/x-babel-config/index.js +++ b/packages/x-babel-config/index.js @@ -1,21 +1,13 @@ -module.exports = (targets = []) => ({ +module.exports = ({ targets = [], modules = false } = {}) => ({ plugins: [ // this plugin is not React specific! It includes a general JSX parser and helper 🙄 [ - require.resolve('babel-plugin-transform-react-jsx'), + require.resolve('@babel/plugin-transform-react-jsx'), { pragma: 'h', useBuiltIns: true } ], - // Although this feature is at stage 4, we'd have to use babel 7 to get the version - // of preset-env that actually supports it 😖 - [ - require.resolve('babel-plugin-transform-object-rest-spread'), - { - useBuiltIns: true - } - ], // Implements async/await using syntax transformation rather than generators which require // a huge runtime for browsers which do not natively support them. [ @@ -29,12 +21,12 @@ module.exports = (targets = []) => ({ ], presets: [ [ - require.resolve('babel-preset-env'), + require.resolve('@babel/preset-env'), { targets, - modules: false, + modules, exclude: ['transform-regenerator', 'transform-async-to-generator'] } ] ] -}); +}) diff --git a/packages/x-babel-config/jest.js b/packages/x-babel-config/jest.js index 5f61b4dcd..f5d8a2517 100644 --- a/packages/x-babel-config/jest.js +++ b/packages/x-babel-config/jest.js @@ -1,4 +1,9 @@ -const getBabelConfig = require('./'); -const babelJest = require('babel-jest'); +const getBabelConfig = require('./') +const babelJest = require('babel-jest') -module.exports = babelJest.createTransformer(getBabelConfig()); +const base = getBabelConfig({ + targets: { node: 'current' }, + modules: 'commonjs' +}) + +module.exports = babelJest.createTransformer(base) diff --git a/packages/x-babel-config/package.json b/packages/x-babel-config/package.json index 9feadcaf4..12871ce10 100644 --- a/packages/x-babel-config/package.json +++ b/packages/x-babel-config/package.json @@ -11,10 +11,9 @@ "author": "", "license": "ISC", "dependencies": { - "babel-jest": "^23.6.0", - "babel-plugin-transform-object-rest-spread": "^6.26.0", - "babel-plugin-transform-react-jsx": "^6.24.1", - "babel-preset-env": "^1.7.0", - "fast-async": "^6.3.7" + "@babel/plugin-transform-react-jsx": "^7.3.0", + "@babel/preset-env": "^7.4.3", + "babel-jest": "^24.0.0", + "fast-async": "^7.0.6" } } diff --git a/packages/x-engine/package.json b/packages/x-engine/package.json index 89c101950..6f4923fa9 100644 --- a/packages/x-engine/package.json +++ b/packages/x-engine/package.json @@ -16,9 +16,9 @@ "type": "git", "url": "https://github.com/Financial-Times/x-dash.git" }, - "homepage": "https://github.com/Financial-Times/x-dash/tree/master/packages/x-engine", + "homepage": "https://github.com/Financial-Times/x-dash/tree/HEAD/packages/x-engine", "engines": { - "node": ">= 6.0.0" + "node": "12.x" }, "publishConfig": { "access": "public" diff --git a/packages/x-engine/readme.md b/packages/x-engine/readme.md index 7a72843c0..514f47c86 100644 --- a/packages/x-engine/readme.md +++ b/packages/x-engine/readme.md @@ -4,7 +4,7 @@ This module is a consolidation library to render `x-dash` components with any co ## Installation -This module is compatible with Node 6+ and is distributed on npm. +This module is supported on Node 12 and is distributed on npm. ```bash npm install -S @financial-times/x-engine diff --git a/packages/x-engine/src/client.js b/packages/x-engine/src/client.js index 252282893..1e1f06f81 100644 --- a/packages/x-engine/src/client.js +++ b/packages/x-engine/src/client.js @@ -1,9 +1,9 @@ /* eslint no-undef: "off", no-unused-vars: "off" */ // This module is just a placeholder to be re-written at build time. -const runtime = require(X_ENGINE_RUNTIME_MODULE); -const render = require(X_ENGINE_RENDER_MODULE); +const runtime = require(X_ENGINE_RUNTIME_MODULE) +const render = require(X_ENGINE_RENDER_MODULE) -module.exports.h = X_ENGINE_FACTORY; -module.exports.render = X_ENGINE_RENDER; -module.exports.Component = X_ENGINE_COMPONENT; -module.exports.Fragment = X_ENGINE_FRAGMENT; +module.exports.h = X_ENGINE_FACTORY +module.exports.render = X_ENGINE_RENDER +module.exports.Component = X_ENGINE_COMPONENT +module.exports.Fragment = X_ENGINE_FRAGMENT diff --git a/packages/x-engine/src/concerns/deep-get.js b/packages/x-engine/src/concerns/deep-get.js index b83da6a21..b459fe059 100644 --- a/packages/x-engine/src/concerns/deep-get.js +++ b/packages/x-engine/src/concerns/deep-get.js @@ -6,17 +6,17 @@ * @returns {any | null} */ module.exports = (tree, path, defaultValue) => { - const route = path.split('.'); + const route = path.split('.') while (tree !== null && route.length) { - const leaf = route.shift(); + const leaf = route.shift() if (leaf !== undefined && tree.hasOwnProperty(leaf)) { - tree = tree[leaf]; + tree = tree[leaf] } else { - tree = null; + tree = null } } - return tree === null ? defaultValue : tree; -}; + return tree === null ? defaultValue : tree +} diff --git a/packages/x-engine/src/concerns/format-config.js b/packages/x-engine/src/concerns/format-config.js index 695e4c73f..c8a33477c 100644 --- a/packages/x-engine/src/concerns/format-config.js +++ b/packages/x-engine/src/concerns/format-config.js @@ -1,31 +1,31 @@ -const presets = require('./presets'); +const presets = require('./presets') /** * Format Config * @param {string|{ runtime: string, factory?: string }} config * @returns {{ runtime: string, factory: string }} */ -module.exports = function(config) { +module.exports = function (config) { // if configuration is a string, expand it if (typeof config === 'string') { if (presets.hasOwnProperty(config)) { - config = presets[config]; + config = presets[config] } else { - config = { runtime: config, factory: null }; + config = { runtime: config, factory: null } } } if (typeof config.runtime !== 'string') { - throw new TypeError('Engine configuration must define a runtime'); + throw new TypeError('Engine configuration must define a runtime') } if (config.factory && typeof config.factory !== 'string') { - throw new TypeError('Engine factory must be of type String.'); + throw new TypeError('Engine factory must be of type String.') } if (!config.renderModule) { - config.renderModule = config.runtime; + config.renderModule = config.runtime } - return config; -}; + return config +} diff --git a/packages/x-engine/src/concerns/presets.js b/packages/x-engine/src/concerns/presets.js index 399490193..493bf81a7 100644 --- a/packages/x-engine/src/concerns/presets.js +++ b/packages/x-engine/src/concerns/presets.js @@ -11,6 +11,7 @@ module.exports = { runtime: 'preact', factory: 'h', component: 'Component', + fragment: 'Fragment', render: 'render' }, hyperons: { @@ -20,4 +21,4 @@ module.exports = { fragment: 'Fragment', render: 'render' } -}; +} diff --git a/packages/x-engine/src/concerns/resolve-peer.js b/packages/x-engine/src/concerns/resolve-peer.js index b428f9c70..312de034c 100644 --- a/packages/x-engine/src/concerns/resolve-peer.js +++ b/packages/x-engine/src/concerns/resolve-peer.js @@ -1,2 +1,2 @@ -const path = require('path'); -module.exports = (moduleId) => path.join(process.cwd(), 'node_modules', moduleId); +const path = require('path') +module.exports = (moduleId) => path.join(process.cwd(), 'node_modules', moduleId) diff --git a/packages/x-engine/src/concerns/resolve-pkg.js b/packages/x-engine/src/concerns/resolve-pkg.js index 437386cae..5950986bb 100644 --- a/packages/x-engine/src/concerns/resolve-pkg.js +++ b/packages/x-engine/src/concerns/resolve-pkg.js @@ -1,3 +1,3 @@ -const path = require('path'); +const path = require('path') -module.exports = () => path.join(process.cwd(), 'package.json'); +module.exports = () => path.join(process.cwd(), 'package.json') diff --git a/packages/x-engine/src/server.js b/packages/x-engine/src/server.js index 1bd4193ca..ba3cbfc4d 100644 --- a/packages/x-engine/src/server.js +++ b/packages/x-engine/src/server.js @@ -1,40 +1,40 @@ -const deepGet = require('./concerns/deep-get'); -const resolvePkg = require('./concerns/resolve-pkg'); -const resolvePeer = require('./concerns/resolve-peer'); -const formatConfig = require('./concerns/format-config'); +const deepGet = require('./concerns/deep-get') +const resolvePkg = require('./concerns/resolve-pkg') +const resolvePeer = require('./concerns/resolve-peer') +const formatConfig = require('./concerns/format-config') // 1. try to load the application's package manifest -const pkg = require(resolvePkg()); +const pkg = require(resolvePkg()) // 2. if we have the manifest then find the engine configuration -const raw = deepGet(pkg, 'x-dash.engine.server'); +const raw = deepGet(pkg, 'x-dash.engine.server') if (!raw) { - throw new Error(`x-engine requires a server runtime to be specified. none found in ${pkg.name}`); + throw new Error(`x-engine requires a server runtime to be specified. none found in ${pkg.name}`) } // 3. format the configuration we've loaded -const config = formatConfig(raw); +const config = formatConfig(raw) // 4. if this module is a linked dependency then resolve required runtime to CWD -const runtime = require(resolvePeer(config.runtime)); +const runtime = require(resolvePeer(config.runtime)) // 5. if we've loaded the runtime then find its create element factory function -const factory = config.factory ? runtime[config.factory] : runtime; +const factory = config.factory ? runtime[config.factory] : runtime // 6. if we've loaded the runtime then find its Component constructor -const component = config.component ? runtime[config.component] : null; +const component = config.component ? runtime[config.component] : null // 7. if we've loaded the runtime then find its Fragment object -const fragment = config.fragment ? runtime[config.fragment] : null; +const fragment = config.fragment ? runtime[config.fragment] : null // 8. if the rendering module is different to the runtime, load it -const renderModule = config.renderModule ? require(resolvePeer(config.renderModule)) : runtime; +const renderModule = config.renderModule ? require(resolvePeer(config.renderModule)) : runtime // 9. if we've got the render module then find its render method -const render = config.render ? renderModule[config.render] : renderModule; +const render = config.render ? renderModule[config.render] : renderModule -module.exports.h = factory; -module.exports.render = render; -module.exports.Component = component; -module.exports.Fragment = fragment; +module.exports.h = factory +module.exports.render = render +module.exports.Component = component +module.exports.Fragment = fragment diff --git a/packages/x-engine/src/webpack.js b/packages/x-engine/src/webpack.js index 4aaa656d9..308529334 100644 --- a/packages/x-engine/src/webpack.js +++ b/packages/x-engine/src/webpack.js @@ -1,29 +1,29 @@ -const assignDeep = require('assign-deep'); -const deepGet = require('./concerns/deep-get'); -const resolvePkg = require('./concerns/resolve-pkg'); -const resolvePeer = require('./concerns/resolve-peer'); -const formatConfig = require('./concerns/format-config'); +const assignDeep = require('assign-deep') +const deepGet = require('./concerns/deep-get') +const resolvePkg = require('./concerns/resolve-pkg') +const resolvePeer = require('./concerns/resolve-peer') +const formatConfig = require('./concerns/format-config') -module.exports = function() { +module.exports = function () { // 1. try to load the application's package manifest - const pkg = require(resolvePkg()); + const pkg = require(resolvePkg()) // 2. if we have the manifest then find the engine configuration - const raw = deepGet(pkg, 'x-dash.engine.browser'); + const raw = deepGet(pkg, 'x-dash.engine.browser') if (!raw) { - throw new Error(`x-engine requires a browser runtime to be specified. none found in ${pkg.name}`); + throw new Error(`x-engine requires a browser runtime to be specified. none found in ${pkg.name}`) } // 3. format the configuration we've loaded - const config = formatConfig(raw); + const config = formatConfig(raw) // 4. if this module is a linked dependency then resolve Webpack & runtime to CWD - const webpackResolution = resolvePeer('webpack'); - const runtimeResolution = resolvePeer(config.runtime); - const renderResolution = resolvePeer(config.renderModule); + const webpackResolution = resolvePeer('webpack') + const runtimeResolution = resolvePeer(config.runtime) + const renderResolution = resolvePeer(config.renderModule) - const webpack = require(webpackResolution); + const webpack = require(webpackResolution) return { apply(compiler) { @@ -32,25 +32,25 @@ module.exports = function() { resolve: { alias: { [config.runtime]: runtimeResolution, - [config.renderModule]: renderResolution, - }, - }, - }); + [config.renderModule]: renderResolution + } + } + }) const replacements = { - 'X_ENGINE_RUNTIME_MODULE': `"${config.runtime}"`, - 'X_ENGINE_FACTORY': config.factory ? `runtime["${config.factory}"]` : 'runtime', - 'X_ENGINE_COMPONENT': config.component ? `runtime["${config.component}"]` : 'null', - 'X_ENGINE_FRAGMENT': config.fragment ? `runtime["${config.fragment}"]` : 'null', - 'X_ENGINE_RENDER_MODULE': `"${config.renderModule}"`, - 'X_ENGINE_RENDER': config.render ? `render["${config.render}"]` : 'null', - }; + X_ENGINE_RUNTIME_MODULE: `"${config.runtime}"`, + X_ENGINE_FACTORY: config.factory ? `runtime["${config.factory}"]` : 'runtime', + X_ENGINE_COMPONENT: config.component ? `runtime["${config.component}"]` : 'null', + X_ENGINE_FRAGMENT: config.fragment ? `runtime["${config.fragment}"]` : 'null', + X_ENGINE_RENDER_MODULE: `"${config.renderModule}"`, + X_ENGINE_RENDER: config.render ? `render["${config.render}"]` : 'null' + } // The define plugin performs direct text replacement // <https://webpack.js.org/plugins/define-plugin/> - const define = new webpack.DefinePlugin(replacements); + const define = new webpack.DefinePlugin(replacements) - define.apply(compiler); + define.apply(compiler) } - }; -}; + } +} diff --git a/packages/x-handlebars/concerns/resolve-local.js b/packages/x-handlebars/concerns/resolve-local.js index f3170513e..2ce5365ba 100644 --- a/packages/x-handlebars/concerns/resolve-local.js +++ b/packages/x-handlebars/concerns/resolve-local.js @@ -1,2 +1,2 @@ -const path = require('path'); -module.exports = (baseDirectory, moduleId) => path.resolve(baseDirectory, moduleId); +const path = require('path') +module.exports = (baseDirectory, moduleId) => path.resolve(baseDirectory, moduleId) diff --git a/packages/x-handlebars/concerns/resolve-peer.js b/packages/x-handlebars/concerns/resolve-peer.js index b428f9c70..312de034c 100644 --- a/packages/x-handlebars/concerns/resolve-peer.js +++ b/packages/x-handlebars/concerns/resolve-peer.js @@ -1,2 +1,2 @@ -const path = require('path'); -module.exports = (moduleId) => path.join(process.cwd(), 'node_modules', moduleId); +const path = require('path') +module.exports = (moduleId) => path.join(process.cwd(), 'node_modules', moduleId) diff --git a/packages/x-handlebars/index.js b/packages/x-handlebars/index.js index 867580b0c..4ce57ee17 100644 --- a/packages/x-handlebars/index.js +++ b/packages/x-handlebars/index.js @@ -1,61 +1,62 @@ -const { h, render } = require('@financial-times/x-engine'); -const resolvePeer = require('./concerns/resolve-peer'); -const resolveLocal = require('./concerns/resolve-local'); +const { h, render } = require('@financial-times/x-engine') +const resolvePeer = require('./concerns/resolve-peer') +const resolveLocal = require('./concerns/resolve-local') const defaults = { baseDirectory: process.cwd() -}; +} // We're exporting a function in case we need to add options or similar features later module.exports = (userOptions = {}) => { - const options = Object.assign({}, defaults, userOptions); + const options = Object.assign({}, defaults, userOptions) // Return a regular function expression so that the template context may be shared (this) - return function({ hash, data }) { - let moduleId; + return function ({ hash }) { + let moduleId if (hash.hasOwnProperty('package')) { - moduleId = resolvePeer(`@financial-times/${hash.package}`); + moduleId = resolvePeer(`@financial-times/${hash.package}`) } if (hash.hasOwnProperty('local')) { - moduleId = resolveLocal(options.baseDirectory, `./${hash.local}`); + moduleId = resolveLocal(options.baseDirectory, `./${hash.local}`) } if (!moduleId) { - throw new Error('You must specify a "package" or "local" argument to load a component'); + throw new Error('You must specify a "package" or "local" argument to load a component') } - const target = require(moduleId); + const target = require(moduleId) // TODO: remove this mixin stuff and make the components more easily configurable! - const mixins = {}; + const mixins = {} if (hash.hasOwnProperty('preset') && target.hasOwnProperty('presets')) { - Object.assign(mixins, target.presets[hash.preset]); + Object.assign(mixins, target.presets[hash.preset]) } - const component = hash.hasOwnProperty('component') ? target[hash.component] : target; + const component = hash.hasOwnProperty('component') ? target[hash.component] : target - const type = typeof component; + const type = typeof component if (type !== 'function') { - throw TypeError(`The included component (${hash.component} from ${hash.local || hash.package}) is not a function, it is of type "${type}"`); + throw TypeError( + `The included component (${hash.component} from ${ + hash.local || hash.package + }) is not a function, it is of type "${type}"` + ) } - // "this" is the current Handlebars context. don't merge it in if it's the root context - const props = Object.assign( - {}, - this === data.root ? {} : this, - mixins, - hash - ); + const props = Object.assign({}, this, mixins, hash) - // if this key is defined they've passed the root context in themselves, which is naughty + // Don't allow implementors to pass in the root context when using Express as the "locals" object may include sensitive data. + // <https://github.com/expressjs/express/blob/0a48e18056865364b2461b2ece7ccb2d1075d3c9/lib/response.js#L1002-L1003> if (props.hasOwnProperty('_locals')) { - throw new Error(`The root handlebars context shouldn't be passed to a component, as it may contain sensitive data.`); + throw new Error( + `The root handlebars context shouldn't be passed to a component, as it may contain sensitive data.` + ) } - return render(h(component, props) || ''); - }; -}; + return render(h(component, props) || '') + } +} diff --git a/packages/x-handlebars/package.json b/packages/x-handlebars/package.json index 525438701..bdb7ae83e 100644 --- a/packages/x-handlebars/package.json +++ b/packages/x-handlebars/package.json @@ -15,9 +15,9 @@ "type": "git", "url": "https://github.com/Financial-Times/x-dash.git" }, - "homepage": "https://github.com/Financial-Times/x-dash/tree/master/packages/x-handlebars", + "homepage": "https://github.com/Financial-Times/x-dash/tree/HEAD/packages/x-handlebars", "engines": { - "node": ">= 6.0.0" + "node": "12.x" }, "publishConfig": { "access": "public" diff --git a/packages/x-handlebars/readme.md b/packages/x-handlebars/readme.md index 8f2729036..c72186fdb 100644 --- a/packages/x-handlebars/readme.md +++ b/packages/x-handlebars/readme.md @@ -4,7 +4,7 @@ This module provides Handlebars helper functions to render `x-dash` component pa ## Installation -This module is compatible with Node 6+ and is distributed on npm. +This module is supported on Node 12 and is distributed on npm. ```bash npm install -S @financial-times/x-handlebars @@ -43,7 +43,7 @@ xHandlebars({ This module will install the [x-engine][x-engine] module as a dependency to perform the rendering of `x-` components. Please refer to the x-engine documentation to setup your application with `x-engine`. [n-ui]: https://github.com/Financial-Times/n-ui/ -[x-engine]: https://github.com/Financial-Times/x-dash/tree/master/packages/x-engine +[x-engine]: https://github.com/Financial-Times/x-dash/tree/HEAD/packages/x-engine ## Usage diff --git a/packages/x-logo/package.json b/packages/x-logo/package.json deleted file mode 100644 index df29b9eaa..000000000 --- a/packages/x-logo/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "@financial-times/x-logo", - "version": "0.0.0", - "private": true, - "description": "", - "main": "src/index.js", - "keywords": [], - "author": "", - "license": "ISC", - "dependencies": { - "delaunator": "^2.0.0", - "hsluv": "^0.0.3", - "point-in-polygon": "^1.0.1", - "poisson-disk-sampling": "^1.0.2", - "seedrandom": "^2.4.3" - } -} diff --git a/packages/x-logo/src/color.js b/packages/x-logo/src/color.js deleted file mode 100644 index bdf1f7be7..000000000 --- a/packages/x-logo/src/color.js +++ /dev/null @@ -1,20 +0,0 @@ -import { hsluvToHex } from 'hsluv'; - -export default (shift, random) => { - // Start a hue rotation (0-360 degrees) from a random position - const hue = random() * 360; - - // Make a starting HSL color - const hues = [hue, hue + shift, hue - shift, hue + shift * 2]; - - // Return a function to generate a color for a given coordinate - return ([x, y]) => { - const [tl, tr, bl, br] = hues; - const th = tl + (tr - tl) * (x / 100); - const bh = bl + (br - bl) * (x / 100); - const hue = th + (bh - th) * (y / 100); - - // <http://www.hsluv.org/> - return hsluvToHex([hue, 85 + 10 * random(), 45 + 10 * random()]); - }; -}; diff --git a/packages/x-logo/src/index.js b/packages/x-logo/src/index.js deleted file mode 100644 index 264dfd26f..000000000 --- a/packages/x-logo/src/index.js +++ /dev/null @@ -1,103 +0,0 @@ -import React from 'react'; -import seedrandom from 'seedrandom'; -import createColor from './color'; -import createNoise from './noise'; -import createPolygon from './polygon'; -import createTriangles from './triangles'; -import { pointsToString } from './util'; - -const options = { - seed: Math.random(), - density: 15, - thickness: 16, - hueShift: 45 -}; - -// Create a random number generator -const random = seedrandom(options.seed); - -// Create the standard size X for use as the clip mask -const polygonPoints = createPolygon({ - x: 0, - y: 0, - width: 100, - height: 100, - thickness: options.thickness -}); - -// Create a larger X "canvas" to place points and shapes within -const polygonCanvas = createPolygon({ - x: -25, - y: -25, - width: 150, - height: 150, - thickness: options.thickness * 1.25 -}); - -const animations = ` - @keyframes logoHueRotate { - 0% { filter: hue-rotate(0); } - 100% { filter: hue-rotate(359.9deg); } - } - - @keyframes logoShimmer { - 0% { opacity: 1; } - 50% { opacity: 0.8; } - 100% { opacity: 1; } - } -`; - -// Create random points within the given canvas and with the given seed -const noise = createNoise(options.density, polygonCanvas, random); - -// Join the random points to create a set of triangles -const triangles = createTriangles(noise); - -// Create a random color generator from the given hue and seed -const getColor = createColor(options.hueShift, random); - -// Create an array to iterate over to draw each triangle -const numberOfTriangles = Array.from(Array(triangles.length / 3).keys()); - -export default () => ( - <React.Fragment> - <style>{animations}</style> - <svg - viewBox="0 0 100 100" - xmlns="http://www.w3.org/2000/svg" - style={{ - animation: 'logoHueRotate 30s linear infinite' - }}> - - <clipPath id="logo-clip-path"> - <polygon points={pointsToString(polygonPoints)} /> - </clipPath> - - <g clipPath="url(#logo-clip-path)"> - {numberOfTriangles.map((i) => { - const points = [ - noise[triangles[i * 3]], - noise[triangles[i * 3 + 1]], - noise[triangles[i * 3 + 2]] - ]; - - const color = getColor(noise[triangles[i * 3]]); - - return ( - <polygon - key={`triangle-${i}`} - fill={color} - stroke={color} - strokeWidth="0.1%" - strokeLinejoin="round" - points={pointsToString(points)} - style={{ - animation: `logoShimmer ${(random() * 10 + 5).toFixed(2)}s linear infinite` - }} - /> - ); - })} - </g> - </svg> - </React.Fragment> -); diff --git a/packages/x-logo/src/noise.js b/packages/x-logo/src/noise.js deleted file mode 100644 index 29db3f030..000000000 --- a/packages/x-logo/src/noise.js +++ /dev/null @@ -1,14 +0,0 @@ -import Poisson from 'poisson-disk-sampling'; -import pointInPolygon from 'point-in-polygon'; - -export default (density, canvas, random) => { - // Poisson noise produces randomly distributed points that may not be too close to each other - // <https://en.wikipedia.org/wiki/Shot_noise> - const noise = new Poisson([150, 150], 100 / density, 100, 30, random); - - // Remove any points that do not fit within the given canvas - return noise - .fill() - .map(([x, y]) => [x - 25, y - 25]) - .filter((point) => pointInPolygon(point, canvas)); -}; diff --git a/packages/x-logo/src/polygon.js b/packages/x-logo/src/polygon.js deleted file mode 100644 index 332d747a1..000000000 --- a/packages/x-logo/src/polygon.js +++ /dev/null @@ -1,20 +0,0 @@ -// Returns an array of points for an X shaped polygon -export default ({ x, y, width, height, thickness }) => { - const middleX = x + width / 2; - const middleY = y + height / 2; - - return [ - [x, y + thickness], - [middleX - thickness, middleY], - [x, y + height - thickness], - [x + thickness, y + height], - [middleX, middleY + thickness], - [x + width - thickness, y + height], - [x + width, y + height - thickness], - [middleX + thickness, middleY], - [x + width, y + thickness], - [x + width - thickness, y], - [middleX, middleY - thickness], - [x + thickness, y] - ]; -}; diff --git a/packages/x-logo/src/triangles.js b/packages/x-logo/src/triangles.js deleted file mode 100644 index cfd1b8087..000000000 --- a/packages/x-logo/src/triangles.js +++ /dev/null @@ -1,6 +0,0 @@ -import Delaunay from 'delaunator'; - -export default (noise) => { - const { triangles } = Delaunay.from(noise); - return triangles; -}; diff --git a/packages/x-logo/src/util.js b/packages/x-logo/src/util.js deleted file mode 100644 index c1e13095d..000000000 --- a/packages/x-logo/src/util.js +++ /dev/null @@ -1,4 +0,0 @@ -// Stringifies a list points -// <https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/points> -export const pointsToString = (points) => - points.map((point) => point.map((x) => Math.round(x)).join()).join(); diff --git a/packages/x-node-jsx/index.js b/packages/x-node-jsx/index.js index 99824cafd..14993d99f 100644 --- a/packages/x-node-jsx/index.js +++ b/packages/x-node-jsx/index.js @@ -1,29 +1,29 @@ -const { addHook } = require('pirates'); -const { transform } = require('sucrase'); +const { addHook } = require('pirates') +const { transform } = require('sucrase') -const extension = '.jsx'; +const extension = '.jsx' // Assume .jsx components are using x-engine const jsxOptions = { jsxPragma: 'h', jsxFragmentPragma: 'Fragment' -}; +} const defaultOptions = { // Do not output JSX debugger information production: true, // https://github.com/alangpierce/sucrase#transforms transforms: ['imports', 'jsx'] -}; +} module.exports = (userOptions = {}) => { - const options = { ...defaultOptions, ...userOptions, ...jsxOptions }; + const options = { ...defaultOptions, ...userOptions, ...jsxOptions } const handleJSX = (code) => { - const transformed = transform(code, options); - return transformed.code; - }; + const transformed = transform(code, options) + return transformed.code + } // Return a function to revert the hook - return addHook(handleJSX, { exts: [extension] }); -}; + return addHook(handleJSX, { exts: [extension] }) +} diff --git a/packages/x-node-jsx/package.json b/packages/x-node-jsx/package.json index 3a2a2c1dc..b6ecee6ce 100644 --- a/packages/x-node-jsx/package.json +++ b/packages/x-node-jsx/package.json @@ -16,9 +16,9 @@ "type": "git", "url": "https://github.com/Financial-Times/x-dash.git" }, - "homepage": "https://github.com/Financial-Times/x-dash/tree/master/packages/x-node-jsx", + "homepage": "https://github.com/Financial-Times/x-dash/tree/HEAD/packages/x-node-jsx", "engines": { - "node": ">= 8.0.0" + "node": "12.x" }, "publishConfig": { "access": "public" diff --git a/packages/x-node-jsx/readme.md b/packages/x-node-jsx/readme.md index 9dd465654..2d1604363 100644 --- a/packages/x-node-jsx/readme.md +++ b/packages/x-node-jsx/readme.md @@ -8,7 +8,7 @@ This module extends Node's `require()` function to enable the use of `.jsx` file ## Installation -This module is compatible with Node 8+ and is distributed on npm. +This module is supported on Node 12 and is distributed on npm. ```bash npm install -S @financial-times/x-node-jsx diff --git a/packages/x-node-jsx/register.js b/packages/x-node-jsx/register.js index f73e3bfa7..2b8efefce 100644 --- a/packages/x-node-jsx/register.js +++ b/packages/x-node-jsx/register.js @@ -1 +1 @@ -require('./')(); +require('./')() diff --git a/packages/x-rollup/index.js b/packages/x-rollup/index.js index aa5d51258..1e24dce92 100644 --- a/packages/x-rollup/index.js +++ b/packages/x-rollup/index.js @@ -1,16 +1,16 @@ -const rollupConfig = require('./src/rollup-config'); -const logger = require('./src/logger'); -const bundle = require('./src/bundle'); -const watch = require('./src/watch'); +const rollupConfig = require('./src/rollup-config') +const logger = require('./src/logger') +const bundle = require('./src/bundle') +const watch = require('./src/watch') module.exports = async (options) => { - const configs = rollupConfig(options); - const command = process.argv.slice(-1)[0] === '--watch' ? watch : bundle; + const configs = rollupConfig(options) + const command = process.argv.slice(-1)[0] === '--watch' ? watch : bundle try { - await command(configs); + await command(configs) } catch (error) { - logger.error(error instanceof Error ? error.message : error); - process.exit(1); + logger.error(error instanceof Error ? error.message : error) + process.exit(1) } -}; +} diff --git a/packages/x-rollup/package.json b/packages/x-rollup/package.json index 662a19751..c0f796da1 100644 --- a/packages/x-rollup/package.json +++ b/packages/x-rollup/package.json @@ -11,14 +11,14 @@ "author": "", "license": "ISC", "dependencies": { + "@babel/core": "^7.6.4", + "@babel/plugin-external-helpers": "^7.2.0", "@financial-times/x-babel-config": "file:../x-babel-config", - "babel-core": "^6.26.3", - "babel-plugin-external-helpers": "^6.22.0", - "chalk": "^2.4.1", - "log-symbols": "^2.2.0", - "rollup": "^0.63.0", - "rollup-plugin-babel": "^3.0.7", - "rollup-plugin-commonjs": "^9.1.3", - "rollup-plugin-postcss": "^1.6.2" + "chalk": "^2.4.2", + "log-symbols": "^3.0.0", + "rollup": "^1.23.0", + "rollup-plugin-babel": "^4.3.2", + "rollup-plugin-commonjs": "^10.1.0", + "rollup-plugin-postcss": "^2.0.2" } } diff --git a/packages/x-rollup/src/babel-config.js b/packages/x-rollup/src/babel-config.js index aaf9ffa28..fe6ba7ffd 100644 --- a/packages/x-rollup/src/babel-config.js +++ b/packages/x-rollup/src/babel-config.js @@ -1,15 +1,15 @@ -const baseBabelConfig = require('@financial-times/x-babel-config'); +const baseBabelConfig = require('@financial-times/x-babel-config') module.exports = (...args) => { - const base = baseBabelConfig(...args); + const base = baseBabelConfig(...args) base.plugins.push( // Instruct Babel to not include any internal helper declarations in the output - require.resolve('babel-plugin-external-helpers'), - ); + require.resolve('@babel/plugin-external-helpers') + ) // rollup-specific option not included in base config - base.include = '**/*.{js,jsx}'; + base.include = '**/*.{js,jsx}' - return base; + return base } diff --git a/packages/x-rollup/src/bundle.js b/packages/x-rollup/src/bundle.js index c9fed0d78..69f9d1f6c 100644 --- a/packages/x-rollup/src/bundle.js +++ b/packages/x-rollup/src/bundle.js @@ -1,10 +1,10 @@ -const rollup = require('rollup'); -const logger = require('./logger'); +const rollup = require('rollup') +const logger = require('./logger') module.exports = async (configs) => { for (const [input, output] of configs) { - const bundle = await rollup.rollup(input); - await bundle.write(output); - logger.success(`Bundled ${output.file}`); + const bundle = await rollup.rollup(input) + await bundle.write(output) + logger.success(`Bundled ${output.file}`) } -}; +} diff --git a/packages/x-rollup/src/logger.js b/packages/x-rollup/src/logger.js index a0cd0c6d3..f5cf35b73 100644 --- a/packages/x-rollup/src/logger.js +++ b/packages/x-rollup/src/logger.js @@ -1,27 +1,27 @@ -const chalk = require('chalk'); -const logSymbols = require('log-symbols'); +const chalk = require('chalk') +const logSymbols = require('log-symbols') const format = (symbol, color, message) => { - const time = new Date().toLocaleTimeString(); - return `[${time}] ${symbol} ${chalk[color](message)}\n`; -}; + const time = new Date().toLocaleTimeString() + return `[${time}] ${symbol} ${chalk[color](message)}\n` +} module.exports.info = (message) => { - process.stdout.write(format(logSymbols.info, 'blue', message)); -}; + process.stdout.write(format(logSymbols.info, 'blue', message)) +} module.exports.message = (message) => { - process.stdout.write(format('\x20', 'gray', message)); -}; + process.stdout.write(format('\x20', 'gray', message)) +} module.exports.success = (message) => { - process.stdout.write(format(logSymbols.success, 'green', message)); -}; + process.stdout.write(format(logSymbols.success, 'green', message)) +} module.exports.warning = (message) => { - process.stdout.write(format(logSymbols.warning, 'yellow', message)); -}; + process.stdout.write(format(logSymbols.warning, 'yellow', message)) +} module.exports.error = (message) => { - process.stderr.write(format(logSymbols.error, 'red', message)); -}; + process.stderr.write(format(logSymbols.error, 'red', message)) +} diff --git a/packages/x-rollup/src/postcss-config.js b/packages/x-rollup/src/postcss-config.js index 5fac57486..e554a7b35 100644 --- a/packages/x-rollup/src/postcss-config.js +++ b/packages/x-rollup/src/postcss-config.js @@ -1,4 +1,4 @@ -const path = require('path'); +const path = require('path') module.exports = (style) => { return { @@ -14,5 +14,5 @@ module.exports = (style) => { 'stylus', 'less' ] - }; -}; + } +} diff --git a/packages/x-rollup/src/rollup-config.js b/packages/x-rollup/src/rollup-config.js index a3b85dd67..b022b2685 100644 --- a/packages/x-rollup/src/rollup-config.js +++ b/packages/x-rollup/src/rollup-config.js @@ -1,22 +1,22 @@ -const babel = require('rollup-plugin-babel'); -const postcss = require('rollup-plugin-postcss'); -const commonjs = require('rollup-plugin-commonjs'); -const postcssConfig = require('./postcss-config'); -const babelConfig = require('./babel-config'); +const babel = require('rollup-plugin-babel') +const postcss = require('rollup-plugin-postcss') +const commonjs = require('rollup-plugin-commonjs') +const postcssConfig = require('./postcss-config') +const babelConfig = require('./babel-config') module.exports = ({ input, pkg }) => { // Don't bundle any dependencies - const external = Object.keys(pkg.dependencies); + const external = Object.keys(pkg.dependencies) const plugins = [ // Convert CommonJS modules to ESM so they can be included in the bundle commonjs({ extensions: ['.js', '.jsx'] }) - ]; + ] // Add support for CSS modules (and any required transpilation) if (pkg.style) { - const config = postcssConfig(pkg.style); - plugins.push(postcss(config)); + const config = postcssConfig(pkg.style) + plugins.push(postcss(config)) } // Pairs of input and output options @@ -25,7 +25,14 @@ module.exports = ({ input, pkg }) => { { input, external, - plugins: [babel(babelConfig({ node: 6 })), ...plugins] + plugins: [ + babel( + babelConfig({ + targets: { node: '12' } + }) + ), + ...plugins + ] }, { file: pkg.module, @@ -36,7 +43,14 @@ module.exports = ({ input, pkg }) => { { input, external, - plugins: [babel(babelConfig({ node: 6 })), ...plugins] + plugins: [ + babel( + babelConfig({ + targets: { node: '12' } + }) + ), + ...plugins + ] }, { file: pkg.main, @@ -47,12 +61,19 @@ module.exports = ({ input, pkg }) => { { input, external, - plugins: [babel(babelConfig({ browsers: ['ie 11'] })), ...plugins] + plugins: [ + babel( + babelConfig({ + targets: { ie: '11' } + }) + ), + ...plugins + ] }, { file: pkg.browser, format: 'cjs' } ] - ]; -}; + ] +} diff --git a/packages/x-rollup/src/watch.js b/packages/x-rollup/src/watch.js index e4eebf705..be9199b79 100644 --- a/packages/x-rollup/src/watch.js +++ b/packages/x-rollup/src/watch.js @@ -1,36 +1,36 @@ -const path = require('path'); -const rollup = require('rollup'); -const logger = require('./logger'); +const path = require('path') +const rollup = require('rollup') +const logger = require('./logger') module.exports = (configs) => { // Merge the separate input/output options for each bundle const formattedConfigs = configs.map(([input, output]) => { - return { ...input, output }; - }); + return { ...input, output } + }) return new Promise((resolve, reject) => { - const watcher = rollup.watch(formattedConfigs); + const watcher = rollup.watch(formattedConfigs) - logger.info('Watching files, press ctrl + c to stop'); + logger.info('Watching files, press ctrl + c to stop') watcher.on('event', (event) => { switch (event.code) { case 'END': - logger.message('Waiting for changes…'); - break; + logger.message('Waiting for changes…') + break case 'BUNDLE_END': - logger.success(`Bundled ${path.relative(process.cwd(), event.output[0])}`); - break; + logger.success(`Bundled ${path.relative(process.cwd(), event.output[0])}`) + break case 'ERROR': - logger.warning(event.error); - break; + logger.warning(event.error) + break case 'FATAL': - reject(event.error); - break; + reject(event.error) + break } - }); - }); -}; + }) + }) +} diff --git a/packages/x-test-utils/enzyme.js b/packages/x-test-utils/enzyme.js index 571b0a790..f840a1ddc 100644 --- a/packages/x-test-utils/enzyme.js +++ b/packages/x-test-utils/enzyme.js @@ -1,7 +1,7 @@ -const Enzyme = require('enzyme'); -const Adapter = require('enzyme-adapter-react-16'); -require('jest-enzyme'); +const Enzyme = require('enzyme') +const Adapter = require('enzyme-adapter-react-16') +require('jest-enzyme') -Enzyme.configure({ adapter: new Adapter() }); +Enzyme.configure({ adapter: new Adapter() }) -module.exports = Enzyme; +module.exports = Enzyme diff --git a/packages/x-test-utils/package.json b/packages/x-test-utils/package.json index 193cd74db..2aac2c177 100644 --- a/packages/x-test-utils/package.json +++ b/packages/x-test-utils/package.json @@ -13,7 +13,7 @@ "dependencies": { "enzyme": "^3.6.0", "enzyme-adapter-react-16": "^1.5.0", - "jest-enzyme": "^6.0.4", + "jest-enzyme": "^7.1.2", "react": "^16.5.0", "react-dom": "^16.5.0" } diff --git a/private/blueprints/component/package.json b/private/blueprints/component/package.json index 4dca01431..217b8e2c8 100644 --- a/private/blueprints/component/package.json +++ b/private/blueprints/component/package.json @@ -6,7 +6,6 @@ "module": "dist/{{componentName}}.esm.js", "browser": "dist/{{componentName}}.es5.js", "scripts": { - "prepare": "npm run build", "build": "node rollup.js", "start": "node rollup.js --watch" }, @@ -23,9 +22,9 @@ "type" : "git", "url" : "https://github.com/Financial-Times/x-dash.git" }, - "homepage": "https://github.com/Financial-Times/x-dash/tree/master/components/{{packageName}}", + "homepage": "https://github.com/Financial-Times/x-dash/tree/HEAD/components/{{packageName}}", "engines": { - "node": ">= 6.0.0" + "node": "12.x" }, "publishConfig": { "access": "public" diff --git a/private/blueprints/component/readme.md b/private/blueprints/component/readme.md index ae258400a..ee7c68bf2 100644 --- a/private/blueprints/component/readme.md +++ b/private/blueprints/component/readme.md @@ -5,7 +5,7 @@ This module has these features and scope. ## Installation -This module is compatible with Node 6+ and is distributed on npm. +This module is supported on Node 12 and is distributed on npm. ```bash npm install --save @financial-times/{{packageName}} @@ -13,7 +13,7 @@ npm install --save @financial-times/{{packageName}} The [`x-engine`][engine] module is used to inject your chosen runtime into the component. Please read the `x-engine` documentation first if you are consuming `x-` components for the first time in your application. -[engine]: https://github.com/Financial-Times/x-dash/tree/master/packages/x-engine +[engine]: https://github.com/Financial-Times/x-dash/tree/HEAD/packages/x-engine ## Usage diff --git a/private/blueprints/component/rollup.js b/private/blueprints/component/rollup.js index dd7f2fb3f..3dbac7a40 100644 --- a/private/blueprints/component/rollup.js +++ b/private/blueprints/component/rollup.js @@ -1,4 +1,4 @@ -const xRollup = require('@financial-times/x-rollup'); -const pkg = require('./package.json'); +const xRollup = require('@financial-times/x-rollup') +const pkg = require('./package.json') -xRollup({ input: './src/{{componentName}}.jsx', pkg }); +xRollup({ input: './src/{{componentName}}.jsx', pkg }) diff --git a/private/blueprints/component/stories/example.js b/private/blueprints/component/stories/example.js index 6df941bf7..05cb1c9d9 100644 --- a/private/blueprints/component/stories/example.js +++ b/private/blueprints/component/stories/example.js @@ -1,9 +1,9 @@ -exports.title = 'Example'; +exports.title = 'Example' exports.data = { message: 'Hello World!' -}; +} // This reference is only required for hot module loading in development // <https://webpack.js.org/concepts/hot-module-replacement/> -exports.m = module; +exports.m = module diff --git a/private/scripts/blueprint.js b/private/scripts/blueprint.js index adca45ef8..d2551bcb9 100644 --- a/private/scripts/blueprint.js +++ b/private/scripts/blueprint.js @@ -1,99 +1,99 @@ /* eslint no-console:off */ -const fs = require('fs'); -const path = require('path'); +const fs = require('fs') +const path = require('path') function template(source, data = {}) { - const token = /\{\{(\w+)\}\}/g; - return source.replace(token, (match, prop) => data[prop] || ''); + const token = /\{\{(\w+)\}\}/g + return source.replace(token, (match, prop) => data[prop] || '') } // Recursively load files from the target directory function loadFiles(target) { - const fileNames = fs.readdirSync(target); - const output = {}; + const fileNames = fs.readdirSync(target) + const output = {} for (const fileName of fileNames) { - const fullPath = path.join(target, fileName); - const stats = fs.statSync(fullPath); + const fullPath = path.join(target, fileName) + const stats = fs.statSync(fullPath) if (stats.isDirectory()) { - output[fileName] = loadFiles(fullPath); + output[fileName] = loadFiles(fullPath) } else { - output[fileName] = String(fs.readFileSync(fullPath)); + output[fileName] = String(fs.readFileSync(fullPath)) } } - return output; + return output } // Run each file through simple templating function templateFiles(files = {}, data = {}) { - const output = {}; + const output = {} for (const [file, contents] of Object.entries(files)) { if (typeof contents === 'object') { - output[file] = templateFiles(contents, data); + output[file] = templateFiles(contents, data) } else { // allow file names to include placeholders - const fileName = template(file, data); - output[fileName] = template(contents, data); + const fileName = template(file, data) + output[fileName] = template(contents, data) } } - return output; + return output } function writeOutput(target, output) { - const relPath = path.relative(process.cwd(), target); - console.log(`Creating directory ${relPath}`); + const relPath = path.relative(process.cwd(), target) + console.log(`Creating directory ${relPath}`) - fs.mkdirSync(target); + fs.mkdirSync(target) for (const [file, contents] of Object.entries(output)) { - const fullPath = path.join(target, file); + const fullPath = path.join(target, file) if (typeof contents === 'object') { - writeOutput(fullPath, contents); + writeOutput(fullPath, contents) } else { - console.log(`Creating file ${file}`); + console.log(`Creating file ${file}`) - fs.writeFileSync(fullPath, contents); + fs.writeFileSync(fullPath, contents) } } } function fatal(message) { - console.error(`ERROR: ${message}`); - process.exit(1); + console.error(`ERROR: ${message}`) + process.exit(1) } // Collate variables -const name = process.argv.slice(-1).pop(); -const formattedName = typeof name === 'string' ? name.replace(/[^a-z]/i, '') : ''; -const packageName = `x-${formattedName.toLowerCase()}`; -const componentName = formattedName.charAt(0).toUpperCase() + name.substr(1); -const sourceDir = path.join(process.cwd(), 'private/blueprints/component'); -const targetDir = path.join(process.cwd(), 'components', packageName); +const name = process.argv.slice(-1).pop() +const formattedName = typeof name === 'string' ? name.replace(/[^a-z]/i, '') : '' +const packageName = `x-${formattedName.toLowerCase()}` +const componentName = formattedName.charAt(0).toUpperCase() + name.substr(1) +const sourceDir = path.join(process.cwd(), 'private/blueprints/component') +const targetDir = path.join(process.cwd(), 'components', packageName) // Validate input if (name === undefined) { - fatal('A component name is required, usage: blueprint.js {name}'); + fatal('A component name is required, usage: blueprint.js {name}') } if (/^x/.test(name)) { - fatal('Component names should not include the "x-" prefix'); + fatal('Component names should not include the "x-" prefix') } if (fs.existsSync(targetDir)) { - fatal(`Directory ${targetDir} already exists`); + fatal(`Directory ${targetDir} already exists`) } // Create and write blueprint files try { - const files = loadFiles(sourceDir); - const templated = templateFiles(files, { packageName, componentName }); + const files = loadFiles(sourceDir) + const templated = templateFiles(files, { packageName, componentName }) - writeOutput(targetDir, templated); + writeOutput(targetDir, templated) } catch (error) { - fatal(error.message); + fatal(error.message) } diff --git a/private/scripts/gh-pages b/private/scripts/gh-pages deleted file mode 100755 index f0d1b1fa5..000000000 --- a/private/scripts/gh-pages +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash - -TARGET_DIR=tools/x-docs/public/* -TARGET_BRANCH=gh-pages -TEMP_DIR=tmp - -# Set error handling -set -eu -o pipefail - -# Set GitHub user information (the email must match the SSH key provided to Circle) -git config --global user.email $GITHUB_EMAIL -git config --global user.name $GITHUB_NAME - -# HACK: Add GitHub to known hosts to avoid an interactive prompt when cloning over SSH -mkdir -p ~/.ssh -ssh-keyscan -H github.com >> ~/.ssh/known_hosts - -# Clone only the branch we need so we don't download all of the project history -git clone $CIRCLE_REPOSITORY_URL $TEMP_DIR --single-branch --branch $TARGET_BRANCH - -# Remove all of the files, -q prevents logging every filename -cd $TEMP_DIR -git rm -rf . -cd .. - -# Copy contents of target directory to the deployment directory -cp -R -L $TARGET_DIR $TEMP_DIR - -# Copy CI config (which should instruct Circle to ignore this branch) -cp -r .circleci $TEMP_DIR - -cd $TEMP_DIR - -# Stage and commit all of the files -git add -A &> /dev/null -git commit -m "Automated deployment to GitHub Pages: ${CIRCLE_SHA1}" --allow-empty - -# Push to the target branch, staying quiet unless something goes wrong -git push -q origin $TARGET_BRANCH diff --git a/readme.md b/readme.md index dcaad73a0..b9d288ca0 100644 --- a/readme.md +++ b/readme.md @@ -1,8 +1,8 @@ <h1 align="center"> - <img src="https://user-images.githubusercontent.com/271645/38416861-1e6c6202-398e-11e8-907c-8c199a03988a.png" width="200" alt="x-dash"><br> + <img src="https://user-images.githubusercontent.com/271645/38416861-1e6c6202-398e-11e8-907c-8c199a03988a.png" width="200" alt=""><br> x-dash - <a href="https://circleci.com/gh/Financial-Times/x-dash/tree/master"> - <img alt="Build Status" src="https://circleci.com/gh/Financial-Times/x-dash/tree/master.svg?style=svg"> + <a href="https://circleci.com/gh/Financial-Times/x-dash/tree/main"> + <img alt="Build Status" src="https://circleci.com/gh/Financial-Times/x-dash/tree/main.svg?style=svg"> </a> </h1> @@ -13,8 +13,104 @@ Check out the [getting started] guide to begin hacking on x-dash. [Google Slides]: https://docs.google.com/presentation/d/1Z8mGsv4JU2TafNPIHw2RcejoNp7AN_v4LfCCGC7qrgw/edit?usp=sharing [getting started]: https://financial-times.github.io/x-dash/docs/get-started/installation -### How is that not Origami? +## How is that not Origami? Origami components are designed to work across the whole of FT and our sub-brands, making as few assumptions as possible about the tech stack that will be consuming them. Origami components don't contain templating, only styles and behaviour. It's up to each individual app to produce markup for components. x-dash aims to complement Origami by providing easily reusable and composable templates, flexibly enough to work across Next and Apps apps. + +## Installing x-dash + +### Requirements + +To get started with x-dash, you'll need to make sure you have the following software tools installed. + +1. [Git](https://git-scm.com/) +2. [Make](https://www.gnu.org/software/make/) +3. [Node.js](https://nodejs.org/en/) (version 12) +4. [npm](http://npmjs.com/) + +Please note that x-dash has only been tested in Mac and Linux environments. If you are on a Mac you may find it easiest to install the [Command Line Tools](https://developer.apple.com/download/more/) package which includes Git and Make. + +#### Recommended + +To aid the development of interactive components with Storybook it is recommended to install the React Developer Tools for your browser. These addons aid debugging by displaying the rendered component tree and provide access to view and edit their properties: + +- [React Developer Tools for Chrome](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) +- [React Developer Tools for Firefox](https://addons.mozilla.org/en-GB/firefox/addon/react-devtools/) + + +### Project setup + +1. Clone the x-dash Git repository and change to the new directory that has been created: + + ```bash + git clone git@github.com:financial-times/x-dash + cd x-dash + ``` + +2. Install all of the project dependencies (this may take a few minutes if you are running this for the first time): + + ```bash + make install + ``` + +3. Build the current set of x-dash components and start Storybook to view: + + ```bash + make build + npm run start-storybook + ``` + +## Working with x-dash + +The project repository is a monorepo which means all of the tools, packages and components are kept in one place and can be worked upon concurrently. + + +### Repository structure + +The repository groups related code together in directories. UI components are stored in the `components` directory, documentation files in the `docs` directory, additional public packages in the `packages` directory and tools to aid working with x-dash are in the `tools` directory. + +``` +├ components/ +│ └ x-component/ +│ ├ readme.md +│ └ package.json +├ packages/ +│ └ x-package/ +│ ├ readme.md +│ └ package.json +├ tools/ +│ └ x-docs/ +│ └ package.json +├ readme.md +└ package.json +``` + +### Using Storybook + +[Storybook] is a development environment and showcase for UI components. It makes working on and sharing UI components easier by providing a richly configurable environment. + +After installing x-dash you can start Storybook by running the following command from the repository root: + +```sh +npm run start-storybook +``` + +This command will start a server running on [local.ft.com:9001] and generate an application presenting all of the components configured to use it. Changes to these components can be updated in real-time speeding up the development process. + +Data properties passed to these component may also be configured in-situ and these changes will be reflected in the URL making it possible to share specific configurations. + +[Storybook]: https://storybook.js.org/ +[local.ft.com:9001]: http://local.ft.com:9001/ + + +## Coding standards + +The best way to ensure you stick to the x-dash code style is to make your work consistent with the code around it. We also provide a [Prettier] configuration to automatically format files and run [ESLint] before any tests. See the [contribution guide] for more information. + +[Prettier]: https://prettier.io/ +[ESLint]: https://eslint.org/ +[contribution guide]: https://github.com/Financial-Times/x-dash/blob/HEAD/contribution.md + +For more in-depth information visit the [Wiki](https://github.com/Financial-Times/x-dash/wiki) \ No newline at end of file diff --git a/release-guidelines.md b/release-guidelines.md deleted file mode 100644 index d4e99c79a..000000000 --- a/release-guidelines.md +++ /dev/null @@ -1,34 +0,0 @@ -# Release Guidelines - -## Experimental features - -Only stable, well tested components and packages may be present in the master or development branches. _Any publishable code in the master or development branches must have been tested in both The App and FT.com_. This is so we do not release unproven components with a stable version number. - -To develop your component create a new feature branch including your module name, for example if you are building a new tabs component you would create a branch named `feature-x-tabs`. Your component will stay in this branch until it is ready to be merged into the next major or minor release so you are encouraged to merge from or rebase onto the latest development or master branch regularly. You are welcome to raise pull requests against your feature branch if you need to. - -Because experimental modules will not be included in any stable releases we allow them to be published separately using a pre-1.0.0 version number. You are free to make as many prereleases as you need. To create a prerelease of your experimental module you must create a tag in the format `module-name-v0.x.x`, for example to release the tabs component you would create tag named `x-tabs-v0.0.1` for the latest commit in the `feature-x-tabs` branch. - -You are encouraged to use an identifier to namespace your prereleases, e.g. `x-tags-v0.0.1-beta.1`, as this will also prevent Renovate from automatically creating a PR for updating applications using an earlier version of your component (this would be undesirable if your new component version contained breaking changes which cannot be expressed with semver). - -When your new module is considered stable raise a pull request against the current development branch. Your module will be released as part of the next major or minor version. - - -## Releasing/Versioning - -All of our projects are versioned using [Semantic Versioning], you should familiarise yourself with this. The following guide will outline how to tag and release a new version of all projects, it assumes that all the code you wish to release is now on the `master` or main branch. - - 1. **Review the commits since the last release**. You can find the last release in the git log, or by using the compare feature on GitHub. Make sure you've pulled all of the latest changes. - - 2. **Decide on a version**. Work out whether this release is major, minor, or patch level. Major releases are generally planned out; if a breaking change has snuck into `master` without prior-planning it may be worth removing it or attempting to make it backwards-compatible. - - 3. **Write the changelog**. This project has a `changelog.md` file in the root. You should create a new section at the top with the new version number and the date, then outline all of the changes as a list. Follow the style of the rest of the document. - - 4. **Update any package files**. Add the new version to package files. This could include `package.json` or `bower.json` as examples. A quick way to check if you've got them all is by running: `git grep "current-version-number"` - - 5. **Commit your changes**. Commit the changes to changelong, README, and package files. The commit message should be "Version x.x.x" (exact casing, and with no "v" preceeding the version number). This is the _only_ time you're allowed to commit directly to `master`. - - 6. **Add a release**. Create a release using the GitHub UI (note there should be a "v" preceeding the version number). This will automatically kick off a new build and publish each package. - - 7. **Celebrate**. :tada::beer::cake::cocktail: - -[semantic versioning]: http://semver.org/ diff --git a/renovate.json b/renovate.json new file mode 100644 index 000000000..2bde8702b --- /dev/null +++ b/renovate.json @@ -0,0 +1,5 @@ +{ + "extends": [ + "github>financial-times/renovate-config-next-beta" + ] +} diff --git a/static.json b/static.json new file mode 100644 index 000000000..9e4c3e264 --- /dev/null +++ b/static.json @@ -0,0 +1,3 @@ +{ + "root": "dist/storybook" +} diff --git a/tools/x-docs/.gitignore b/tools/x-docs/.gitignore deleted file mode 100644 index a1610c548..000000000 --- a/tools/x-docs/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -.cache -/public diff --git a/tools/x-docs/gatsby-config.js b/tools/x-docs/gatsby-config.js deleted file mode 100644 index 36b851000..000000000 --- a/tools/x-docs/gatsby-config.js +++ /dev/null @@ -1,57 +0,0 @@ -module.exports = { - // The GitHub Pages deployment will be in this sub-folder - pathPrefix: '/x-dash', - polyfill: false, - siteMetadata: { - title: 'x-dash', - siteUrl: 'https://financial-times.github.io/x-dash/', - description: 'Shared front-end for FT.com and The App.' - }, - plugins: [ - { - resolve: 'gatsby-source-filesystem', - options: { - name: 'docs', - path: './src/data' - }, - }, - { - resolve: 'gatsby-source-filesystem', - options: { - name: 'docs', - path: '../../docs' - }, - }, - { - resolve: 'gatsby-source-filesystem', - options: { - name: 'components', - path: '../../components', - ignore: ['**/bower_components'] - }, - }, - { - resolve: 'gatsby-source-filesystem', - options: { - name: 'packages', - path: '../../packages', - ignore: ['**/bower_components'] - }, - }, - // Handles markdown files (creates "MarkdownRemark" nodes) - { - resolve: 'gatsby-transformer-remark', - options: { - plugins: [ - 'gatsby-remark-prismjs', - 'gatsby-remark-autolink-headers', - 'gatsby-remark-external-links' - ] - } - }, - // Handles package manifest files (creates "NpmPackage" nodes) - 'gatsby-transformer-npm-package', - // Handles YAML files (creates "YourFileNameYaml" nodes) - 'gatsby-transformer-yaml' - ] -}; diff --git a/tools/x-docs/gatsby-node.js b/tools/x-docs/gatsby-node.js deleted file mode 100644 index 93f3c2286..000000000 --- a/tools/x-docs/gatsby-node.js +++ /dev/null @@ -1,14 +0,0 @@ -const decorateNodes = require('./src/lib/decorate-nodes'); -const createNpmPackagePages = require('./src/lib/create-npm-package-pages'); -const createDocumentationPages = require('./src/lib/create-documentation-pages'); - -exports.onCreateNode = ({ node, actions, getNode }) => { - decorateNodes(node, actions, getNode); -}; - -exports.createPages = async ({ actions, graphql }) => { - return Promise.all([ - createNpmPackagePages(actions, graphql), - createDocumentationPages(actions, graphql) - ]); -}; diff --git a/tools/x-docs/package.json b/tools/x-docs/package.json deleted file mode 100644 index f072a35b4..000000000 --- a/tools/x-docs/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@financial-times/x-docs", - "private": true, - "version": "0.0.0", - "description": "", - "scripts": { - "build": "gatsby build --prefix-paths --verbose", - "start": "gatsby develop -H local.ft.com", - "test": "echo \"Error: no test specified\" && exit 1" - }, - "license": "ISC", - "devDependencies": { - "@financial-times/x-logo": "file:../../packages/x-logo", - "@financial-times/x-storybook": "file:../x-storybook", - "case": "^1.5.5", - "gatsby": "^2.0.11", - "gatsby-remark-autolink-headers": "^2.0.6", - "gatsby-remark-external-links": "0.0.4", - "gatsby-remark-prismjs": "^3.0.1", - "gatsby-source-filesystem": "^2.0.2", - "gatsby-transformer-remark": "^2.1.6", - "gatsby-transformer-yaml": "^2.1.1", - "graphql-type-json": "^0.2.1", - "prismjs": "^1.15.0", - "react": "^16.4.1", - "react-dom": "^16.4.1", - "react-helmet": "^5.2.0" - }, - "browserslist": [ - "> 1%", - "not ie 11" - ], - "x-dash": { - "engine": { - "server": "react", - "browser": "react" - } - } -} diff --git a/tools/x-docs/plugins/gatsby-transformer-npm-package/extend-node-type.js b/tools/x-docs/plugins/gatsby-transformer-npm-package/extend-node-type.js deleted file mode 100644 index f521d1b26..000000000 --- a/tools/x-docs/plugins/gatsby-transformer-npm-package/extend-node-type.js +++ /dev/null @@ -1,13 +0,0 @@ -const GraphQlJson = require('graphql-type-json'); - -// This allows us to fetch the entire manifest without specifying every field \0/ -module.exports = ({ type }) => { - if (type.name === 'NpmPackage') { - return { - manifest: { - type: GraphQlJson, - resolve: (node) => node.manifest - } - }; - } -}; diff --git a/tools/x-docs/plugins/gatsby-transformer-npm-package/gatsby-node.js b/tools/x-docs/plugins/gatsby-transformer-npm-package/gatsby-node.js deleted file mode 100644 index 8a140cc2a..000000000 --- a/tools/x-docs/plugins/gatsby-transformer-npm-package/gatsby-node.js +++ /dev/null @@ -1,3 +0,0 @@ -// This plugin will create new nodes for any package manifests found by the filesystem plugin -exports.setFieldsOnGraphQLNodeType = require('./extend-node-type'); -exports.onCreateNode = require('./on-create-node'); diff --git a/tools/x-docs/plugins/gatsby-transformer-npm-package/on-create-node.js b/tools/x-docs/plugins/gatsby-transformer-npm-package/on-create-node.js deleted file mode 100644 index 682ece288..000000000 --- a/tools/x-docs/plugins/gatsby-transformer-npm-package/on-create-node.js +++ /dev/null @@ -1,53 +0,0 @@ -const crypto = require('crypto'); - -const hash = (string) => crypto.createHash('md5').update(string).digest('hex'); - -module.exports = ({ node, actions }) => { - const { createNode, createParentChildLink } = actions; - - if (node.internal.type === 'File' && node.base === 'package.json') { - const json = require(node.absolutePath); - - // Assemble node information - const npmPackageNode = { - id: `${node.id} >>> NpmPackage`, - children: [], - parent: node.id, - internal: { - type: 'NpmPackage' - }, - manifest: json, - name: json.name, - private: Boolean(json.private), - // Mimic remark transformer - fileAbsolutePath: node.absolutePath - }; - - // Append unique node hash - npmPackageNode.internal.contentDigest = hash(JSON.stringify(npmPackageNode)); - - createNode(npmPackageNode); - createParentChildLink({ parent: node, child: npmPackageNode }); - } - - if (node.internal.type === 'File' && node.absolutePath.endsWith('stories/index.js')) { - const contents = require(node.absolutePath); - - const storiesNode = { - id: `${node.id} >>> Stories`, - children: [], - parent: node.id, - internal: { - type: 'Stories' - }, - stories: contents.stories.map((story) => story.title), - fileAbsolutePath: node.absolutePath - }; - - // Append unique node hash - storiesNode.internal.contentDigest = hash(JSON.stringify(storiesNode)); - - createNode(storiesNode); - createParentChildLink({ parent: node, child: storiesNode }); - } -}; diff --git a/tools/x-docs/plugins/gatsby-transformer-npm-package/package.json b/tools/x-docs/plugins/gatsby-transformer-npm-package/package.json deleted file mode 100644 index 25bb6271f..000000000 --- a/tools/x-docs/plugins/gatsby-transformer-npm-package/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "name": "gatsby-transformer-npm-package" -} diff --git a/tools/x-docs/src/components/footer/index.jsx b/tools/x-docs/src/components/footer/index.jsx deleted file mode 100644 index ec551bb5e..000000000 --- a/tools/x-docs/src/components/footer/index.jsx +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react'; - -const linkProps = { - rel: 'noopener noreferrer', - target: '_blank' -}; - -export default () => ( - <footer className="site-footer" role="contentinfo"> - <div className="site-footer__legal-links"> - <a href="http://help.ft.com/help/legal-privacy/cookies/" {...linkProps}> - Cookies - </a> - <a href="http://help.ft.com/help/legal-privacy/copyright/copyright-policy/" {...linkProps}> - Copyright - </a> - <a href="http://help.ft.com/help/legal-privacy/privacy/" {...linkProps}> - Privacy - </a> - <a href="http://help.ft.com/help/legal-privacy/terms-conditions" {...linkProps}> - Terms & Conditions - </a> - </div> - <div className="site-footer__related-links"> - <a href="https://github.com/financial-times/x-dash" {...linkProps}> - x-dash on GitHub - </a> - <a href="https://slack.com/messages/x/" {...linkProps}> - x-dash on Slack - </a> - </div> - <p className="site-footer__small-print"> - <small> - © THE FINANCIAL TIMES LTD 2018. FT and 'Financial Times' are trademarks of The Financial - Times Ltd - </small> - </p> - </footer> -); diff --git a/tools/x-docs/src/components/header/index.jsx b/tools/x-docs/src/components/header/index.jsx deleted file mode 100644 index 80cbb503f..000000000 --- a/tools/x-docs/src/components/header/index.jsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; -import { Link, withPrefix } from 'gatsby'; - -export default ({ showLogo }) => ( - <header className="site-header"> - <div className="site-header__masthead"> - <Link to="/">x-dash</Link> - </div> - {showLogo ? <img className="site-header__logo" src={withPrefix('/logo.png')} alt="" /> : null} - <nav role="navigation" className="site-header__menu"> - <Link to="/docs" activeClassName="is-active">Docs</Link> - <Link to="/components" activeClassName="is-active">Components</Link> - <Link to="/packages" activeClassName="is-active">Packages</Link> - </nav> - </header> -); diff --git a/tools/x-docs/src/components/icon/index.jsx b/tools/x-docs/src/components/icon/index.jsx deleted file mode 100644 index 410b79d98..000000000 --- a/tools/x-docs/src/components/icon/index.jsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; - -const slate = '#262A33'; - -const domain = 'https://www.ft.com/__origami/service/image/v2/images/raw/fticon-v1'; - -export default ({ icon, tint = slate, ...props }) => { - const iconUrl = `${domain}:${icon}?tint=${encodeURIComponent(tint)}&source=x-dash`; - return <img src={iconUrl} alt={icon} {...props} /> -}; diff --git a/tools/x-docs/src/components/layouts/basic.jsx b/tools/x-docs/src/components/layouts/basic.jsx deleted file mode 100644 index 3524e4dcb..000000000 --- a/tools/x-docs/src/components/layouts/basic.jsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import Helmet from 'react-helmet'; -import Header from '../header'; -import Footer from '../footer'; - -export default ({ title, children, sidebar }) => ( - <div className="basic-layout"> - <Helmet title={`${title} / x-dash`} /> - <div className="basic-layout__header"> - <Header showLogo={true} /> - </div> - <div className="basic-layout__content"> - {children} - </div> - <div className="basic-layout__sidebar"> - {sidebar} - </div> - <div className="basic-layout__footer"> - <Footer /> - </div> - </div> -); diff --git a/tools/x-docs/src/components/layouts/splash.jsx b/tools/x-docs/src/components/layouts/splash.jsx deleted file mode 100644 index 808f9beae..000000000 --- a/tools/x-docs/src/components/layouts/splash.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import Helmet from 'react-helmet'; -import Header from '../header'; -import Footer from '../footer'; - -export default ({ title, children }) => ( - <div className="splash-layout"> - <Helmet title={`${title} / x-dash`} /> - <div className="splash-layout__header"> - <Header /> - </div> - <main className="splash-layout__hero" role="main"> - {children} - </main> - <div className="splash-layout__footer"> - <Footer /> - </div> - </div> -); diff --git a/tools/x-docs/src/components/module-list/index.jsx b/tools/x-docs/src/components/module-list/index.jsx deleted file mode 100644 index 44d79e027..000000000 --- a/tools/x-docs/src/components/module-list/index.jsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -import { Link } from 'gatsby'; - -export default ({ items }) => ( - <ul className="module-list"> - {items.map(({ node }, i) => ( - <li key={`module-list-${i}`} className="module-list__item"> - <Link to={node.path} className="module-list__link"> - <h2 className="module-list__heading">{node.context.packageName}</h2> - <p className="module-list__description">{node.context.packageDescription}</p> - </Link> - </li> - ))} - </ul> -); diff --git a/tools/x-docs/src/components/sidebar/module-menu.jsx b/tools/x-docs/src/components/sidebar/module-menu.jsx deleted file mode 100644 index ba1924858..000000000 --- a/tools/x-docs/src/components/sidebar/module-menu.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import { Link } from 'gatsby'; - -export default ({ heading, items }) => ( - <div className="site-sidebar"> - <ul className="site-sidebar__list site-sidebar__list--sticky"> - <li className="site-sidebar__item site-sidebar__item--heading"> - {heading} - </li> - {items.map(({ node }, i) => ( - <li key={`module-menu-${i}`} className="site-sidebar__item"> - <Link to={node.path} exact activeClassName="is-active"> - {node.context.title} - </Link> - </li> - ))} - </ul> - </div> -); diff --git a/tools/x-docs/src/components/sidebar/pages-menu.jsx b/tools/x-docs/src/components/sidebar/pages-menu.jsx deleted file mode 100644 index cb8b78481..000000000 --- a/tools/x-docs/src/components/sidebar/pages-menu.jsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import { Link } from 'gatsby'; - -const Group = ({ heading, items }) => ( - <> - <li className="site-sidebar__item site-sidebar__item--heading"> - {heading} - </li> - {items.map((item, i) => ( - <li key={`link-${i}`} className="site-sidebar__item"> - <Link to={item.link} exact activeClassName="is-active"> - {item.title} - </Link> - </li> - ))} - </> -); - -export default ({ data }) => ( - <div className="site-sidebar"> - <ul className="site-sidebar__list"> - {data.map(({ node }, i) => ( - <Group key={`section-${i}`} heading={node.title} items={node.items} /> - ))} - </ul> - </div> -); diff --git a/tools/x-docs/src/components/story-viewer/index.jsx b/tools/x-docs/src/components/story-viewer/index.jsx deleted file mode 100644 index 109e1380b..000000000 --- a/tools/x-docs/src/components/story-viewer/index.jsx +++ /dev/null @@ -1,52 +0,0 @@ -import React from 'react'; -import { withPrefix } from 'gatsby'; - -class StoryViewer extends React.Component { - constructor(props) { - super(props); - - this.state = { - selected: 0 - }; - } - - onClick(index) { - if (this.state.selected !== index) { - this.setState({ selected: index }); - } - } - - render() { - const story = this.props.stories[this.state.selected]; - const queryString = `?selectedKind=${this.props.name}&selectedStory=${story}`; - const iframeUrl = withPrefix(`/storybook/iframe.html${queryString}`); - const linkUrl = withPrefix(`/storybook/index.html${queryString}`); - - return ( - <div id="component-demos" className="story-viewer"> - <h2 className="story-viewer__heading">Component demos</h2> - <ul className="story-viewer__list" role="tablist"> - {this.props.stories.map((story, i) => ( - <li key={`story-${i}`} className="story-viewer__item"> - <button - role="tab" - className="story-viewer__button" - aria-selected={this.state.selected === i} - onClick={this.onClick.bind(this, i)}> - {story} - </button> - </li> - ))} - </ul> - <div className="story-viewer__panel" role="tabpanel"> - <iframe title={`${story} demo`} src={iframeUrl}></iframe> - </div> - <p className="story-viewer__footer"> - <a href={linkUrl}>View in Storybook</a> - </p> - </div> - ); - } -} - -export default StoryViewer; diff --git a/tools/x-docs/src/components/tertiary/links.jsx b/tools/x-docs/src/components/tertiary/links.jsx deleted file mode 100644 index f1ed0c28b..000000000 --- a/tools/x-docs/src/components/tertiary/links.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import { Link } from 'gatsby'; - -const linkProps = { - rel: 'noopener noreferrer', - target: '_blank' -}; - -export default ({ name, manifest, storybook }) => ( - <div className="tertiary-menu"> - <p className="tertiary-menu__heading">Quick links:</p> - <ul className="tertiary-menu__list"> - <li className="tertiary-menu__item"> - <a href={`https://www.npmjs.com/package/${manifest.name}`} {...linkProps}> - NPM - </a> - </li> - <li className="tertiary-menu__item"> - <a href={manifest.homepage} {...linkProps}> - GitHub - </a> - </li> - {storybook ? ( - <li className="tertiary-menu__item"> - <Link to={`/storybook/index.html?selectedKind=${name}`} {...linkProps}> - Storybook - </Link> - </li> - ) : null} - </ul> - </div> -); diff --git a/tools/x-docs/src/components/tertiary/subheadings.jsx b/tools/x-docs/src/components/tertiary/subheadings.jsx deleted file mode 100644 index fa7d402e7..000000000 --- a/tools/x-docs/src/components/tertiary/subheadings.jsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react'; -import GithubSlugger from 'github-slugger'; - -const createHash = (name) => { - // This is the same module as used by gatsby-remark-autolink-headers - const slugger = new GithubSlugger(); - // This module checks if it is duplicating created URLs, like this: - // slugger.slug('url one') // url-one - // slugger.slug('url one') // url-one-2 - // Therefore we need to create a new class instance every time the function is applied - return '#' + slugger.slug(name); -}; - -const scrollOnClick = (e) => { - e.preventDefault(); - - const target = document.querySelector(e.currentTarget.hash); - - target && - target.scrollIntoView({ - behavior: 'smooth' - }); -}; - -export default ({ items, demos = false, minDepth = 2, maxDepth = 3 }) => { - const headings = items.filter((item) => item.depth >= minDepth && item.depth <= maxDepth); - - if (headings.length === 0) { - // You must explicitly return null for empty nodes - return null; - } - - return ( - <div className="tertiary-menu"> - <p className="tertiary-menu__heading">On this page:</p> - <ul className="tertiary-menu__list"> - {headings.map((item, i) => ( - <li - key={`headings-${i}`} - className="tertiary-menu__item" - style={{ paddingLeft: item.depth - minDepth + 'em' }}> - <a href={createHash(item.value)} onClick={scrollOnClick}> - {item.value} - </a> - </li> - ))} - {demos ? ( - <li className="tertiary-menu__item"> - <a href="#component-demos" onClick={scrollOnClick}>Component demos</a> - </li> - ) : null} - </ul> - </div> - ); -}; diff --git a/tools/x-docs/src/data/docs-menu.yml b/tools/x-docs/src/data/docs-menu.yml deleted file mode 100644 index 84ea35915..000000000 --- a/tools/x-docs/src/data/docs-menu.yml +++ /dev/null @@ -1,39 +0,0 @@ -- title: Get started - items: - - title: What is x-dash? - link: /docs/get-started/what-is-x-dash - - title: Installation - link: /docs/get-started/installation - - title: Working with x-dash - link: /docs/get-started/working-with-x-dash - # TODO - # - title: Thinking in x-dash - # link: /docs/get-started/thinking-in-x-dash -- title: Components in-depth - items: - - title: Overview - link: /docs/components/overview - - title: Creating components - link: /docs/components/creation - - title: JavaScript - link: /docs/components/javascript - - title: Styling - link: /docs/components/styling - - title: Stories - link: /docs/components/stories - - title: Interactions - link: /docs/components/interactions - - title: Testing - link: /docs/components/testing -- title: Integrating x-dash - items: - - title: Using components - link: /docs/integration/using-components - - title: Local development - link: /docs/integration/local-development -- title: Guides - items: - - title: Migrating to x-teaser - link: /docs/guides/migrating-to-x-teaser -# - title: Recipes?? - # This section could be for answering common questions and problems diff --git a/tools/x-docs/src/html.jsx b/tools/x-docs/src/html.jsx deleted file mode 100644 index f28220fca..000000000 --- a/tools/x-docs/src/html.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; -import { withPrefix } from 'gatsby'; - -export default class HTML extends React.Component { - render() { - return ( - <html {...this.props.htmlAttributes} lang="en"> - <head> - {this.props.headComponents} - <meta charSet="utf-8" /> - <meta httpEquiv="X-UA-Compatible" content="IE=edge" /> - <meta - name="viewport" - content="width=device-width, initial-scale=1.0, viewport-fit=cover" - /> - <script src="https://cdn.polyfill.io/v2/polyfill.min.js"></script> - <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Lato:400,700" /> - <link rel="stylesheet" href={withPrefix('/main.css')} /> - <link rel="stylesheet" href={withPrefix('/prism.css')} /> - <link rel="icon" href="/favicon.ico" /> - </head> - <body {...this.props.bodyAttributes}> - <div id="___gatsby" dangerouslySetInnerHTML={{ __html: this.props.body }} /> - {this.props.postBodyComponents} - </body> - </html> - ); - } -} diff --git a/tools/x-docs/src/lib/create-documentation-pages.js b/tools/x-docs/src/lib/create-documentation-pages.js deleted file mode 100644 index dfccb1c64..000000000 --- a/tools/x-docs/src/lib/create-documentation-pages.js +++ /dev/null @@ -1,57 +0,0 @@ -const path = require('path'); -const Case = require('case'); - -const findPageTitle = (node) => { - if (node.frontmatter.title) { - return node.frontmatter.title; - } - - if (node.headings.some((heading) => heading.depth === 1)) { - return node.headings.find((heading) => heading.depth === 1).value; - } - - // HACK: use the file name as a last resort - return Case.title(path.basename(node.fields.slug)); -}; - -module.exports = async (actions, graphql) => { - const result = await graphql(` - query { - allMarkdownRemark(filter: { fields: { source: { eq: "docs" } } }) { - edges { - node { - id - fields { - slug - source - } - headings { - value - depth - } - frontmatter { - title - } - } - } - } - } - `); - - if (result.errors) { - throw result.errors; - } - - result.data.allMarkdownRemark.edges.map(({ node }) => { - actions.createPage({ - component: path.resolve('src/templates/documentation-page.jsx'), - path: node.fields.slug, - // the context object is passed to the template pageQuery - context: { - type: 'documentation-page', - source: node.fields.source, - title: findPageTitle(node) - } - }); - }); -}; diff --git a/tools/x-docs/src/lib/create-npm-package-pages.js b/tools/x-docs/src/lib/create-npm-package-pages.js deleted file mode 100644 index a8e5cba60..000000000 --- a/tools/x-docs/src/lib/create-npm-package-pages.js +++ /dev/null @@ -1,47 +0,0 @@ -const path = require('path'); - -module.exports = async (actions, graphql) => { - const result = await graphql(` - query { - npmPackages: allNpmPackage(filter: { private: { eq: false } }) { - edges { - node { - name - manifest - fields { - slug - source - } - } - } - } - } - `); - - if (result.errors) { - throw result.errors; - } - - result.data.npmPackages.edges.map(({ node }) => { - // Package manifest slug will be /package so remove it - const pagePath = path.dirname(node.fields.slug); - - actions.createPage({ - component: path.resolve('src/templates/npm-package.jsx'), - // Remove the file name from the slug - path: pagePath, - // Data passed to context is available in page queries as GraphQL variables. - context: { - type: `npm-package-${node.fields.source}`, - title: node.name.replace('@financial-times/', ''), - source: node.fields.source, - packageName: node.manifest.name, - packageDescription: node.manifest.description, - // Associate readme and story nodes via slug - storiesPath: path.join(pagePath, 'stories'), - packagePath: path.join(pagePath, 'package'), - readmePath: path.join(pagePath, 'readme') - } - }); - }); -}; diff --git a/tools/x-docs/src/lib/decorate-nodes.js b/tools/x-docs/src/lib/decorate-nodes.js deleted file mode 100644 index bb9ab50d1..000000000 --- a/tools/x-docs/src/lib/decorate-nodes.js +++ /dev/null @@ -1,32 +0,0 @@ -const path = require('path'); - -const nodeTypesToSlug = new Set(['MarkdownRemark', 'NpmPackage', 'Stories']); - -const repoRoot = path.resolve('../../'); - -const createSlug = (file) => { - const pathFromRoot = path.relative(repoRoot, file.absolutePath); - const { dir, name } = path.parse(pathFromRoot); - - // If the file is an index file then use the parent directory name - return path.join(dir, name === 'index' ? '' : name).toLowerCase(); -}; - -module.exports = (node, actions, getNode) => { - if (nodeTypesToSlug.has(node.internal.type)) { - const file = getNode(node.parent); - - // Group files by source type (currently: docs, components, packages) - actions.createNodeField({ - node, - name: 'source', - value: file.sourceInstanceName - }); - - actions.createNodeField({ - node, - name: 'slug', - value: '/' + createSlug(file) - }); - } -}; diff --git a/tools/x-docs/src/pages/components.jsx b/tools/x-docs/src/pages/components.jsx deleted file mode 100644 index 1572dfb49..000000000 --- a/tools/x-docs/src/pages/components.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import { graphql } from 'gatsby'; -import Layout from '../components/layouts/basic'; -import Sidebar from '../components/sidebar/module-menu'; -import ModuleList from '../components/module-list'; - -export const query = graphql` - query { - modules: allSitePage( - filter: { context: { type: { eq: "npm-package-components" } } } - ) { - edges { - node { - path - context { - title - packageName - packageDescription - } - } - } - } - } -`; - -export default ({ data }) => ( - <Layout title="Components" sidebar={<Sidebar heading="Components" items={data.modules.edges} />}> - <main className="content-container" role="main"> - <h1>Components</h1> - <ModuleList items={data.modules.edges} /> - </main> - </Layout> -); diff --git a/tools/x-docs/src/pages/index.jsx b/tools/x-docs/src/pages/index.jsx deleted file mode 100644 index e7c4cfd3a..000000000 --- a/tools/x-docs/src/pages/index.jsx +++ /dev/null @@ -1,66 +0,0 @@ -import React from 'react'; -import { Link } from 'gatsby'; -import Icon from '../components/icon'; -import Layout from '../components/layouts/splash'; -import XLogo from '@financial-times/x-logo'; - -export default () => ( - <Layout title="Welcome"> - <div className="hero"> - <div className="content-container"> - <div className="hero__container"> - <div className="hero__logo"> - <XLogo /> - </div> - <div className="hero__content"> - <h1 className="hero__heading">x-dash</h1> - <p className="hero__description">Shared front-end for FT.com and The App.</p> - <Link to="/docs" className="button button--inverse">Get started</Link> - </div> - </div> - </div> - </div> - <div className="content-container"> - <div className="intro"> - <div className="intro__section"> - <h2 className="intro__heading">For FT.com developers</h2> - <ul className="intro__list"> - <li className="intro__item"> - <Icon className="intro__icon" icon="newspaper" /> - No more copy-and-pasting templates. Import components with well-defined, explorable - use-cases. - </li> - <li className="intro__item"> - <Icon className="intro__icon" icon="link" /> - Works with the renderer and build tooling you're already using, no need for glue code. - </li> - <li className="intro__item"> - <Icon className="intro__icon" icon="list" /> - Components are logic-less, with denormalised data stored in Elasticsearch, so apps are - faster and simpler. - </li> - </ul> - </div> - <div className="intro__section"> - <h2 className="intro__heading">For component authors</h2> - <ul className="intro__list"> - <li className="intro__item"> - <Icon className="intro__icon" icon="video" /> - Live-editable preview of every component without the headache of setting up a - development server. - </li> - <li className="intro__item"> - <Icon className="intro__icon" icon="users" /> - Write a component once, and it works across every app already using x-dash. - </li> - <li className="intro__item"> - <Icon className="intro__icon" icon="download" /> - Get set up for development quickly. Components and build tools live in a unified - monorepo. - </li> - </ul> - </div> - </div> - </div> - </Layout> -); diff --git a/tools/x-docs/src/pages/packages.jsx b/tools/x-docs/src/pages/packages.jsx deleted file mode 100644 index 9fb63d4f8..000000000 --- a/tools/x-docs/src/pages/packages.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import { graphql } from 'gatsby'; -import Layout from '../components/layouts/basic'; -import Sidebar from '../components/sidebar/module-menu'; -import ModuleList from '../components/module-list'; - -export const query = graphql` - query { - modules: allSitePage( - filter: { context: { type: { eq: "npm-package-packages" } } } - ) { - edges { - node { - path - context { - title - packageName - packageDescription - } - } - } - } - } -`; - -export default ({ data }) => ( - <Layout title="Packages" sidebar={<Sidebar heading="Packages" items={data.modules.edges} />}> - <main className="content-container" role="main"> - <h1>Packages</h1> - <ModuleList items={data.modules.edges} /> - </main> - </Layout> -); diff --git a/tools/x-docs/src/templates/documentation-page.jsx b/tools/x-docs/src/templates/documentation-page.jsx deleted file mode 100644 index b1c179674..000000000 --- a/tools/x-docs/src/templates/documentation-page.jsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { graphql } from 'gatsby'; -import Layout from '../components/layouts/basic'; -import Sidebar from '../components/sidebar/pages-menu'; -import Subheadings from '../components/tertiary/subheadings'; - -export default ({ pageContext, data }) => ( - <Layout title={pageContext.title} sidebar={<Sidebar data={data.menu.edges} />}> - <div className="content-layout"> - <main className="content-layout__main" role="main"> - <div className="content-layout__main-inner"> - <div className="markdown" dangerouslySetInnerHTML={{ __html: data.markdown.html }} /> - </div> - </main> - <div className="content-layout__tertiary"> - <div className="content-layout__tertiary-inner"> - <Subheadings items={data.markdown.headings} /> - </div> - </div> - </div> - </Layout> -); - -export const pageQuery = graphql` - query($path: String!) { - markdown: markdownRemark(fields: { slug: { eq: $path } }) { - html - headings { - value - depth - } - } - menu: allDocsMenuYaml { - edges { - node { - title - items { - title - link - } - } - } - } - } -`; diff --git a/tools/x-docs/src/templates/npm-package.jsx b/tools/x-docs/src/templates/npm-package.jsx deleted file mode 100644 index 0be87644a..000000000 --- a/tools/x-docs/src/templates/npm-package.jsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from 'react'; -import { graphql } from 'gatsby'; -import Layout from '../components/layouts/basic'; -import Sidebar from '../components/sidebar/module-menu'; -import Subheadings from '../components/tertiary/subheadings'; -import Links from '../components/tertiary/links'; -import StoryViewer from '../components/story-viewer'; - -export default ({ pageContext, data, location }) => ( - <Layout - title={pageContext.title} - sidebar={ - <Sidebar - heading={pageContext.source} - items={data.modules.edges} - location={location.pathname} - /> - }> - <div className="content-layout"> - <main className="content-layout__main" role="main"> - <div className="content-layout__main-inner"> - <div className="markdown" dangerouslySetInnerHTML={{ __html: data.markdown.html }} /> - {data.storybook ? ( - <StoryViewer name={pageContext.title} stories={data.storybook.stories} /> - ) : null} - </div> - </main> - <div className="content-layout__tertiary"> - <div className="content-layout__tertiary-inner"> - <Links - name={pageContext.title} - manifest={data.npm.manifest} - storybook={Boolean(data.storybook)} - /> - <Subheadings items={data.markdown.headings} demos={Boolean(data.storybook)} /> - </div> - </div> - </div> - </Layout> -); - -export const pageQuery = graphql` - query($type: String!, $packagePath: String!, $readmePath: String!, $storiesPath: String!) { - npm: npmPackage(fields: { slug: { eq: $packagePath } }) { - manifest - } - markdown: markdownRemark(fields: { slug: { eq: $readmePath } }) { - html - headings { - value - depth - } - } - storybook: stories(fields: { slug: { eq: $storiesPath } }) { - stories - } - modules: allSitePage(filter: { context: { type: { eq: $type } } }) { - edges { - node { - path - context { - title - } - } - } - } - } -`; diff --git a/tools/x-docs/static/favicon.ico b/tools/x-docs/static/favicon.ico deleted file mode 100644 index 069b77b91..000000000 Binary files a/tools/x-docs/static/favicon.ico and /dev/null differ diff --git a/tools/x-docs/static/logo.png b/tools/x-docs/static/logo.png deleted file mode 100644 index 9d88c0d15..000000000 Binary files a/tools/x-docs/static/logo.png and /dev/null differ diff --git a/tools/x-docs/static/main.css b/tools/x-docs/static/main.css deleted file mode 100644 index cd809c1e4..000000000 --- a/tools/x-docs/static/main.css +++ /dev/null @@ -1,743 +0,0 @@ -:root { - /* primary colors */ - --o-colors-paper: #fff1e5; - --o-colors-black: #000000; - --o-colors-white: #ffffff; - --o-colors-claret: #990f3d; - --o-colors-oxford: #0f5499; - --o-colors-teal: #0d7680; - - /* secondary colors */ - --o-colors-wheat: #f2dfce; - --o-colors-sky: #cce6ff; - --o-colors-slate: #262a33; - --o-colors-velvet: #593380; - --o-colors-mandarin: #ff8833; - --o-colors-lemon: #ffec1a; - - /* tertiary colors */ - --o-colors-candy: #ff7faa; - --o-colors-wasabi: #96cc28; - --o-colors-jade: #00b359; - --o-colors-crimson: #cc0000; - - /* color shades */ - --o-colors-claret-30: #4d081f; - --o-colors-claret-40: #660a29; - --o-colors-claret-50: #800d33; - --o-colors-claret-60: #990f3d; - --o-colors-claret-70: #b31247; - --o-colors-claret-80: #cc1452; - --o-colors-claret-90: #e6175c; - --o-colors-claret-100: #ff1a66; - --o-colors-oxford-30: #082a4d; - --o-colors-oxford-40: #0a3866; - --o-colors-oxford-50: #0d4680; - --o-colors-oxford-60: #0f5499; - --o-colors-oxford-70: #1262b3; - --o-colors-oxford-80: #1470cc; - --o-colors-oxford-90: #177ee6; - --o-colors-oxford-100: #1a8cff; - --o-colors-teal-20: #052f33; - --o-colors-teal-30: #08474d; - --o-colors-teal-40: #0a5e66; - --o-colors-teal-50: #0d7680; - --o-colors-teal-60: #0f8e99; - --o-colors-teal-70: #12a5b3; - --o-colors-teal-80: #14bdcc; - --o-colors-teal-90: #17d4e6; - --o-colors-teal-100: #1aecff; - - /* color washes (not really from o-colors) */ - --o-colors-black-wash: #ebf2f0; - --o-colors-claret-wash: #fbf5f7; - --o-colors-oxford-wash: #f9fbfd; - --o-colors-teal-wash: #f3f8f9; - - /* font-stacks */ - --font-sans: Lato, -apple-system, BlinkMacSystemFont, sans-serif; - --font-mono: "Space Mono", Menlo, Monaco, Consolas, monospace; - - /* breakpoints (can't be specified with custom properties so only for reference */ - --break-small: 50em; - --break-large: 70em; - - /* containers */ - --container-small: 360px; - --container-readable: 760px; - --container-large: 960px; -} - - -/* Globals */ -body { - margin: 0; - color: #24292e; - font-family: var(--font-sans); - font-size: 16px; - line-height: 1.5; - -ms-text-size-adjust: 100%; - -webkit-text-size-adjust: 100%; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - /* name specific properties to avoid animating the focus outline */ - transition: color 0.1s, background-color 0.1s, border-color 0.1s, opacity 0.1s; - color: var(--o-colors-teal); - text-decoration: none; -} - -a:hover, -a:focus { - color: var(--o-colors-teal-80); -} - -a:focus { - outline: 2px solid var(--o-colors-teal-100); - outline-offset: 2px; -} - -img { - border: 0; - max-width: 100%; -} - - -/* Preformatted text and code */ -pre, -code { - tab-size: 2; - hyphens: none; - font-size: 0.875rem; - font-family: var(--font-mono); -} - -pre { - background: var(--o-colors-teal-wash); - padding: 0.5rem 0.75rem; - overflow: auto; -} - -pre code { - display: block; - white-space: pre; -} - -:not(pre) > code { - padding: 0 0.25em; - color: var(--o-colors-claret-60); - background: var(--o-colors-claret-wash); -} - -table { - outline: 3px solid var(--o-colors-black-wash); - border: 1px solid #ddd; - border-collapse: collapse; -} - -thead th { - background: var(--o-colors-black-wash); -} - -th { - text-align: left; -} - -th, -td { - padding: 0.25rem 0.5rem; - border: 1px solid #ddd; - border-top: 0; - border-bottom: 0; -} - -th:first-child, -td:first-child { - border-left: 0; -} - -/* Buttons */ -.button { - display: inline-block; - padding: 0.75rem 1.25rem; - border: 2px solid; - font: inherit; - font-weight: bold; - color: var(--o-colors-teal); - background: none; -} - -.button:hover, -.button:focus { - color: var(--o-colors-teal-80); -} - -.button--inverse { - color: var(--o-colors-white); - background: transparent; -} - -.button--inverse:hover, -.button--inverse:focus { - color: var(--o-colors-white); - background: rgba(255, 255, 255, 0.25); -} - -/* Re-usable layout components */ -.content-container { - max-width: var(--container-small); - margin-left: auto; - margin-right: auto; - padding-left: 1rem; - padding-right: 1rem; -} - -@media screen and (min-width: 50em) { - .content-container { - max-width: var(--container-large); - } -} - - -/* Splash layout */ -.splash-layout { - display: grid; - /* 100% height layout all the time */ - min-height: 100vh; - grid-template-rows: auto 1fr auto; -} - -/* Basic layout */ -.basic-layout { - display: grid; - min-height: 100vh; - grid-template-rows: auto 1fr auto; - /* "auto" = max content, so nothing will wrap without specifying a width */ - grid-template-columns: 100%; -} - -.basic-layout__header {} - -.basic-layout__content {} - -.basic-layout__sidebar { - margin-top: 1rem; -} - -.basic-layout__footer {} - -@media screen and (min-width: 50em) { - .basic-layout { - grid-template-columns: 1fr 4fr; - grid-template-areas: "header header" "sidebar main" "footer footer"; - } - - .basic-layout__header { - grid-area: header; - } - - .basic-layout__content { - grid-area: main; - } - - .basic-layout__sidebar { - grid-area: sidebar; - margin-top: 0; - } - - .basic-layout__footer { - grid-area: footer; - } -} - -@media screen and (min-width: 70em) { - .basic-layout { - grid-template-columns: 1fr 5fr; - } -} - -/* Header */ -.site-header { - position: relative; - color: var(--o-colors-white); - background: var(--o-colors-slate); - text-align: center; -} - -.site-header__masthead { - padding: 0.5rem 0.75rem; - font-size: 1.25rem; - font-weight: bold; - text-transform: uppercase; -} - -.site-header__masthead a { - color: inherit; -} - -.site-header__logo { - display: none; -} - -.site-header__menu { - display: flex; -} - -.site-header__menu a { - flex-grow: 1; - padding: 0.5rem 0.75rem; - font-size: 0.875rem; - font-weight: bold; - text-transform: uppercase; - color: inherit; - opacity: 0.65; -} - -.site-header__menu a:hover, -.site-header__menu a:focus, -.site-header__menu a.is-active { - opacity: 1; -} - -.site-header__menu a.is-active { - color: var(--o-colors-teal-100); -} - -@media screen and (min-width: 50em) { - .site-header { - display: flex; - align-items: center; - text-align: initial; - padding: 0.75rem 1.25rem; - } - - .site-header__masthead { - padding: 0; - } - - .site-header__logo { - display: initial; - position: absolute; - width: 30px; - left: 50%; - margin-left: 15px; - } - - .site-header__menu { - margin-left: auto; - } - - .site-header__menu a { - padding: 0; - margin-left: 1rem; - } -} - - -/* Sidebar */ -.site-sidebar { - box-sizing: border-box; - min-height: 100%; - padding: 0.75rem 0.5rem; - background: var(--o-colors-teal-wash); - border-right: 1px solid rgba(0, 0, 0, 0.05); -} - -.site-sidebar__list { - margin: 0; - padding: 0; -} - -.site-sidebar__item { - display: block; - margin-top: 0.25rem; -} - -.site-sidebar__item:first-child { - margin-top: 0; -} - -.site-sidebar__item--heading { - margin: 1.25rem 0 0.5rem; - font-weight: bold; -} - -.site-sidebar__item a { - color: var(--o-colors-black); - opacity: 0.65; -} - -.site-sidebar__item a:hover, -.site-sidebar__item a:focus, -.site-sidebar__item a.is-active { - opacity: 1; -} - -.site-sidebar__item a.is-active { - color: var(--o-colors-teal); -} - -@media screen and (min-width: 50em) { - .site-sidebar { - padding: 1.5rem 1.25rem; - } -} - - -/* Site footer */ -.site-footer { - padding: 1.25rem 1rem 1.5rem; - font-size: 0.75rem; - text-align: center; - color: var(--o-colors-white); - background: var(--o-colors-slate); -} - -.site-footer a { - margin-left: 0.75rem; - color: var(--o-colors-teal-100); -} - -.site-footer a:first-child { - margin-left: 0; -} - -.site-footer__legal-links {} - -.site-footer__related-links {} - -.site-footer__small-print { - margin: 0.5rem 0 0; -} - -.site-footer__small-print small { - font-size: inherit; -} - -@media screen and (min-width: 50em) { - .site-footer { - display: flex; - flex-wrap: wrap; - text-align: left; - font-size: 0.875rem; - padding-left: 1.25rem; - padding-right: 1.25rem; - } - - .site-footer__related-links { - margin-left: auto; - } - - .site-footer__small-print { - width: 100%; - } -} - - -/* Content layout */ -.content-layout {} - -.content-layout__main {} - -.content-layout__main-inner { - margin: 0 auto 2rem; - padding-left: 1rem; - padding-right: 1rem; - max-width: var(--container-readable); -} - -.content-layout__tertiary { - display: none; -} - -.content-layout__tertiary-inner { - margin: 1.5rem 0; - padding: 0.25rem 1rem; - border-left: 2px solid rgba(0, 0, 0, 0.1); -} - -@media screen and (min-width: 70em) { - .content-layout { - display: grid; - grid-template-columns: auto 220px; - } - - .content-layout__tertiary { - display: block; - } - - .content-layout__tertiary-inner { - position: sticky; - top: 0; - } -} - - -/* Tertiary menu */ -.tertiary-menu { - margin-top: 1rem; - font-size: 0.875rem; - background: var(--o-colors-white); -} - -.tertiary-menu:first-child { - margin-top: 0; -} - -.tertiary-menu__heading { - margin: 0; - font-weight: bold; -} - -.tertiary-menu__list { - padding: 0; - margin: 0.25rem 0; -} - -.tertiary-menu__item { - display: block; - margin-top: 0.25rem; -} - -.tertiary-menu__item:first-child { - margin-top: 0; -} - -/* Hero */ -.hero { - padding: 2.5rem 0; - color: var(--o-colors-white); - background: var(--o-colors-slate); -} - -.hero__container {} - -.hero__logo { - /* Chrome requires explicit size to render implicit height */ - width: 100%; - margin: auto; -} - -.hero__content { - max-width: 480px; - margin: auto; - text-align: center; -} - -.hero__heading { - font-size: 2rem; - line-height: 1; - text-transform: uppercase; -} - -.hero__description { - margin: 1.25rem 0; - font-size: 1.25rem; -} - -@media screen and (min-width: 50em) { - .hero { - padding: 3.75rem 0; - } - - .hero__container { - display: grid; - grid-gap: 1.25rem; - grid-template-columns: 1fr 2fr; - } - - .hero__content { - text-align: left; - } - - .hero__heading { - margin: 0; - font-size: 3rem; - } - - .hero__description { - font-size: 1.5rem; - } -} - - -/* Intro */ -.intro { - margin: 1rem 0; -} - -.intro__section {} - -.intro__heading { - margin: 0; - font-size: 1.25rem; -} - -.intro__list { - margin: 1.25rem 0; - padding: 0; -} - -.intro__item { - display: block; - margin-top: 1.5rem; - /* encapsulate floating icon */ - overflow: hidden; -} - -.intro__icon { - float: left; - width: 50px; - margin-right: 0.75rem; -} - -@media screen and (min-width: 50em) { - .intro { - display: grid; - grid-gap: 1.25rem; - grid-template-columns: 1fr 1fr; - margin: 2.5rem 0; - } - - .intro__heading { - font-size: 1.5rem; - } - - .intro__item { - margin-top: 1.5rem; - } -} - - -/* Module list */ -.module-list { - margin: 0; - padding: 0; - list-style: none; -} - -.module-list__item { - margin-bottom: 1.25rem; -} - -.module-list__link { - display: block; - padding: 0.75rem 1rem; - background: var(--o-colors-teal-wash); - border: 1px solid rgba(0, 0, 0, 0.05); -} - -.module-list__link:hover, -.module-list__link:focus { - border-color: var(--o-colors-teal-70); -} - -.module-list__heading { - margin: 0 0 0.5rem; - font-size: 1rem; - font-weight: normal; -} - -.module-list__description { - margin: 0; - color: var(--o-colors-slate); -} - -@media screen and (min-width: 50em) { - .module-list { - display: grid; - grid-gap: 1.25rem; - grid-template-columns: 1fr 1fr; - } - - .module-list__link { - box-sizing: border-box; - height: 100%; - padding: 1rem 1.25rem; - } - - .module-list__heading { - margin: 0; - font-size: 1.25rem; - } -} - -/* Storybook viewer */ -.story-viewer { - margin-top: 2.5rem; - border-top: 2px solid rgba(0, 0, 0, 0.1); -} - -.story-viewer__header { - margin: 1.5rem 0; -} - -.story-viewer__list { - margin: 1.5rem 0; - display: flex; - list-style: none; - padding-left: 0; -} - -.story-viewer__item { - margin-left: 0.5rem; -} - -.story-viewer__item:first-child { - margin-left: 0; -} - -.story-viewer__button { - padding: 0.25rem 0.5rem; - font: inherit; - font-size: 0.95rem; - border: 1px solid; - color: var(--o-colors-oxford); - background: none; - cursor: pointer; -} - -.story-viewer__button:hover, -.story-viewer__button:focus { - color: var(--o-colors-oxford-100); -} - -.story-viewer__button[aria-selected=true] { - border: 1px solid transparent; - color: var(--o-colors-white); - background: var(--o-colors-oxford); -} - -.story-viewer__panel { - height: 20rem; - resize: vertical; - overflow: auto; -} - -.story-viewer__panel iframe { - display: block; - width: 100%; - height: 100%; - border: 0; -} - -.story-viewer__footer { - margin: 0.5rem 0 1.5rem; - text-align: right; -} - -.story-viewer__footer a { - font-size: 0.875rem; - color: var(--o-colors-oxford); -} - -.story-viewer__footer a:hover, -.story-viewer__footer a:focus { - color: var(--o-colors-oxford-100); -} - -.story-viewer__footer a:after { - content: ' →'; -} diff --git a/tools/x-docs/static/prism.css b/tools/x-docs/static/prism.css deleted file mode 100644 index 4e8016d94..000000000 --- a/tools/x-docs/static/prism.css +++ /dev/null @@ -1,57 +0,0 @@ -.token.punctuation, -.token.prolog, -.token.doctype, -.token.cdata { - color: var(--o-colors-black); -} - -.token.comment { - color: #999; -} - -.token.operator { - color: var(--o-colors-mandarin); -} - -.token.boolean, -.token.number { - color: var(--o-colors-velvet); -} - -.token.regex, -.token.attr-value, -.token.string, -.token.property, -.token.url { - color: var(--o-colors-oxford-90); -} - -.token.selector, -.token.tag { - color: var(--o-colors-teal); -} - -.token.function, -.token.inserted { - color: var(--o-colors-jade); -} - -.token.attr-name, -.token.rule, -.token.keyword, -.token.important, -.token.deleted { - color: var(--o-colors-claret); -} - -.token.bold { - font-weight: bold; -} - -.token.italic { - font-style: italic; -} - -.token.entity { - cursor: help; -} diff --git a/tools/x-docs/static/storybook b/tools/x-docs/static/storybook deleted file mode 120000 index 3e0bb31d9..000000000 --- a/tools/x-docs/static/storybook +++ /dev/null @@ -1 +0,0 @@ -../node_modules/@financial-times/x-storybook/dist/storybook/ \ No newline at end of file diff --git a/tools/x-ssr-demo/index.js b/tools/x-ssr-demo/index.js deleted file mode 100644 index 06dbdea4b..000000000 --- a/tools/x-ssr-demo/index.js +++ /dev/null @@ -1,58 +0,0 @@ -const express = require('express'); -const webpack = require('webpack'); -const webpackMiddleware = require('webpack-dev-middleware'); -const xEngine = require('@financial-times/x-engine/src/webpack'); -const getBabelConfig = require('@financial-times/x-babel-config'); -const path = require('path'); -const { Serialiser } = require('@financial-times/x-interaction'); - -const app = express(); -const publicPath = '/static'; - -require('@financial-times/n-handlebars')(app, { - directory: '.', - helpers: { - x: require('@financial-times/x-handlebars')(), - }, -}); - -const compiler = webpack({ - output: { - publicPath, - }, - plugins: [ - xEngine() - ], - module: { - rules: [ - { - test: /\.js$/, - loader: 'babel-loader', - include: path.join(__dirname, 'src'), - options: getBabelConfig(), - }, - ], - }, - mode: 'development', -}); - -app.use(webpackMiddleware(compiler, { - publicPath, - serverSideRender: true, -})); - -app.use((req, res) => { - const {assetsByChunkName} = res.locals.webpackStats.toJson(); - res.locals.serialiser = new Serialiser(); - - res.render('index', { - publicPath, - jsAssets: [].concat(assetsByChunkName.main).filter(path => path.endsWith('.js')), - cssAssets: [].concat(assetsByChunkName.main).filter(path => path.endsWith('.css')), - }); -}); - -app.listen(1370, () => { - /* eslint no-console:off */ - console.log('\nSSR demo listening on http://localhost:1370\n') -}); diff --git a/tools/x-ssr-demo/package.json b/tools/x-ssr-demo/package.json deleted file mode 100644 index 78fbe0dd3..000000000 --- a/tools/x-ssr-demo/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@financial-times/x-ssr-demo", - "private": true, - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "start": "nodemon -w index.js index.js" - }, - "keywords": [], - "author": "", - "license": "ISC", - "dependencies": { - "@financial-times/n-handlebars": "^1.21.0", - "@financial-times/x-babel-config": "file:../../packages/x-babel-config", - "@financial-times/x-engine": "file:../../packages/x-engine", - "@financial-times/x-handlebars": "file:../../packages/x-handlebars", - "@financial-times/x-increment": "file:../../components/x-increment", - "@financial-times/x-interaction": "file:../../components/x-interaction", - "babel-core": "^6.0.0", - "babel-loader": "^7.0.0", - "express": "^4.16.3", - "hyperons": "^0.5.0", - "preact": "^8.2.9", - "webpack": "^4.8.1", - "webpack-dev-middleware": "^3.1.3" - }, - "x-dash": { - "engine": { - "server": "hyperons", - "browser": "preact" - } - }, - "devDependencies": { - "nodemon": "^1.17.4" - } -} diff --git a/tools/x-ssr-demo/src/index.js b/tools/x-ssr-demo/src/index.js deleted file mode 100644 index a2281a402..000000000 --- a/tools/x-ssr-demo/src/index.js +++ /dev/null @@ -1,20 +0,0 @@ -import '@financial-times/x-increment'; -import {hydrate} from '@financial-times/x-interaction'; - -document.addEventListener('DOMContentLoaded', () => { - hydrate(); - - const external = document.getElementById('external-button'); - const increment = document.querySelector('[data-x-dash-id="x-ssr-increment-1"]'); - - external.addEventListener('click', () => { - increment.dispatchEvent(new CustomEvent('x-interaction.trigger-action', { - detail: { - action: 'increment', - args: [ { amount: 5 } ] - }, - })); - }); -}); - -import 'preact/devtools'; diff --git a/tools/x-ssr-demo/views/index.html b/tools/x-ssr-demo/views/index.html deleted file mode 100644 index 9e4e273b0..000000000 --- a/tools/x-ssr-demo/views/index.html +++ /dev/null @@ -1,22 +0,0 @@ -<!doctype html> -<html> -<head> - <title>x-dash SSR interactivity demo - {{#each cssAssets}} - - {{/each}} - - -
- {{{x package="x-increment" component="Increment" id="x-ssr-increment-1" count=1 timeout=1000 serialiser=@root.serialiser}}} - - -
- - {{#each jsAssets}} - - {{/each}} - - {{{x package="x-interaction" component="HydrationData" serialiser=@root.serialiser}}} - - diff --git a/tools/x-storybook/.gitignore b/tools/x-storybook/.gitignore deleted file mode 100644 index 16d3c4dbb..000000000 --- a/tools/x-storybook/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.cache diff --git a/tools/x-storybook/.storybook/addons.js b/tools/x-storybook/.storybook/addons.js deleted file mode 100644 index eb2cbb9ce..000000000 --- a/tools/x-storybook/.storybook/addons.js +++ /dev/null @@ -1,2 +0,0 @@ -import '@storybook/addon-knobs/register'; -import '@storybook/addon-viewport/register'; diff --git a/tools/x-storybook/.storybook/build-story.js b/tools/x-storybook/.storybook/build-story.js deleted file mode 100644 index 1c7c49bba..000000000 --- a/tools/x-storybook/.storybook/build-story.js +++ /dev/null @@ -1,81 +0,0 @@ -import React from 'react'; -import BuildService from './build-service'; -import { storiesOf } from '@storybook/react'; -import * as knobsAddon from '@storybook/addon-knobs'; -import { Helmet } from 'react-helmet'; -import path from 'path'; -import fetchMock from 'fetch-mock'; - -const defaultKnobs = () => ({}); - -/** - * Create Props - * @param {{ [key: string]: any }} defaultData - * @param {String[]} allowedKnobs - * @param {Function} hydrateKnobs - */ -function createProps(defaultData, allowedKnobs = [], hydrateKnobs = defaultKnobs) { - // Inject knobs add-on into given dependency container - const knobs = hydrateKnobs(defaultData, knobsAddon); - // Mix the available knob props into default data - const mixedProps = { ...defaultData, ...knobs }; - - if (allowedKnobs.length === 0) { - return mixedProps; - } - - return allowedKnobs.reduce((map, prop) => { - if (mixedProps.hasOwnProperty(prop)) { - const value = mixedProps[prop]; - - // Knobs are functions which need calling to register them - if (typeof value === 'function') { - map[prop] = value(); - } else { - map[prop] = value; - } - } - - return map; - }, {}); -} - -/** - * Build Story - * @param {String} name - * @param {{ [key: string]: string }} dependencies - * @param {Function} Component - * @param {Function} knobs - * @param {{ title: String, data: {}, knobs: String[], m: module }} story - */ -function buildStory({ package: pkg, dependencies, component: Component, knobs, story }) { - const name = path.basename(pkg.name); - const storybook = storiesOf(name, story.m); - - storybook.addDecorator(knobsAddon.withKnobs); - - storybook.add(story.title, () => { - const props = createProps(story.data, story.knobs, knobs); - - if (story.fetchMock) { - fetchMock.restore(); // to isolate the mocks to each story - story.fetchMock(fetchMock); - } - - return ( -
- {dependencies && } - {pkg.style && ( - - - - )} - -
- ); - }); - - return storybook; -} - -export default buildStory; diff --git a/tools/x-storybook/.storybook/config.js b/tools/x-storybook/.storybook/config.js deleted file mode 100644 index d2a527fab..000000000 --- a/tools/x-storybook/.storybook/config.js +++ /dev/null @@ -1,9 +0,0 @@ -import { configure } from '@storybook/react'; -import buildStory from './build-story'; -import * as components from '../register-components'; - -configure(() => { - components.forEach(({ stories, ...data }) => { - stories.forEach((story) => buildStory({ story, ...data })); - }); -}, module); diff --git a/tools/x-storybook/.storybook/webpack.config.js b/tools/x-storybook/.storybook/webpack.config.js deleted file mode 100644 index 4ea542d63..000000000 --- a/tools/x-storybook/.storybook/webpack.config.js +++ /dev/null @@ -1,44 +0,0 @@ -const path = require('path'); -const fs = require('fs'); -const findUp = require('find-up'); -const xEngine = require('@financial-times/x-engine/src/webpack'); -const CopyPlugin = require('copy-webpack-plugin'); -const WritePlugin = require('write-file-webpack-plugin'); - -// TODO: Find a less obtuse heuristic? -const repoBase = path.dirname(findUp.sync('makefile')); - -const cssCopy = fs.readdirSync( - path.resolve(repoBase, 'components') -).reduce((mains, component) => { - const componentPkg = path.resolve(repoBase, 'components', component, 'package.json'); - - if(fs.existsSync(componentPkg)) { - const pkg = require(componentPkg); - - if(pkg.style) { - const styleResolved = path.resolve(repoBase, 'components', component, pkg.style); - - return mains.concat({ - from: styleResolved, - to: path.resolve(__dirname, '../static/components', path.basename(pkg.name), pkg.style), - }); - } - } - - return mains; -}, []); - -module.exports = { - resolve: { - alias: { - '@storybook/addons': require.resolve('@storybook/addons'), - 'react': require.resolve('react'), - } - }, - plugins: [ - xEngine(), - new CopyPlugin(cssCopy), - new WritePlugin(), - ], -}; diff --git a/tools/x-storybook/package.json b/tools/x-storybook/package.json deleted file mode 100644 index 0eda317ea..000000000 --- a/tools/x-storybook/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@financial-times/x-storybook", - "private": true, - "version": "0.0.0", - "description": "", - "main": "register-components.js", - "scripts": { - "start": "start-storybook -p 9001 -c .storybook -s static -h local.ft.com", - "build": "build-storybook -c .storybook -o dist/storybook -s static" - }, - "keywords": [], - "author": "", - "license": "ISC", - "x-dash": { - "engine": { - "browser": "react" - } - }, - "devDependencies": { - "@babel/core": "^7.1.5", - "@financial-times/x-engine": "file:../../packages/x-engine", - "@financial-times/x-increment": "file:../../components/x-increment", - "@financial-times/x-styling-demo": "file:../../components/x-styling-demo", - "@financial-times/x-teaser": "file:../../components/x-teaser", - "@financial-times/x-article-save-button": "file:../../components/x-article-save-button", - "@storybook/addon-knobs": "^4.0.4", - "@storybook/addon-viewport": "^4.0.4", - "@storybook/addons": "^4.0.4", - "@storybook/cli": "^4.0.4", - "@storybook/react": "^4.0.4", - "babel-loader": "^8.0.4", - "copy-webpack-plugin": "^4.6.0", - "react": "^16.3.0", - "react-dom": "^16.3.0", - "react-helmet": "^5.2.0", - "style-loader": "^0.23.0", - "write-file-webpack-plugin": "^4.4.0" - } -} diff --git a/tools/x-storybook/register-components.js b/tools/x-storybook/register-components.js deleted file mode 100644 index a46504c80..000000000 --- a/tools/x-storybook/register-components.js +++ /dev/null @@ -1,8 +0,0 @@ -const components = [ - require('@financial-times/x-article-save-button/stories'), - require('@financial-times/x-teaser/stories'), - require('@financial-times/x-increment/stories'), - require('@financial-times/x-styling-demo/stories'), -]; - -module.exports = components; diff --git a/tools/x-storybook/static/components/.gitignore b/tools/x-storybook/static/components/.gitignore deleted file mode 100644 index d6b7ef32c..000000000 --- a/tools/x-storybook/static/components/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/wiki_images/ccpa.png b/wiki_images/ccpa.png new file mode 100644 index 000000000..041bf2cdb Binary files /dev/null and b/wiki_images/ccpa.png differ