├── .storybook ├── static │ └── .gitignore ├── preview-body.html ├── .browserslistrc ├── main.js ├── preview-head.html ├── webpack.config.js └── build-service.js ├── __mocks__ └── styleMock.js ├── e2e ├── .gitignore ├── index.js ├── app.js ├── jest.e2e.config.js ├── common.js ├── webpack.config.js ├── package.json └── e2e.test.js ├── packages ├── x-node-jsx │ ├── register.js │ ├── index.js │ ├── package.json │ └── readme.md ├── x-engine │ ├── src │ │ ├── concerns │ │ │ ├── resolve-pkg.js │ │ │ ├── resolve-peer.js │ │ │ ├── presets.js │ │ │ ├── deep-get.js │ │ │ └── format-config.js │ │ ├── client.js │ │ ├── server.js │ │ └── webpack.js │ └── package.json ├── x-handlebars │ ├── concerns │ │ ├── resolve-local.js │ │ └── resolve-peer.js │ └── package.json ├── x-test-utils │ ├── enzyme.js │ └── package.json ├── x-babel-config │ ├── jest.js │ ├── package.json │ └── index.js └── x-rollup │ ├── src │ ├── bundle.js │ ├── postcss-config.js │ ├── babel-config.js │ ├── logger.js │ ├── watch.js │ └── rollup-config.js │ ├── index.js │ └── package.json ├── components ├── x-gift-article │ ├── .gitignore │ ├── src │ │ ├── main.scss │ │ ├── lib │ │ │ ├── variables.scss │ │ │ ├── constants.js │ │ │ ├── highlightsHelpers.js │ │ │ ├── tracking.js │ │ │ ├── highlightsApi.js │ │ │ └── share-link-actions.js │ │ ├── Footer.jsx │ │ ├── NoCreditAlert.jsx │ │ ├── RegisteredUserAlert.jsx │ │ ├── FreeArticleAlert.jsx │ │ ├── CopyConfirmation.jsx │ │ ├── GiftLinkSection.jsx │ │ ├── Header.jsx │ │ ├── CreateLinkButton.jsx │ │ ├── ShareArticleDialog.jsx │ │ ├── UrlSection.jsx │ │ └── IncludeHighlights.jsx │ ├── rollup.js │ ├── package.json │ └── storybook │ │ ├── share-article-modal.js │ │ ├── share-article-modal-no-credits.jsx │ │ ├── share-article-modal-b2b-free-article.js │ │ ├── share-article-modal-no-credits-mpr-version.jsx │ │ ├── share-article-modal-b2b-save-highlights-message.jsx │ │ ├── share-article-modal-b2c.jsx │ │ ├── share-article-modal-with-advanced-sharing-save-highlights-message.jsx │ │ ├── share-article-modal-b2c-free-article.jsx │ │ ├── share-article-modal-b2c-no-credits.jsx │ │ ├── share-article-modal-registered.jsx │ │ ├── share-article-modal-b2b-highlights.jsx │ │ ├── share-article-modal-mpr-version.js │ │ ├── share-article-modal-with-advanced-sharing-no-both-credits.jsx │ │ ├── share-article-modal-with-advanced-sharing-no-enterprise-credits.jsx │ │ ├── share-article-modal-with-advanced-sharing.jsx │ │ ├── share-article-modal-with-advanced-sharing-free-article.jsx │ │ ├── share-article-modal-with-advanced-sharing-no-gift-credits.jsx │ │ ├── share-article-modal-with-advanced-sharing-no-enterprise-credits-mpr.jsx │ │ └── share-article-modal-with-advanced-sharing-mpr-version.jsx ├── x-privacy-manager │ ├── typings │ │ └── internal.d.ts │ ├── rollup.js │ ├── jsconfig.json │ ├── storybook │ │ ├── stories │ │ │ ├── legislation-ccpa.js │ │ │ ├── consent-blocked.js │ │ │ ├── save-failed.js │ │ │ ├── consent-accepted.js │ │ │ ├── consent-indeterminate.js │ │ │ └── legislation-gdpr.js │ │ ├── index.jsx │ │ ├── story-container.jsx │ │ └── data.js │ ├── src │ │ ├── components │ │ │ ├── form.jsx │ │ │ └── radio-btn.jsx │ │ ├── privacy-manager.scss │ │ ├── __tests__ │ │ │ ├── utils.test.js │ │ │ └── config.test.jsx │ │ └── utils.js │ └── package.json ├── x-teaser │ ├── rollup.js │ ├── src │ │ ├── Content.jsx │ │ ├── PromotionaContent.jsx │ │ ├── Meta.jsx │ │ ├── Promoted.jsx │ │ ├── ScoopLabel.jsx │ │ ├── TimeStamp.jsx │ │ ├── concerns │ │ │ ├── constants.js │ │ │ ├── image-service.js │ │ │ ├── date-time.js │ │ │ └── rules.js │ │ ├── Link.jsx │ │ ├── Teaser.scss │ │ ├── CustomSlot.jsx │ │ ├── PremiumLabel.jsx │ │ ├── RelatedLinks.jsx │ │ ├── Headshot.jsx │ │ ├── Standfirst.jsx │ │ ├── LiveBlogStatus.jsx │ │ ├── AlwaysShowTimestamp.jsx │ │ ├── Title.jsx │ │ ├── Container.jsx │ │ ├── Status.jsx │ │ ├── RelativeTime.jsx │ │ ├── MetaLink.jsx │ │ ├── Video.jsx │ │ ├── Teaser.jsx │ │ └── Image.jsx │ ├── storybook │ │ ├── video.js │ │ ├── podcast.js │ │ ├── package-item.js │ │ ├── promoted.js │ │ ├── content-package.js │ │ ├── top-story.js │ │ ├── opinion.js │ │ ├── article.js │ │ └── argTypes.js │ ├── __fixtures__ │ │ ├── promoted.json │ │ ├── content-package.json │ │ ├── package-item.json │ │ ├── video.json │ │ ├── article-with-missing-image-url.json │ │ ├── podcast.json │ │ ├── opinion.json │ │ ├── article-with-data-image.json │ │ ├── article.json │ │ └── top-story.json │ ├── __tests__ │ │ └── snapshots.test.js │ └── package.json ├── x-increment │ ├── rollup.js │ ├── storybook │ │ └── index.jsx │ ├── src │ │ └── Increment.jsx │ ├── package.json │ └── __tests__ │ │ └── x-increment.test.jsx ├── x-styling-demo │ ├── rollup.js │ ├── readme.md │ ├── src │ │ ├── Button.css │ │ └── Button.jsx │ ├── storybook │ │ └── index.jsx │ └── package.json ├── x-interaction │ ├── rollup.js │ ├── src │ │ ├── concerns │ │ │ ├── map-values.js │ │ │ ├── wrap-component-name.js │ │ │ ├── register-component.js │ │ │ └── serialiser.js │ │ ├── InteractionRender.jsx │ │ ├── HydrationData.jsx │ │ ├── InteractionSSR.jsx │ │ └── InteractionClass.jsx │ ├── package.json │ └── __tests__ │ │ └── registerComponent.test.js ├── x-teaser-list │ ├── rollup.js │ ├── src │ │ ├── TeaserList.scss │ │ └── TeaserList.jsx │ ├── storybook │ │ └── index.jsx │ └── package.json ├── x-follow-button │ ├── rollup.js │ ├── src │ │ └── styles │ │ │ ├── main.scss │ │ │ └── components │ │ │ └── FollowButton.scss │ ├── package.json │ └── storybook │ │ └── index.jsx ├── x-teaser-timeline │ ├── rollup.js │ ├── typings │ │ └── x-teaser-timeline.d.ts │ ├── storybook │ │ └── index.jsx │ ├── src │ │ ├── TeaserTimeline.scss │ │ └── lib │ │ │ └── date.js │ ├── package.json │ └── __tests__ │ │ └── lib │ │ └── date.test.js ├── x-topic-search │ ├── rollup.js │ ├── src │ │ ├── NoSuggestions.jsx │ │ ├── lib │ │ │ └── get-suggestions.js │ │ └── SuggestionList.jsx │ ├── package.json │ └── storybook │ │ └── index.jsx └── x-article-save-button │ ├── rollup.js │ ├── storybook │ └── index.jsx │ ├── package.json │ └── src │ ├── ArticleSaveButton.scss │ └── ArticleSaveButton.jsx ├── .gitignore ├── private └── logos │ ├── Logo Blue.png │ ├── Logo Red.png │ └── Logo Green.png ├── .snyk ├── .eslintignore ├── .prettierrc ├── app.json ├── jest.setup.js ├── .github ├── issue_template.md ├── settings.yml ├── pull_request_template.md └── dependabot.yml ├── .editorconfig ├── jest.config.js ├── CODEOWNERS └── .eslintrc.js /.storybook/static/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {} 2 | -------------------------------------------------------------------------------- /e2e/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled by webpack 2 | main.js -------------------------------------------------------------------------------- /packages/x-node-jsx/register.js: -------------------------------------------------------------------------------- 1 | require('./')() 2 | -------------------------------------------------------------------------------- /.storybook/preview-body.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /components/x-gift-article/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | package-lock.json 3 | -------------------------------------------------------------------------------- /components/x-gift-article/src/main.scss: -------------------------------------------------------------------------------- 1 | @import 'ShareArticleDialog'; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .DS_Store 4 | dist 5 | .idea 6 | coverage 7 | -------------------------------------------------------------------------------- /private/logos/Logo Blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Financial-Times/x-dash/HEAD/private/logos/Logo Blue.png -------------------------------------------------------------------------------- /private/logos/Logo Red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Financial-Times/x-dash/HEAD/private/logos/Logo Red.png -------------------------------------------------------------------------------- /.storybook/.browserslistrc: -------------------------------------------------------------------------------- 1 | last 2 Chrome versions 2 | last 2 FF versions 3 | last 2 Edge versions 4 | Safari >= 12 5 | -------------------------------------------------------------------------------- /private/logos/Logo Green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Financial-Times/x-dash/HEAD/private/logos/Logo Green.png -------------------------------------------------------------------------------- /.snyk: -------------------------------------------------------------------------------- 1 | # Snyk (https://snyk.io) policy file, which patches or ignores known vulnerabilities. 2 | version: v1.13.5 3 | ignore: {} 4 | patch: {} 5 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ['../components/*/storybook/index.jsx'], 3 | addons: ['@storybook/addon-essentials'] 4 | } 5 | -------------------------------------------------------------------------------- /packages/x-engine/src/concerns/resolve-pkg.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = () => path.join(process.cwd(), 'package.json') 4 | -------------------------------------------------------------------------------- /e2e/index.js: -------------------------------------------------------------------------------- 1 | import { hydrate } from '@financial-times/x-interaction' 2 | import './common' 3 | 4 | document.addEventListener('DOMContentLoaded', hydrate) 5 | -------------------------------------------------------------------------------- /components/x-privacy-manager/typings/internal.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.scss' { 2 | const content: { [className: string]: string } 3 | export default content 4 | } 5 | -------------------------------------------------------------------------------- /packages/x-engine/src/concerns/resolve-peer.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | module.exports = (moduleId) => path.join(process.cwd(), 'node_modules', moduleId) 3 | -------------------------------------------------------------------------------- /packages/x-handlebars/concerns/resolve-local.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | module.exports = (baseDirectory, moduleId) => path.resolve(baseDirectory, moduleId) 3 | -------------------------------------------------------------------------------- /packages/x-handlebars/concerns/resolve-peer.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | module.exports = (moduleId) => path.join(process.cwd(), 'node_modules', moduleId) 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/*.ts 2 | **/coverage/** 3 | **/node_modules/** 4 | **/dist/** 5 | **/vendor/** 6 | **/public/** 7 | **/public-prod/** 8 | web/static/** 9 | /e2e/** 10 | -------------------------------------------------------------------------------- /components/x-teaser/rollup.js: -------------------------------------------------------------------------------- 1 | const xRollup = require('@financial-times/x-rollup') 2 | const pkg = require('./package.json') 3 | 4 | xRollup({ input: './src/Teaser.jsx', pkg }) 5 | -------------------------------------------------------------------------------- /components/x-increment/rollup.js: -------------------------------------------------------------------------------- 1 | const xRollup = require('@financial-times/x-rollup') 2 | const pkg = require('./package.json') 3 | 4 | xRollup({ input: './src/Increment.jsx', pkg }) 5 | -------------------------------------------------------------------------------- /components/x-styling-demo/rollup.js: -------------------------------------------------------------------------------- 1 | const xRollup = require('@financial-times/x-rollup') 2 | const pkg = require('./package.json') 3 | 4 | xRollup({ input: './src/Button.jsx', pkg }) 5 | -------------------------------------------------------------------------------- /components/x-teaser/src/Content.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | 3 | export default ({ children = [] }) =>
{children}
4 | -------------------------------------------------------------------------------- /components/x-gift-article/rollup.js: -------------------------------------------------------------------------------- 1 | const xRollup = require('@financial-times/x-rollup') 2 | const pkg = require('./package.json') 3 | 4 | xRollup({ input: './src/GiftArticle.jsx', pkg }) 5 | -------------------------------------------------------------------------------- /components/x-interaction/rollup.js: -------------------------------------------------------------------------------- 1 | const xRollup = require('@financial-times/x-rollup') 2 | const pkg = require('./package.json') 3 | 4 | xRollup({ input: './src/Interaction.jsx', pkg }) 5 | -------------------------------------------------------------------------------- /components/x-teaser-list/rollup.js: -------------------------------------------------------------------------------- 1 | const xRollup = require('@financial-times/x-rollup'); 2 | const pkg = require('./package.json'); 3 | 4 | xRollup({ input: './src/TeaserList.jsx', pkg }); 5 | -------------------------------------------------------------------------------- /components/x-follow-button/rollup.js: -------------------------------------------------------------------------------- 1 | const xRollup = require('@financial-times/x-rollup') 2 | const pkg = require('./package.json') 3 | 4 | xRollup({ input: './src/FollowButton.jsx', pkg }) 5 | -------------------------------------------------------------------------------- /components/x-teaser-timeline/rollup.js: -------------------------------------------------------------------------------- 1 | const xRollup = require('@financial-times/x-rollup') 2 | const pkg = require('./package.json') 3 | 4 | xRollup({ input: './src/TeaserTimeline.jsx', pkg }) 5 | -------------------------------------------------------------------------------- /components/x-topic-search/rollup.js: -------------------------------------------------------------------------------- 1 | const xRollup = require('@financial-times/x-rollup'); 2 | const pkg = require('./package.json'); 3 | 4 | xRollup({ input: './src/TopicSearch.jsx', pkg }); 5 | -------------------------------------------------------------------------------- /components/x-privacy-manager/rollup.js: -------------------------------------------------------------------------------- 1 | const xRollup = require('@financial-times/x-rollup') 2 | const pkg = require('./package.json') 3 | 4 | xRollup({ input: './src/privacy-manager.jsx', pkg }) 5 | -------------------------------------------------------------------------------- /components/x-article-save-button/rollup.js: -------------------------------------------------------------------------------- 1 | const xRollup = require('@financial-times/x-rollup'); 2 | const pkg = require('./package.json'); 3 | 4 | xRollup({ input: './src/ArticleSaveButton.jsx', pkg }); 5 | -------------------------------------------------------------------------------- /components/x-follow-button/src/styles/main.scss: -------------------------------------------------------------------------------- 1 | // TODO: update me to not need a system code 2 | $system-code: 'github:Financial-Times/x-dash' !default; 3 | 4 | @import './components/FollowButton.scss'; 5 | -------------------------------------------------------------------------------- /components/x-gift-article/src/lib/variables.scss: -------------------------------------------------------------------------------- 1 | // This is needed for calls to the image service for Icons used in Social, 2 | // and oMessage 3 | $system-code: 'github:Financial-Times/x-dash' !default; 4 | -------------------------------------------------------------------------------- /components/x-styling-demo/readme.md: -------------------------------------------------------------------------------- 1 | # x-styling-demo 2 | 3 | A small demo of a custom-styled button. For more information see the [styling cookbook](https://github.com/Financial-Times/x-dash/wiki/Styling). 4 | -------------------------------------------------------------------------------- /e2e/app.js: -------------------------------------------------------------------------------- 1 | // set up app to host main.js file included in server side rendered html 2 | const express = require('express') 3 | const server = express() 4 | server.use(express.static(__dirname)) 5 | exports.app = server 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 110, 3 | "semi": false, 4 | "singleQuote": true, 5 | "bracketSpacing": true, 6 | "arrowParens": "always", 7 | "jsxBracketSameLine": true, 8 | "trailingComma": "none" 9 | } 10 | -------------------------------------------------------------------------------- /packages/x-test-utils/enzyme.js: -------------------------------------------------------------------------------- 1 | const Enzyme = require('enzyme') 2 | const Adapter = require('@cfaester/enzyme-adapter-react-18').default 3 | require('jest-enzyme') 4 | 5 | Enzyme.configure({ adapter: new Adapter() }) 6 | 7 | module.exports = Enzyme 8 | -------------------------------------------------------------------------------- /components/x-interaction/src/concerns/map-values.js: -------------------------------------------------------------------------------- 1 | const mapValues = (obj, fn) => 2 | Object.keys(obj).reduce( 3 | (mapped, key) => 4 | Object.assign(mapped, { 5 | [key]: fn(obj[key], key, obj) 6 | }), 7 | {} 8 | ) 9 | 10 | export default mapValues 11 | -------------------------------------------------------------------------------- /components/x-styling-demo/src/Button.css: -------------------------------------------------------------------------------- 1 | .button { 2 | background: steelblue; 3 | color: white; 4 | border-radius: 0.25em; 5 | padding: 0.5em; 6 | } 7 | 8 | .button--large { 9 | font-size: 1.2em; 10 | } 11 | 12 | .button--danger { 13 | background: firebrick; 14 | } 15 | -------------------------------------------------------------------------------- /packages/x-babel-config/jest.js: -------------------------------------------------------------------------------- 1 | const getBabelConfig = require('./') 2 | const babelJest = require('babel-jest') 3 | 4 | const base = getBabelConfig({ 5 | targets: { node: 'current' }, 6 | modules: 'commonjs' 7 | }) 8 | 9 | module.exports = babelJest.createTransformer(base) 10 | -------------------------------------------------------------------------------- /components/x-follow-button/src/styles/components/FollowButton.scss: -------------------------------------------------------------------------------- 1 | @import '@financial-times/n-myft-ui/mixins/lozenge/main.scss'; 2 | 3 | @each $theme in map-keys($myft-lozenge-themes) { 4 | .x-follow-button#{getThemeModifier($theme)} { 5 | @include myftLozengeToggleButton($theme); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /components/x-interaction/src/InteractionRender.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | 3 | export const InteractionRender = ({ id, actions, state, initialState, inFlight, Component }) => ( 4 | 0} /> 5 | ) 6 | -------------------------------------------------------------------------------- /components/x-teaser/src/PromotionaContent.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | 3 | const PromotionalContent = ({ promotionalContent }) => ( 4 |
{promotionalContent}
5 | ) 6 | 7 | export default PromotionalContent 8 | -------------------------------------------------------------------------------- /components/x-gift-article/src/Footer.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | import { FooterMessage } from './FooterMessage' 3 | 4 | export const Footer = (props) => { 5 | return ( 6 | 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /components/x-teaser/storybook/video.js: -------------------------------------------------------------------------------- 1 | const { presets } = require('../') 2 | 3 | exports.args = Object.assign(require('../__fixtures__/video.json'), presets.HeroVideo) 4 | 5 | // This reference is only required for hot module loading in development 6 | // 7 | exports.m = module 8 | -------------------------------------------------------------------------------- /components/x-teaser/storybook/podcast.js: -------------------------------------------------------------------------------- 1 | const { presets } = require('../') 2 | 3 | exports.args = Object.assign(require('../__fixtures__/podcast.json'), presets.SmallHeavy) 4 | 5 | // This reference is only required for hot module loading in development 6 | // 7 | exports.m = module 8 | -------------------------------------------------------------------------------- /components/x-teaser/storybook/package-item.js: -------------------------------------------------------------------------------- 1 | const { presets } = require('../') 2 | 3 | exports.args = Object.assign(require('../__fixtures__/package-item.json'), presets.Hero) 4 | 5 | // This reference is only required for hot module loading in development 6 | // 7 | exports.m = module 8 | -------------------------------------------------------------------------------- /components/x-teaser/storybook/promoted.js: -------------------------------------------------------------------------------- 1 | const { presets } = require('../') 2 | 3 | exports.args = Object.assign(require('../__fixtures__/promoted.json'), presets.SmallHeavy) 4 | 5 | // This reference is only required for hot module loading in development 6 | // 7 | exports.m = module 8 | -------------------------------------------------------------------------------- /packages/x-rollup/src/bundle.js: -------------------------------------------------------------------------------- 1 | const rollup = require('rollup') 2 | const logger = require('./logger') 3 | 4 | module.exports = async (configs) => { 5 | for (const [input, output] of configs) { 6 | const bundle = await rollup.rollup(input) 7 | await bundle.write(output) 8 | logger.success(`Bundled ${output.file}`) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /components/x-interaction/src/concerns/wrap-component-name.js: -------------------------------------------------------------------------------- 1 | function wrapComponentName(Component, Enhanced) { 2 | const originalDisplayName = Component.displayName || Component.name 3 | Enhanced.displayName = `withActions(${originalDisplayName})` 4 | Enhanced.wrappedDisplayName = originalDisplayName 5 | } 6 | 7 | export default wrapComponentName 8 | -------------------------------------------------------------------------------- /components/x-teaser/storybook/content-package.js: -------------------------------------------------------------------------------- 1 | const { presets } = require('../') 2 | 3 | exports.args = Object.assign(require('../__fixtures__/content-package.json'), presets.Hero) 4 | 5 | // This reference is only required for hot module loading in development 6 | // 7 | exports.m = module 8 | -------------------------------------------------------------------------------- /components/x-teaser/storybook/top-story.js: -------------------------------------------------------------------------------- 1 | const { presets } = require('../') 2 | 3 | exports.args = Object.assign(require('../__fixtures__/top-story.json'), presets.TopStoryLandscape) 4 | 5 | // This reference is only required for hot module loading in development 6 | // 7 | exports.m = module 8 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "NPM_CONFIG_PRODUCTION": { 4 | "description": "don't prune devDependencies", 5 | "value": "false" 6 | } 7 | }, 8 | "formation": { 9 | "web": { 10 | "quantity": 1, 11 | "size": "Standard-1X" 12 | } 13 | }, 14 | "buildpacks": [ 15 | {"url": "heroku/nodejs"} 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /components/x-privacy-manager/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist/types", 4 | "target": "es2015", 5 | "module": "commonjs", 6 | "strict": true, 7 | "noImplicitAny": true, 8 | "jsx": "react" 9 | }, 10 | "include": ["src/**/*.js", "src/**/*.jsx", "typings/*.d.ts"], 11 | "exclude": ["node_modules"] 12 | } 13 | -------------------------------------------------------------------------------- /components/x-styling-demo/src/Button.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | import classNames from 'classnames' 3 | 4 | export const Button = ({ large, danger }) => ( 5 | 13 | ) 14 | -------------------------------------------------------------------------------- /components/x-teaser/src/Meta.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | import MetaLink from './MetaLink' 3 | import Promoted from './Promoted' 4 | 5 | export default (props) => { 6 | const showPromoted = props.promotedPrefixText && props.promotedSuffixText 7 | 8 | return showPromoted ? : 9 | } 10 | -------------------------------------------------------------------------------- /e2e/jest.e2e.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testMatch: ['/e2e.test.js'], 3 | testPathIgnorePatterns: ['/node_modules/'], 4 | transform: { 5 | '^.+\\.jsx?$': '../packages/x-babel-config/jest' 6 | }, 7 | moduleNameMapper: { 8 | '^[./a-zA-Z0-9$_-]+\\.scss$': '/__mocks__/styleMock.js' 9 | }, 10 | testEnvironment: 'node' 11 | } 12 | -------------------------------------------------------------------------------- /components/x-teaser/src/Promoted.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | 3 | export default ({ promotedPrefixText, promotedSuffixText }) => ( 4 |
5 | {promotedPrefixText} by 6 | {` ${promotedSuffixText} `} 7 |
8 | ) 9 | -------------------------------------------------------------------------------- /packages/x-rollup/src/postcss-config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = (style) => { 4 | return { 5 | extract: style, 6 | modules: true, 7 | use: [ 8 | [ 9 | 'sass', 10 | { 11 | includePaths: [path.resolve(process.cwd(), 'node_modules')] 12 | } 13 | ], 14 | 'stylus', 15 | 'less' 16 | ] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /components/x-gift-article/src/lib/constants.js: -------------------------------------------------------------------------------- 1 | export const ShareType = { 2 | gift: 'gift', 3 | enterprise: 'enterprise', 4 | nonGift: 'nonGift' 5 | } 6 | 7 | export const UrlType = { 8 | dummy: 'example-gift-link', 9 | gift: 'gift-link', 10 | nonGift: 'non-gift-link' 11 | } 12 | 13 | export const HIGHLIGHTS_BASE_URL = 'https://pro-user-highlights.ft.com/v1' 14 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | import util from 'util' 2 | 3 | // Polyfill for TextEncoder so it is available to enzyme.render() 4 | // Jest and JSDOM do not have a built-in TextEncoder implementation in the test environment 5 | if (typeof global.TextEncoder === 'undefined') { 6 | Object.defineProperty(global, 'TextEncoder', { 7 | value: util.TextEncoder 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /components/x-teaser/src/ScoopLabel.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | 3 | export default function ScoopLabel() { 4 | return ( 5 |
6 | Exclusive 7 |  content 8 |
9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /components/x-teaser/storybook/opinion.js: -------------------------------------------------------------------------------- 1 | const { presets } = require('../') 2 | 3 | exports.args = Object.assign(require('../__fixtures__/opinion.json'), presets.SmallHeavy, { 4 | showHeadshot: true 5 | }) 6 | 7 | // This reference is only required for hot module loading in development 8 | // 9 | exports.m = module 10 | -------------------------------------------------------------------------------- /components/x-teaser/storybook/article.js: -------------------------------------------------------------------------------- 1 | const { presets } = require('../') 2 | 3 | exports.args = Object.assign(require('../__fixtures__/article.json'), presets.SmallHeavy, { 4 | allowLiveTeaserStyling: false 5 | }) 6 | 7 | // This reference is only required for hot module loading in development 8 | // 9 | exports.m = module 10 | -------------------------------------------------------------------------------- /components/x-styling-demo/storybook/index.jsx: -------------------------------------------------------------------------------- 1 | import { Button } from '../src/Button' 2 | import React from 'react' 3 | 4 | import '../src/Button.css' 5 | 6 | export default { 7 | title: 'x-styling-demo' 8 | } 9 | 10 | export const Styling = (args) => { 11 | return ( 12 |
13 |
15 | ) 16 | } 17 | Styling.args = { danger: false, large: false } 18 | -------------------------------------------------------------------------------- /packages/x-engine/src/client.js: -------------------------------------------------------------------------------- 1 | /* eslint no-undef: "off", no-unused-vars: "off" */ 2 | // This module is just a placeholder to be re-written at build time. 3 | const runtime = require(X_ENGINE_RUNTIME_MODULE) 4 | const render = require(X_ENGINE_RENDER_MODULE) 5 | 6 | module.exports.h = X_ENGINE_FACTORY 7 | module.exports.render = X_ENGINE_RENDER 8 | module.exports.Component = X_ENGINE_COMPONENT 9 | module.exports.Fragment = X_ENGINE_FRAGMENT 10 | -------------------------------------------------------------------------------- /components/x-interaction/src/HydrationData.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | 3 | export const HydrationData = ({ serialiser }) => { 4 | if (serialiser) { 5 | const data = serialiser.flushHydrationData() 6 | 7 | return ( 8 | 26 | -------------------------------------------------------------------------------- /components/x-increment/storybook/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Increment } from '../src/Increment' 3 | 4 | export default { 5 | title: 'x-increment' 6 | } 7 | 8 | export const Sync = () => { 9 | const data = { 10 | count: 1, 11 | id: 'base-increment-static-id' 12 | } 13 | 14 | return 15 | } 16 | 17 | export const Async = () => { 18 | const data = { 19 | count: 1, 20 | timeout: 1000, 21 | id: 'base-increment-static-id' 22 | } 23 | 24 | return 25 | } 26 | -------------------------------------------------------------------------------- /components/x-teaser/src/Teaser.scss: -------------------------------------------------------------------------------- 1 | @import '@financial-times/o3-foundation/css/core.css'; 2 | 3 | // WARNING: Do not use the x-teaser__premium-label class to override styling. 4 | // The styling should be in o-teaser, not x-teaser. 5 | // Use o-teaser__labels or o-teaser__labels--premium instead of x-teaser__premium-label. 6 | 7 | // Theses styles copy the spacing from o-teaser__timestamp 8 | // as the premium label is replacing it 9 | .x-teaser__premium-label { 10 | margin-top: auto; 11 | padding-top: var(--o3-spacing-2xs); 12 | } 13 | -------------------------------------------------------------------------------- /packages/x-rollup/index.js: -------------------------------------------------------------------------------- 1 | const rollupConfig = require('./src/rollup-config') 2 | const logger = require('./src/logger') 3 | const bundle = require('./src/bundle') 4 | const watch = require('./src/watch') 5 | 6 | module.exports = async (options) => { 7 | try { 8 | const configs = rollupConfig(options) 9 | const command = process.argv.slice(-1)[0] === '--watch' ? watch : bundle 10 | 11 | await command(configs) 12 | } catch (error) { 13 | logger.error(error instanceof Error ? error.message : error) 14 | process.exit(1) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.github/settings.yml: -------------------------------------------------------------------------------- 1 | _extends: github-apps-config-next 2 | branches: 3 | - name: main 4 | protection: 5 | required_pull_request_reviews: 6 | required_approving_review_count: 1 7 | dismiss_stale_reviews: true 8 | require_code_owner_reviews: false 9 | required_status_checks: 10 | strict: true 11 | contexts: 12 | - 'ci/circleci: test-v14.19' 13 | - 'ci/circleci: test-v16.14' 14 | enforce_admins: true 15 | restrictions: 16 | users: [] 17 | teams: [] 18 | -------------------------------------------------------------------------------- /components/x-gift-article/src/NoCreditAlert.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | 3 | export const NoCreditAlert = ({ children }) => { 4 | return ( 5 |
10 |
11 |
12 |

{children}

13 |
14 |
15 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /components/x-privacy-manager/storybook/index.jsx: -------------------------------------------------------------------------------- 1 | import '../src/privacy-manager.scss' 2 | 3 | export { LegislationCCPA } from './stories/legislation-ccpa' 4 | export { LegislationGDPR } from './stories/legislation-gdpr' 5 | 6 | export { ConsentIndeterminate } from './stories/consent-indeterminate' 7 | export { ConsentAccepted } from './stories/consent-accepted' 8 | export { ConsentBlocked } from './stories/consent-blocked' 9 | 10 | export { SaveFailed } from './stories/save-failed' 11 | 12 | export default { 13 | title: 'x-privacy-manager' 14 | } 15 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testURL: 'https://local.ft.com/', 3 | testMatch: ['**/__tests__/**/*.test.js?(x)'], 4 | testPathIgnorePatterns: ['/node_modules/'], 5 | transform: { 6 | '^.+\\.jsx?$': './packages/x-babel-config/jest' 7 | }, 8 | moduleNameMapper: { 9 | '^[./a-zA-Z0-9$_-]+\\.scss$': '/__mocks__/styleMock.js', 10 | '@financial-times/o-share': '/node_modules/@financial-times/o-share/main.js' 11 | }, 12 | modulePathIgnorePatterns: ['/e2e/'], 13 | setupFilesAfterEnv: ['/jest.setup.js'] 14 | } 15 | -------------------------------------------------------------------------------- /components/x-privacy-manager/storybook/stories/consent-accepted.js: -------------------------------------------------------------------------------- 1 | import { StoryContainer } from '../story-container' 2 | import { defaultArgs, defaultArgTypes, getFetchMock } from '../data' 3 | 4 | /** 5 | * @param {XPrivacyManager.PrivacyManagerProps} args 6 | */ 7 | export const ConsentAccepted = (args) => { 8 | getFetchMock(200) 9 | return StoryContainer(args) 10 | } 11 | 12 | ConsentAccepted.storyName = 'Consent: accepted' 13 | ConsentAccepted.args = { 14 | ...defaultArgs, 15 | consent: true 16 | } 17 | ConsentAccepted.argTypes = defaultArgTypes 18 | -------------------------------------------------------------------------------- /packages/x-engine/src/concerns/presets.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | react: { 3 | runtime: 'react', 4 | factory: 'createElement', 5 | component: 'Component', 6 | fragment: 'Fragment', 7 | renderModule: 'react-dom', 8 | render: 'render' 9 | }, 10 | preact: { 11 | runtime: 'preact', 12 | factory: 'h', 13 | component: 'Component', 14 | fragment: 'Fragment', 15 | render: 'render' 16 | }, 17 | hyperons: { 18 | runtime: 'hyperons', 19 | factory: 'h', 20 | component: 'Component', 21 | fragment: 'Fragment', 22 | render: 'render' 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /components/x-teaser/src/CustomSlot.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | 3 | /** 4 | * Render 5 | * @param {String|ReactElement} action 6 | * @returns {ReactElement} 7 | */ 8 | const render = (action) => { 9 | // Allow parent components to pass raw HTML strings 10 | if (typeof action === 'string') { 11 | return 12 | } else { 13 | return action 14 | } 15 | } 16 | 17 | export default ({ customSlot }) => 18 | customSlot ?
{render(customSlot)}
: null 19 | -------------------------------------------------------------------------------- /components/x-gift-article/src/RegisteredUserAlert.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | 3 | export const RegisteredUserAlert = ({ children }) => { 4 | return ( 5 |
10 |
11 |
12 |

{children}

13 |
14 |
15 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /packages/x-engine/src/concerns/deep-get.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Deep Get 3 | * @param {{ [key: string]: any }} tree 4 | * @param {string} path 5 | * @param {any} defaultValue 6 | * @returns {any | null} 7 | */ 8 | module.exports = (tree, path, defaultValue) => { 9 | const route = path.split('.') 10 | 11 | while (tree !== null && route.length) { 12 | const leaf = route.shift() 13 | 14 | if (leaf !== undefined && tree.hasOwnProperty(leaf)) { 15 | tree = tree[leaf] 16 | } else { 17 | tree = null 18 | } 19 | } 20 | 21 | return tree === null ? defaultValue : tree 22 | } 23 | -------------------------------------------------------------------------------- /components/x-privacy-manager/storybook/stories/consent-indeterminate.js: -------------------------------------------------------------------------------- 1 | import { StoryContainer } from '../story-container' 2 | import { defaultArgs, defaultArgTypes, getFetchMock } from '../data' 3 | 4 | /** 5 | * @param {XPrivacyManager.PrivacyManagerProps} args 6 | */ 7 | export const ConsentIndeterminate = (args) => { 8 | getFetchMock(200) 9 | return StoryContainer(args) 10 | } 11 | 12 | ConsentIndeterminate.storyName = 'Consent: indeterminate' 13 | ConsentIndeterminate.args = { 14 | ...defaultArgs, 15 | consent: undefined 16 | } 17 | ConsentIndeterminate.argTypes = defaultArgTypes 18 | -------------------------------------------------------------------------------- /components/x-teaser/src/concerns/image-service.js: -------------------------------------------------------------------------------- 1 | const BASE_URL = 'https://www.ft.com/__origami/service/image/v2/images/raw' 2 | const OPTIONS = { source: 'next', fit: 'scale-down', dpr: 2 } 3 | 4 | /** 5 | * Image Service 6 | * @param {String} url 7 | * @param {Number} width 8 | * @param {String} options 9 | */ 10 | export default function imageService(url, width, options) { 11 | const imageSrc = new URL(`${BASE_URL}/${encodeURIComponent(url)}`) 12 | imageSrc.search = new URLSearchParams({ ...OPTIONS, ...options }) 13 | imageSrc.searchParams.set('width', width) 14 | return imageSrc.href 15 | } 16 | -------------------------------------------------------------------------------- /e2e/common.js: -------------------------------------------------------------------------------- 1 | const { withActions, registerComponent } = require('@financial-times/x-interaction') 2 | const { h } = require('@financial-times/x-engine') 3 | 4 | export const greetingActions = withActions({ 5 | actionOne() { 6 | return { greeting: 'world' } 7 | } 8 | }) 9 | 10 | export const GreetingComponent = greetingActions(({ greeting, actions }) => { 11 | return ( 12 |
13 | hello {greeting} 14 | 17 |
18 | ) 19 | }) 20 | 21 | registerComponent(GreetingComponent, 'GreetingComponent') 22 | -------------------------------------------------------------------------------- /components/x-teaser/src/PremiumLabel.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | 3 | export default function PremiumLabel() { 4 | return ( 5 | // WARNING: Do not use the x-teaser__premium-label class to override styling. 6 | // The styling should be in o-teaser, not x-teaser. 7 | // Use o-teaser__labels or o-teaser__labels--premium instead of x-teaser__premium-label. 8 |
9 | Premium 10 |  content 11 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /components/x-teaser/src/RelatedLinks.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | 3 | const renderLink = ({ id, type, title, url, relativeUrl }, i) => { 4 | const displayUrl = relativeUrl || url 5 | return ( 6 |
  • 10 | 11 | {title} 12 | 13 |
  • 14 | ) 15 | } 16 | 17 | export default ({ relatedLinks = [] }) => 18 | relatedLinks && relatedLinks.length ? ( 19 |
      {relatedLinks.map(renderLink)}
    20 | ) : null 21 | -------------------------------------------------------------------------------- /components/x-topic-search/src/NoSuggestions.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | 3 | export default ({ searchTerm }) => ( 4 |
    5 |

    6 | No topics matching {searchTerm} 7 |

    8 | 9 |

    Suggestions:

    10 | 11 |
      12 |
    • Make sure that all words are spelled correctly.
    • 13 |
    • Try different keywords.
    • 14 |
    • Try more general keywords.
    • 15 |
    • Try fewer keywords.
    • 16 |
    17 |
    18 | ) 19 | -------------------------------------------------------------------------------- /components/x-privacy-manager/storybook/story-container.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import BuildService from '../../../.storybook/build-service' 3 | import { PrivacyManager } from '../src/privacy-manager' 4 | 5 | const dependencies = { 6 | 'o-fonts': '^5.3.0' 7 | } 8 | 9 | /** 10 | * @param {import("../typings/x-privacy-manager").PrivacyManagerProps} args 11 | */ 12 | export function StoryContainer(args) { 13 | return ( 14 |
    15 | {dependencies && } 16 |
    17 | 18 |
    19 |
    20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /components/x-teaser/__fixtures__/promoted.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "paid-post", 3 | "id": "", 4 | "url": "#", 5 | "title": "Why eSports companies are on a winning streak", 6 | "standfirst": "ESports is big business and about to get bigger: global revenues could hit $1.5bn by 2020", 7 | "promotedPrefixText": "Paid post", 8 | "promotedSuffixText": "UBS", 9 | "image": { 10 | "url": "https://tpc.googlesyndication.com/pagead/imgad?id=CICAgKCrm_3yahABGAEyCMx3RoLss603", 11 | "width": 700, 12 | "height": 394 13 | }, 14 | "status": "", 15 | "headshotTint": "", 16 | "accessLevel": "free", 17 | "theme": "", 18 | "parentTheme": "", 19 | "modifiers": "" 20 | } 21 | -------------------------------------------------------------------------------- /components/x-gift-article/src/lib/highlightsHelpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Checks if the user can share with non-subscriber users 3 | * @param {giftCredits: number, enterpriseHasCredits: boolean } param0 4 | * @returns {boolean} 5 | */ 6 | export const canShareWithNonSubscribers = ({ giftCredits, enterpriseHasCredits }) => 7 | giftCredits > 0 || enterpriseHasCredits 8 | 9 | export const isNonSubscriberOption = ({ showNonSubscriberOptions, showAdvancedSharingOptions }) => 10 | showNonSubscriberOptions || showAdvancedSharingOptions 11 | 12 | export const trimHighlights = (text, maxWordsCount = 30) => 13 | text.split(' ').length > maxWordsCount ? `${text.split(' ').slice(0, maxWordsCount).join(' ')} ...` : text 14 | -------------------------------------------------------------------------------- /components/x-gift-article/src/FreeArticleAlert.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | 3 | export const FreeArticleAlert = () => { 4 | return ( 5 |
    10 |
    11 |
    12 |

    13 | This is one of our free articles 14 |
    15 | Even non-subscribers can read it, without using up your sharing credits. 16 |

    17 |
    18 |
    19 |
    20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /components/x-topic-search/src/lib/get-suggestions.js: -------------------------------------------------------------------------------- 1 | const addQueryParamToUrl = (name, value, url, append = true) => { 2 | const queryParam = `${name}=${value}`; 3 | 4 | return append === true ? `${url}&${queryParam}` : `${url}?${queryParam}`; 5 | }; 6 | 7 | export default (searchTerm, maxSuggestions, apiUrl) => { 8 | const dataSrc = addQueryParamToUrl('count', maxSuggestions, apiUrl, false); 9 | const url = addQueryParamToUrl('partial', searchTerm.replace(' ', '+'), dataSrc); 10 | 11 | return fetch(url) 12 | .then(response => { 13 | if (!response.ok) { 14 | throw new Error(response.statusText); 15 | } 16 | 17 | return response.json(); 18 | }) 19 | .then(suggestions => ({ suggestions })); 20 | }; 21 | -------------------------------------------------------------------------------- /components/x-interaction/src/InteractionSSR.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | import { getComponentName } from './concerns/register-component' 3 | import shortId from '@quarterto/short-id' 4 | 5 | import { InteractionRender } from './InteractionRender' 6 | 7 | export const InteractionSSR = ({ 8 | initialState, 9 | Component, 10 | id = `${getComponentName(Component)}-${shortId()}`, 11 | actions, 12 | serialiser 13 | }) => { 14 | if (serialiser) { 15 | serialiser.addData({ 16 | id, 17 | Component, 18 | props: initialState 19 | }) 20 | } 21 | 22 | return ( 23 |
    24 | 25 |
    26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /components/x-article-save-button/storybook/index.jsx: -------------------------------------------------------------------------------- 1 | import { ArticleSaveButton } from '../src/ArticleSaveButton' 2 | import React from 'react' 3 | import BuildService from '../../../.storybook/build-service' 4 | 5 | import '../src/ArticleSaveButton.scss' 6 | 7 | export default { 8 | title: 'x-article-save-button' 9 | } 10 | 11 | export const SaveButton = (args) => ( 12 |
    13 | 14 | 15 |
    16 | ) 17 | 18 | SaveButton.args = { 19 | contentId: '0000-0000-0000-0000', 20 | contentTitle: 'UK crime agency steps up assault on Russian dirty money', 21 | csrfToken: 'dummy-token', 22 | saved: false, 23 | trackableId: 'trackable-id' 24 | } 25 | -------------------------------------------------------------------------------- /components/x-gift-article/src/CopyConfirmation.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | 3 | export default ({ hideCopyConfirmation }) => ( 4 |
    8 |
    9 |
    10 |

    11 | {Link copied to clipboard.} 12 |

    13 |
    14 | 15 | 21 |
    22 |
    23 | ) 24 | -------------------------------------------------------------------------------- /components/x-teaser/src/Headshot.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | import { ImageSizes } from './concerns/constants' 3 | import imageService from './concerns/image-service' 4 | 5 | // these colours are tweaked from o-colors palette colours to make headshots look less washed out 6 | const DEFAULT_TINT = '054593,d6d5d3' 7 | 8 | export default ({ headshot, headshotTint }) => { 9 | const options = { tint: `${headshotTint || DEFAULT_TINT}` } 10 | 11 | return headshot ? ( 12 | 20 | ) : null 21 | } 22 | -------------------------------------------------------------------------------- /components/x-teaser/src/Standfirst.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | import Link from './Link' 3 | 4 | export default ({ standfirst, altStandfirst, headlineTesting, relativeUrl, url, ...props }) => { 5 | const displayStandfirst = headlineTesting && altStandfirst ? altStandfirst : standfirst 6 | const displayUrl = relativeUrl || url 7 | return displayStandfirst ? ( 8 |

    9 | 18 | {displayStandfirst} 19 | 20 |

    21 | ) : null 22 | } 23 | -------------------------------------------------------------------------------- /packages/x-test-utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@financial-times/x-test-utils", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "", 6 | "main": "enzyme.js", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "engines": { 14 | "node": "16.x || 18.x || 20.x", 15 | "npm": "7.x || 8.x || 9.x || 10.x" 16 | }, 17 | "volta": { 18 | "extends": "../../package.json" 19 | }, 20 | "devDependencies": { 21 | "@cfaester/enzyme-adapter-react-18": "^0.8.0", 22 | "check-engine": "^1.10.1", 23 | "react": "^18.3.1", 24 | "react-dom": "^18.3.1", 25 | "enzyme": "^3.6.0", 26 | "jest-enzyme": "^7.1.2" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /e2e/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const xEngine = require('../packages/x-engine/src/webpack') 3 | const webpack = require('webpack') 4 | 5 | module.exports = { 6 | entry: './index.js', 7 | output: { 8 | filename: 'main.js', 9 | path: path.resolve(__dirname) 10 | }, 11 | plugins: [ 12 | new webpack.ProvidePlugin({ 13 | React: 'react' 14 | }), 15 | xEngine() 16 | ], 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.(js|jsx)$/, 21 | use: { 22 | loader: 'babel-loader', 23 | options: { 24 | presets: ['@babel/preset-env', '@babel/preset-react'] 25 | } 26 | }, 27 | exclude: /node_modules/ 28 | } 29 | ] 30 | }, 31 | resolve: { 32 | extensions: ['.js', '.jsx', '.json', '.wasm', '.mjs', '*'] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/x-babel-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@financial-times/x-babel-config", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "", 6 | "main": "index.js", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "@babel/plugin-transform-react-jsx": "^7.3.0", 15 | "@babel/preset-env": "^7.4.3", 16 | "babel-jest": "^24.0.0", 17 | "fast-async": "^7.0.6" 18 | }, 19 | "engines": { 20 | "node": "16.x || 18.x || 20.x", 21 | "npm": "7.x || 8.x || 9.x || 10.x" 22 | }, 23 | "volta": { 24 | "extends": "../../package.json" 25 | }, 26 | "devDependencies": { 27 | "check-engine": "^1.10.1" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /components/x-teaser-list/src/TeaserList.scss: -------------------------------------------------------------------------------- 1 | @import '@financial-times/o3-foundation/css/core.css'; 2 | @import '@financial-times/x-article-save-button/src/ArticleSaveButton'; 3 | @import '@financial-times/x-teaser/src/Teaser'; 4 | 5 | .x-teaser-list { 6 | list-style-type: none; 7 | margin: 0; 8 | padding: 0; 9 | } 10 | 11 | .x-teaser-list-item { 12 | display: grid; 13 | grid-gap: 0 20px; 14 | grid-template: 'article actions' min-content / 1fr min-content; 15 | margin-bottom: 20px; 16 | border-bottom: 1px solid var(--o3-color-palette-black-20); 17 | 18 | .o-teaser--teaser-list { 19 | border-bottom: 0; 20 | padding-bottom: 0; 21 | } 22 | } 23 | 24 | .x-teaser-list-item__article { 25 | grid-area: article; 26 | } 27 | 28 | .x-teaser-list-item__actions { 29 | grid-area: actions; 30 | } 31 | -------------------------------------------------------------------------------- /components/x-increment/src/Increment.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | import { withActions } from '@financial-times/x-interaction' 3 | 4 | const delay = (ms) => new Promise((r) => setTimeout(r, ms)) 5 | 6 | const withIncrementActions = withActions(({ timeout }) => ({ 7 | async increment({ amount = 1 } = {}) { 8 | await delay(timeout) 9 | 10 | return ({ count }) => ({ 11 | count: count + amount 12 | }) 13 | } 14 | })) 15 | 16 | const BaseIncrement = ({ count, customSlot, actions: { increment }, isLoading }) => ( 17 |
    18 | {count} 19 | 23 |
    24 | ) 25 | 26 | const Increment = withIncrementActions(BaseIncrement) 27 | 28 | export { Increment } 29 | -------------------------------------------------------------------------------- /components/x-privacy-manager/storybook/stories/legislation-gdpr.js: -------------------------------------------------------------------------------- 1 | import { StoryContainer } from '../story-container' 2 | import { defaultArgs, defaultArgTypes, getFetchMock } from '../data' 3 | 4 | const args = { 5 | ...defaultArgs, 6 | legislationId: 'gdpr', 7 | consent: undefined, 8 | buttonText: { 9 | allow: { 10 | label: 'Allow', 11 | text: 'See personalised advertising and allow measurement of advertising effectiveness' 12 | }, 13 | block: { 14 | label: 'Block', 15 | text: 'Block personalised advertising and measurement of advertising effectiveness' 16 | } 17 | } 18 | } 19 | 20 | export const LegislationGDPR = (args) => { 21 | getFetchMock(200) 22 | return StoryContainer(args) 23 | } 24 | 25 | LegislationGDPR.storyName = 'Legislation: GDPR' 26 | LegislationGDPR.args = args 27 | LegislationGDPR.argTypes = defaultArgTypes 28 | -------------------------------------------------------------------------------- /components/x-teaser/src/LiveBlogStatus.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | 3 | const LiveBlogModifiers = { 4 | inprogress: 'live', 5 | comingsoon: 'pending', 6 | closed: 'closed' 7 | } 8 | 9 | export default ({ status, allowLiveTeaserStyling = false }) => 10 | status && status !== 'closed' ? ( 11 |
    12 | {status === 'comingsoon' && {` Coming Soon `}} 13 | {status === 'inprogress' && ( 14 | 19 | {` Live `} 20 | 21 | )} 22 |
    23 | ) : null 24 | -------------------------------------------------------------------------------- /packages/x-node-jsx/index.js: -------------------------------------------------------------------------------- 1 | const { addHook } = require('pirates') 2 | const { transform } = require('sucrase') 3 | 4 | const extension = '.jsx' 5 | 6 | // Assume .jsx components are using x-engine 7 | const jsxOptions = { 8 | jsxPragma: 'h', 9 | jsxFragmentPragma: 'Fragment' 10 | } 11 | 12 | const defaultOptions = { 13 | // Do not output JSX debugger information 14 | production: true, 15 | // https://github.com/alangpierce/sucrase#transforms 16 | transforms: ['imports', 'jsx'] 17 | } 18 | 19 | module.exports = (userOptions = {}) => { 20 | const options = { ...defaultOptions, ...userOptions, ...jsxOptions } 21 | 22 | const handleJSX = (code) => { 23 | const transformed = transform(code, options) 24 | return transformed.code 25 | } 26 | 27 | // Return a function to revert the hook 28 | return addHook(handleJSX, { exts: [extension] }) 29 | } 30 | -------------------------------------------------------------------------------- /packages/x-babel-config/index.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ targets = [], modules = false } = {}) => ({ 2 | plugins: [ 3 | // this plugin is not React specific! It includes a general JSX parser and helper 🙄 4 | [ 5 | require.resolve('@babel/plugin-transform-react-jsx'), 6 | { 7 | pragma: 'h', 8 | useBuiltIns: true 9 | } 10 | ], 11 | // Implements async/await using syntax transformation rather than generators which require 12 | // a huge runtime for browsers which do not natively support them. 13 | [ 14 | require.resolve('fast-async'), 15 | { 16 | compiler: { 17 | noRuntime: true 18 | } 19 | } 20 | ] 21 | ], 22 | presets: [ 23 | [ 24 | require.resolve('@babel/preset-env'), 25 | { 26 | targets, 27 | modules, 28 | exclude: ['transform-regenerator', 'transform-async-to-generator'] 29 | } 30 | ] 31 | ] 32 | }) 33 | -------------------------------------------------------------------------------- /components/x-teaser-list/storybook/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { TeaserList } from '../src/TeaserList' 3 | import BuildService from '../../../.storybook/build-service' 4 | 5 | import '../src/TeaserList.scss' 6 | 7 | const dependencies = { 8 | 'o-date': '^7.0.1', 9 | 'o-labels': '^7.1.0', 10 | 'o-teaser': '^9.1.0', 11 | 'o-video': '^8.0.0' 12 | } 13 | 14 | export default { 15 | title: 'x-teaser-list' 16 | } 17 | 18 | export const _TeaserList = (args) => { 19 | return ( 20 |
    21 | 22 | 23 |
    24 | ) 25 | } 26 | 27 | _TeaserList.args = { 28 | showSaveButtons: true, 29 | csrfToken: 'dummy-token', 30 | items: require('./content-items.json') 31 | } 32 | 33 | _TeaserList.argTypes = { 34 | showSaveButtons: { name: 'Show save buttons' } 35 | } 36 | -------------------------------------------------------------------------------- /packages/x-rollup/src/logger.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk') 2 | const logSymbols = require('log-symbols') 3 | 4 | const format = (symbol, color, message) => { 5 | const time = new Date().toLocaleTimeString() 6 | return `[${time}] ${symbol} ${chalk[color](message)}\n` 7 | } 8 | 9 | module.exports.info = (message) => { 10 | process.stdout.write(format(logSymbols.info, 'blue', message)) 11 | } 12 | 13 | module.exports.message = (message) => { 14 | process.stdout.write(format('\x20', 'gray', message)) 15 | } 16 | 17 | module.exports.success = (message) => { 18 | process.stdout.write(format(logSymbols.success, 'green', message)) 19 | } 20 | 21 | module.exports.warning = (message) => { 22 | process.stdout.write(format(logSymbols.warning, 'yellow', message)) 23 | } 24 | 25 | module.exports.error = (message) => { 26 | process.stderr.write(format(logSymbols.error, 'red', message)) 27 | } 28 | -------------------------------------------------------------------------------- /components/x-teaser/src/AlwaysShowTimestamp.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | import TimeStamp from './TimeStamp' 3 | import RelativeTime from './RelativeTime' 4 | import { differenceInCalendarDays } from 'date-fns' 5 | 6 | /** 7 | * Timestamp shown always, the default 4h limit does not apply here 8 | * If same calendar day, we show relative time e.g. X hours ago or Updated X min ago 9 | * If different calendar day, we show full Date time e.g. June 9, 2021 10 | */ 11 | export default (props) => { 12 | const localTodayDate = new Date().toISOString().substr(0, 10) // keep only the date bit 13 | const dateToCompare = new Date(props.publishedDate).toISOString().substr(0, 10) 14 | 15 | if (differenceInCalendarDays(localTodayDate, dateToCompare) >= 1) { 16 | return 17 | } else { 18 | return 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/about-codeowners/ for more information about this file. 2 | 3 | * @financial-times/platforms 4 | 5 | # component ownership 6 | 7 | components/x-privacy-manager @financial-times/platforms @Financial-Times/ads 8 | components/x-teaser @financial-times/platforms @Financial-Times/curation-and-loyalty 9 | components/x-teaser-timeline @financial-times/platforms @Financial-Times/curation-and-loyalty 10 | components/x-teaser-list @financial-times/platforms @Financial-Times/curation-and-loyalty 11 | components/x-topic-search @financial-times/platforms @Financial-Times/content-discovery 12 | components/x-gift-article @financial-times/platforms @Financial-Times/cp-customer-lifecycle @Financial-Times/professional-bolt-ons 13 | 14 | # Allow Dependency Auto-Merger to approve dependency bump PRs 15 | **/package*.json @ft-dependency-auto-merger @financial-times/platforms 16 | -------------------------------------------------------------------------------- /components/x-teaser/src/Title.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | import Link from './Link' 3 | 4 | export default ({ title, altTitle, headlineTesting, relativeUrl, url, ...props }) => { 5 | const displayTitle = headlineTesting && altTitle ? altTitle : title 6 | const displayUrl = relativeUrl || url 7 | let ariaLabel 8 | if (props.type === 'video') { 9 | ariaLabel = `Watch video ${displayTitle}` 10 | } else if (props.type === 'audio') { 11 | ariaLabel = `Listen to podcast ${displayTitle}` 12 | } 13 | 14 | return ( 15 |
    16 | 26 | {displayTitle} 27 | 28 |
    29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /packages/x-engine/src/concerns/format-config.js: -------------------------------------------------------------------------------- 1 | const presets = require('./presets') 2 | 3 | /** 4 | * Format Config 5 | * @param {string|{ runtime: string, factory?: string }} config 6 | * @returns {{ runtime: string, factory: string }} 7 | */ 8 | module.exports = function (config) { 9 | // if configuration is a string, expand it 10 | if (typeof config === 'string') { 11 | if (presets.hasOwnProperty(config)) { 12 | config = presets[config] 13 | } else { 14 | config = { runtime: config, factory: null } 15 | } 16 | } 17 | 18 | if (typeof config.runtime !== 'string') { 19 | throw new TypeError('Engine configuration must define a runtime') 20 | } 21 | 22 | if (config.factory && typeof config.factory !== 'string') { 23 | throw new TypeError('Engine factory must be of type String.') 24 | } 25 | 26 | if (!config.renderModule) { 27 | config.renderModule = config.runtime 28 | } 29 | 30 | return config 31 | } 32 | -------------------------------------------------------------------------------- /components/x-teaser/__fixtures__/content-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "package", 3 | "id": "", 4 | "url": "#", 5 | "title": "The royal wedding", 6 | "altTitle": "", 7 | "standfirst": "Prince Harry and Meghan Markle will tie the knot at Windsor Castle", 8 | "altStandfirst": "", 9 | "publishedDate": "2018-05-14T16:38:49.000Z", 10 | "firstPublishedDate": "2018-05-14T16:38:49.000Z", 11 | "metaPrefixText": "", 12 | "metaSuffixText": "", 13 | "metaLink": { 14 | "url": "#", 15 | "prefLabel": "FT Magazine" 16 | }, 17 | "metaAltLink": { 18 | "url": "#", 19 | "prefLabel": "FT Series" 20 | }, 21 | "image": { 22 | "url": "http://prod-upp-image-read.ft.com/7e97f5b6-578d-11e8-b8b2-d6ceb45fa9d0", 23 | "width": 2048, 24 | "height": 1152 25 | }, 26 | "status": "", 27 | "headshotTint": "", 28 | "accessLevel": "free", 29 | "theme": "", 30 | "parentTheme": "", 31 | "modifiers": "centre" 32 | } 33 | -------------------------------------------------------------------------------- /components/x-teaser/__fixtures__/package-item.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "article", 3 | "id": "", 4 | "url": "#", 5 | "title": "Why so little has changed since the crash", 6 | "standfirst": "Martin Wolf on the power of vested interests in today’s rent-extracting economy", 7 | "publishedDate": "2018-09-02T15:07:00.000Z", 8 | "firstPublishedDate": "2018-09-02T13:53:00.000Z", 9 | "metaPrefixText": "FT Series", 10 | "metaSuffixText": "", 11 | "metaLink": { 12 | "url": "#", 13 | "prefLabel": "Financial crisis: Are we safer now? " 14 | }, 15 | "image": { 16 | "url": "http://prod-upp-image-read.ft.com/a25832ea-0053-11e8-9650-9c0ad2d7c5b5", 17 | "width": 2048, 18 | "height": 1152 19 | }, 20 | "indicators": { 21 | "isOpinion": true 22 | }, 23 | "status": "", 24 | "headshotTint": "", 25 | "accessLevel": "free", 26 | "theme": "", 27 | "parentTheme": "extra-article", 28 | "modifiers": "centre" 29 | } 30 | -------------------------------------------------------------------------------- /packages/x-rollup/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@financial-times/x-rollup", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "", 6 | "main": "index.js", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "@babel/core": "^7.6.4", 15 | "@babel/plugin-external-helpers": "^7.2.0", 16 | "@financial-times/x-babel-config": "file:../x-babel-config", 17 | "chalk": "^2.4.2", 18 | "log-symbols": "^3.0.0", 19 | "rollup": "^1.23.0", 20 | "rollup-plugin-babel": "^4.3.2", 21 | "rollup-plugin-commonjs": "^10.1.0" 22 | }, 23 | "engines": { 24 | "node": "16.x || 18.x || 20.x", 25 | "npm": "7.x || 8.x || 9.x || 10.x" 26 | }, 27 | "volta": { 28 | "extends": "../../package.json" 29 | }, 30 | "devDependencies": { 31 | "check-engine": "^1.10.1" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/x-node-jsx/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@financial-times/x-node-jsx", 3 | "version": "0.0.0", 4 | "description": "This module extends Node's require function to enable the use of .jsx files at runtime.", 5 | "main": "src/index.js", 6 | "keywords": [ 7 | "x-dash" 8 | ], 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "pirates": "^4.0.0", 13 | "sucrase": "^3.6.0" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/Financial-Times/x-dash.git" 18 | }, 19 | "homepage": "https://github.com/Financial-Times/x-dash/tree/HEAD/packages/x-node-jsx", 20 | "engines": { 21 | "node": "16.x || 18.x || 20.x", 22 | "npm": "7.x || 8.x || 9.x || 10.x" 23 | }, 24 | "publishConfig": { 25 | "access": "public" 26 | }, 27 | 28 | "volta": { 29 | "extends": "../../package.json" 30 | }, 31 | "devDependencies": { 32 | "check-engine": "^1.10.1" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /components/x-styling-demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@financial-times/x-styling-demo", 3 | "version": "0.0.0", 4 | "description": "", 5 | "source": "src/Button.jsx", 6 | "main": "dist/Button.cjs.js", 7 | "browser": "dist/Button.es5.js", 8 | "module": "dist/Button.esm.js", 9 | "style": "src/Button.css", 10 | "private": true, 11 | "scripts": { 12 | "build": "node rollup.js", 13 | "start": "node rollup.js --watch" 14 | }, 15 | "keywords": [], 16 | "author": "", 17 | "license": "ISC", 18 | "devDependencies": { 19 | "@financial-times/x-rollup": "file:../../packages/x-rollup", 20 | "check-engine": "^1.10.1" 21 | }, 22 | "dependencies": { 23 | "@financial-times/x-engine": "file:../../packages/x-engine", 24 | "classnames": "^2.2.6" 25 | }, 26 | "engines": { 27 | "node": "16.x || 18.x || 20.x", 28 | "npm": "7.x || 8.x || 9.x || 10.x" 29 | }, 30 | "volta": { 31 | "extends": "../../package.json" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /components/x-article-save-button/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@financial-times/x-article-save-button", 3 | "version": "0.0.0", 4 | "description": "", 5 | "main": "dist/ArticleSaveButton.cjs.js", 6 | "module": "dist/ArticleSaveButton.esm.js", 7 | "browser": "dist/ArticleSaveButton.es5.js", 8 | "style": "src/ArticleSaveButton.scss", 9 | "scripts": { 10 | "build": "node rollup.js", 11 | "start": "node rollup.js --watch" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "devDependencies": { 17 | "@financial-times/x-rollup": "file:../../packages/x-rollup", 18 | "sass": "^1.49.0" 19 | }, 20 | "volta": { 21 | "extends": "../../package.json" 22 | }, 23 | "dependencies": { 24 | "@financial-times/x-engine": "file:../../packages/x-engine" 25 | }, 26 | "engines": { 27 | "node": "16.x || 18.x || 20.x" 28 | }, 29 | "peerDependencies": { 30 | "@financial-times/o3-foundation": "^3.1.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/x-rollup/src/watch.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const rollup = require('rollup') 3 | const logger = require('./logger') 4 | 5 | module.exports = (configs) => { 6 | // Merge the separate input/output options for each bundle 7 | const formattedConfigs = configs.map(([input, output]) => { 8 | return { ...input, output } 9 | }) 10 | 11 | return new Promise((resolve, reject) => { 12 | const watcher = rollup.watch(formattedConfigs) 13 | 14 | logger.info('Watching files, press ctrl + c to stop') 15 | 16 | watcher.on('event', (event) => { 17 | switch (event.code) { 18 | case 'END': 19 | logger.message('Waiting for changes…') 20 | break 21 | 22 | case 'BUNDLE_END': 23 | logger.success(`Bundled ${path.relative(process.cwd(), event.output[0])}`) 24 | break 25 | 26 | case 'ERROR': 27 | logger.warning(event.error) 28 | break 29 | 30 | case 'FATAL': 31 | reject(event.error) 32 | break 33 | } 34 | }) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /components/x-teaser-timeline/typings/x-teaser-timeline.d.ts: -------------------------------------------------------------------------------- 1 | export type CustomSlotContent = string[] | Object[] | Object | string 2 | export type CustomSlotPosition = number[] | number 3 | 4 | /** 5 | * A news item (i.e. an article) 6 | */ 7 | export interface Item { 8 | id: string // e.g. '01f0b004-36b9-11ea-a6d3-9a26f8c3cba4' 9 | title: string // e.g.,'Europeans step up pressure on Iran over nuclear deal' 10 | publishedDate: string // ISO8601 date string 11 | localisedLastUpdated: string // ISO8601 date string 12 | } 13 | 14 | export interface ItemInGroupInfo { 15 | articleIndex: number 16 | localisedLastUpdated: string // ISO8601 date string 17 | } 18 | 19 | export interface ItemInGroup extends Item, ItemInGroupInfo {} 20 | 21 | export interface GroupOfItems { 22 | title?: string // e.g. 'Earlier Today' 23 | date: string // e.g. '2020-01-14' 24 | items: ItemInGroup[] // An array of news articles 25 | } 26 | 27 | export interface PositionInGroup { 28 | group: number 29 | index: number 30 | } 31 | -------------------------------------------------------------------------------- /packages/x-handlebars/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@financial-times/x-handlebars", 3 | "version": "0.0.0", 4 | "description": "This module provides Handlebars helper functions to render `x-dash` component packages or local compatible modules within existing Handlebars templates.", 5 | "main": "index.js", 6 | "keywords": [ 7 | "x-dash" 8 | ], 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "@financial-times/x-engine": "file:../x-engine" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/Financial-Times/x-dash.git" 17 | }, 18 | "homepage": "https://github.com/Financial-Times/x-dash/tree/HEAD/packages/x-handlebars", 19 | "engines": { 20 | "node": "16.x || 18.x || 20.x", 21 | "npm": "7.x || 8.x || 9.x || 10.x" 22 | }, 23 | "publishConfig": { 24 | "access": "public" 25 | }, 26 | 27 | "volta": { 28 | "extends": "../../package.json" 29 | }, 30 | "devDependencies": { 31 | "check-engine": "^1.10.1" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /components/x-teaser-list/src/TeaserList.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | import { ArticleSaveButton } from '@financial-times/x-article-save-button' 3 | import { Teaser, presets } from '@financial-times/x-teaser' 4 | 5 | const TeaserList = ({ items = [], showSaveButtons = true, csrfToken = null }) => ( 6 |
      7 | {items.map((item) => { 8 | return ( 9 |
    • 10 |
      11 | 12 |
      13 | {showSaveButtons && ( 14 |
      15 | 22 |
      23 | )} 24 |
    • 25 | ) 26 | })} 27 |
    28 | ) 29 | 30 | export { TeaserList } 31 | -------------------------------------------------------------------------------- /packages/x-engine/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@financial-times/x-engine", 3 | "version": "0.0.0", 4 | "description": "This module is a consolidation library to render x-dash components with any compatible runtime.", 5 | "main": "src/server.js", 6 | "browser": "src/client.js", 7 | "keywords": [ 8 | "x-dash" 9 | ], 10 | "author": "", 11 | "license": "ISC", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/Financial-Times/x-dash.git" 15 | }, 16 | "homepage": "https://github.com/Financial-Times/x-dash/tree/HEAD/packages/x-engine", 17 | "engines": { 18 | "node": "16.x || 18.x || 20.x", 19 | "npm": "7.x || 8.x || 9.x || 10.x" 20 | }, 21 | "publishConfig": { 22 | "access": "public" 23 | }, 24 | "volta": { 25 | "extends": "../../package.json" 26 | }, 27 | "devDependencies": { 28 | "check-engine": "^1.10.1" 29 | }, 30 | "peerDependencies": { 31 | "webpack": ">=4.0.0" 32 | }, 33 | "dependencies": { 34 | "assign-deep": "^1.0.1" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /components/x-teaser/src/Container.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | import { media, theme } from './concerns/rules' 3 | 4 | const dynamicModifiers = (props) => { 5 | const modifiers = [] 6 | 7 | const mediaRule = media(props) 8 | 9 | if (mediaRule) { 10 | modifiers.push(`has-${mediaRule}`) 11 | } 12 | 13 | const themeRule = theme(props) 14 | 15 | if (themeRule) { 16 | modifiers.push(themeRule) 17 | } 18 | 19 | return modifiers 20 | } 21 | 22 | export default (props) => { 23 | const computed = dynamicModifiers(props) 24 | // Modifier props may be a string rather than a string[] so concat, don't spread. 25 | const variants = [props.type, props.layout].concat(props.modifiers, computed) 26 | 27 | const classNames = variants 28 | .filter(Boolean) 29 | .map((mod) => `o-teaser--${mod}`) 30 | .join(' ') 31 | 32 | return ( 33 |
    37 | {props.children} 38 |
    39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /components/x-increment/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@financial-times/x-increment", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "", 6 | "source": "src/Increment.jsx", 7 | "main": "dist/Increment.cjs.js", 8 | "module": "dist/Increment.esm.js", 9 | "browser": "dist/Increment.es5.js", 10 | "scripts": { 11 | "build": "node rollup.js", 12 | "start": "node rollup.js --watch" 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "ISC", 17 | "devDependencies": { 18 | "@financial-times/x-rollup": "file:../../packages/x-rollup", 19 | "@financial-times/x-test-utils": "file:../../packages/x-test-utils", 20 | "check-engine": "^1.10.1" 21 | }, 22 | "dependencies": { 23 | "@financial-times/x-engine": "file:../../packages/x-engine", 24 | "@financial-times/x-interaction": "file:../x-interaction" 25 | }, 26 | "engines": { 27 | "node": "16.x || 18.x || 20.x", 28 | "npm": "7.x || 8.x || 9.x || 10.x" 29 | }, 30 | "volta": { 31 | "extends": "../../package.json" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /components/x-teaser/__fixtures__/video.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "video", 3 | "id": "0e89d872-5711-457b-80b1-4ca0d8afea46", 4 | "url": "#", 5 | "title": "FT View: Donald Trump, man of steel", 6 | "standfirst": "The FT's Rob Armstrong looks at why Donald Trump is pushing trade tariffs", 7 | "publishedDate": "2018-03-26T08:12:28.137Z", 8 | "firstPublishedDate": "2018-03-26T08:12:28.137Z", 9 | "metaPrefixText": "", 10 | "metaSuffixText": "02:51min", 11 | "systemCode": "x-teaser", 12 | "metaLink": { 13 | "url": "#", 14 | "prefLabel": "Global Trade" 15 | }, 16 | "metaAltLink": { 17 | "url": "#", 18 | "prefLabel": "US" 19 | }, 20 | "image": { 21 | "url": "http://com.ft.imagepublish.upp-prod-eu.s3.amazonaws.com/a27ce49b-85b8-445b-b883-db6e2f533194", 22 | "width": 1920, 23 | "height": 1080, 24 | "altText": "Image alt text" 25 | }, 26 | "video": { 27 | "url": "https://next-media-api.ft.com/renditions/15218247321960/640x360.mp4", 28 | "width": 640, 29 | "height": 360, 30 | "mediaType": "video/mp4", 31 | "codec": "h264" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /components/x-privacy-manager/src/components/form.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | 3 | /** 4 | * @param {XPrivacyManager.FormProps} args 5 | */ 6 | export const Form = ({ consent, consentApiUrl, sendConsent, trackingKeys, buttonText, children }) => { 7 | /** @type {XPrivacyManager.TrackingKey} */ 8 | const consentAction = consent ? 'consent-allow' : 'consent-block' 9 | const btnTrackingId = trackingKeys[consentAction] 10 | const isDisabled = typeof consent === 'undefined' 11 | 12 | /** @param {React.FormEvent} event */ 13 | const onSubmit = (event) => { 14 | event && event.preventDefault() 15 | return sendConsent() 16 | } 17 | 18 | return ( 19 |
    20 |
    {children}
    21 | 29 |
    30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /components/x-teaser/__fixtures__/article-with-missing-image-url.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "article", 3 | "id": "", 4 | "url": "#", 5 | "title": "Inside charity fundraiser where hostesses are put on show", 6 | "altTitle": "Men Only, the charity fundraiser with hostesses on show", 7 | "standfirst": "FT investigation finds groping and sexual harassment at secretive black-tie dinner", 8 | "altStandfirst": "Groping and sexual harassment at black-tie dinner charity event", 9 | "publishedDate": "2018-01-23T15:07:00.000Z", 10 | "firstPublishedDate": "2018-01-23T13:53:00.000Z", 11 | "metaPrefixText": "", 12 | "metaSuffixText": "", 13 | "metaLink": { 14 | "url": "#", 15 | "prefLabel": "Sexual misconduct allegations" 16 | }, 17 | "metaAltLink": { 18 | "url": "#", 19 | "prefLabel": "FT Investigations" 20 | }, 21 | "image": { 22 | "width": 2048, 23 | "height": 1152 24 | }, 25 | "indicators": { 26 | "isEditorsChoice": true 27 | }, 28 | "status": "", 29 | "headshotTint": "", 30 | "accessLevel": "free", 31 | "theme": "", 32 | "parentTheme": "", 33 | "modifiers": "" 34 | } 35 | -------------------------------------------------------------------------------- /components/x-follow-button/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@financial-times/x-follow-button", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "dist/FollowButton.cjs.js", 6 | "style": "src/FollowButton.scss", 7 | "browser": "dist/FollowButton.es5.js", 8 | "module": "dist/FollowButton.esm.js", 9 | "scripts": { 10 | "build": "node rollup.js", 11 | "start": "node rollup.js --watch" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "devDependencies": { 17 | "@financial-times/x-rollup": "file:../../packages/x-rollup", 18 | "@financial-times/x-test-utils": "file:../../packages/x-test-utils", 19 | "check-engine": "^1.10.1", 20 | "sass": "^1.49.0" 21 | }, 22 | "dependencies": { 23 | "@financial-times/x-engine": "file:../../packages/x-engine", 24 | "classnames": "^2.2.6" 25 | }, 26 | "engines": { 27 | "node": "16.x || 18.x || 20.x", 28 | "npm": "7.x || 8.x || 9.x || 10.x" 29 | }, 30 | "volta": { 31 | "extends": "../../package.json" 32 | }, 33 | "peerDependencies": { 34 | "@financial-times/n-myft-ui": "^40.0.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /components/x-teaser/__fixtures__/podcast.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "audio", 3 | "id": "d1246074-f7d3-4aaf-951c-80a6db495765", 4 | "url": "https://www.ft.com/content/d1246074-f7d3-4aaf-951c-80a6db495765", 5 | "title": "Who sets the internet standards?", 6 | "standfirst": "Hannah Kuchler talks to American social scientist and cyber security expert Andrea…", 7 | "altStandfirst": "", 8 | "publishedDate": "2018-10-24T04:00:00.000Z", 9 | "firstPublishedDate": "2018-10-24T04:00:00.000Z", 10 | "metaSuffixText": "12 mins", 11 | "metaLink": { 12 | "url": "#", 13 | "prefLabel": "Tech Tonic podcast" 14 | }, 15 | "metaAltLink": "", 16 | "image": { 17 | "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", 18 | "width": 2048, 19 | "height": 1152 20 | }, 21 | "indicators": { 22 | "isPodcast": true 23 | }, 24 | "status": "", 25 | "headshotTint": "", 26 | "accessLevel": "free", 27 | "theme": "", 28 | "parentTheme": "", 29 | "modifiers": "" 30 | } 31 | -------------------------------------------------------------------------------- /components/x-teaser/__fixtures__/opinion.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "article", 3 | "id": "", 4 | "url": "#", 5 | "title": "Anti-Semitism and the threat of identity politics", 6 | "altTitle": "", 7 | "standfirst": "Today, hatred of Jews is mixed in with fights about Islam and Israel", 8 | "altStandfirst": "Anti-Semitism and identity politics", 9 | "publishedDate": "2018-04-02T12:22:01.000Z", 10 | "firstPublishedDate": "2018-04-02T12:22:01.000Z", 11 | "metaPrefixText": "", 12 | "metaSuffixText": "", 13 | "metaLink": { 14 | "url": "#", 15 | "prefLabel": "Gideon Rachman" 16 | }, 17 | "metaAltLink": { 18 | "url": "#", 19 | "prefLabel": "Anti-Semitism" 20 | }, 21 | "image": { 22 | "url": "http://prod-upp-image-read.ft.com/1005ca96-364b-11e8-8b98-2f31af407cc8", 23 | "width": 2048, 24 | "height": 1152 25 | }, 26 | "headshot": "fthead-v1:gideon-rachman", 27 | "indicators": { 28 | "isOpinion": true, 29 | "isColumn": true 30 | }, 31 | "showHeadshot": true, 32 | "status": "", 33 | "headshotTint": "", 34 | "accessLevel": "free", 35 | "theme": "", 36 | "parentTheme": "", 37 | "modifiers": "" 38 | } 39 | -------------------------------------------------------------------------------- /components/x-interaction/src/concerns/register-component.js: -------------------------------------------------------------------------------- 1 | const registeredComponents = {} 2 | const xInteractionName = Symbol('x-interaction-name') 3 | 4 | export function registerComponent(Component, name) { 5 | if (registeredComponents[name]) { 6 | throw new Error( 7 | `x-interaction a component has already been registered under that name, please use another name.` 8 | ) 9 | } 10 | 11 | if (!Component._wraps) { 12 | throw new Error( 13 | `only x-interaction wrapped components (i.e. the component returned from withActions) can be registered` 14 | ) 15 | } 16 | 17 | Component[xInteractionName] = name 18 | // add name to original component so we can access the wrapper from the original 19 | Component._wraps.Component[xInteractionName] = name 20 | registeredComponents[name] = Component 21 | } 22 | 23 | export function getComponent(Component) { 24 | const name = Component[xInteractionName] 25 | return registeredComponents[name] 26 | } 27 | 28 | export function getComponentByName(name) { 29 | return registeredComponents[name] 30 | } 31 | 32 | export function getComponentName(Component) { 33 | return Component[xInteractionName] || 'Unknown' 34 | } 35 | -------------------------------------------------------------------------------- /components/x-teaser/__fixtures__/article-with-data-image.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "article", 3 | "id": "", 4 | "url": "#", 5 | "title": "Inside charity fundraiser where hostesses are put on show", 6 | "altTitle": "Men Only, the charity fundraiser with hostesses on show", 7 | "standfirst": "FT investigation finds groping and sexual harassment at secretive black-tie dinner", 8 | "altStandfirst": "Groping and sexual harassment at black-tie dinner charity event", 9 | "publishedDate": "2018-01-23T15:07:00.000Z", 10 | "firstPublishedDate": "2018-01-23T13:53:00.000Z", 11 | "metaPrefixText": "", 12 | "metaSuffixText": "", 13 | "metaLink": { 14 | "url": "#", 15 | "prefLabel": "Sexual misconduct allegations" 16 | }, 17 | "metaAltLink": { 18 | "url": "#", 19 | "prefLabel": "FT Investigations" 20 | }, 21 | "image": { 22 | "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==", 23 | "width": 2048, 24 | "height": 1152, 25 | "imageLazyLoad": "js-image-lazy-load" 26 | }, 27 | "indicators": { 28 | "isEditorsChoice": true 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /components/x-gift-article/src/GiftLinkSection.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | import { SharedLinkTypeSelector } from './SharedLinkTypeSelector' 3 | import { ShareType } from './lib/constants' 4 | import { UrlSection } from './UrlSection' 5 | import { CreateLinkButton } from './CreateLinkButton' 6 | import { FreeArticleAlert } from './FreeArticleAlert' 7 | import { IncludeHighlights } from './IncludeHighlights' 8 | 9 | export const GiftLinkSection = (props) => { 10 | const { isGiftUrlCreated, shareType, isNonGiftUrlShortened, showFreeArticleAlert, showHighlightsCheckbox } = 11 | props 12 | 13 | // when the gift url is created or the non-gift url is shortened, show the url section 14 | if ( 15 | isGiftUrlCreated || 16 | (shareType === ShareType.nonGift && isNonGiftUrlShortened && !showFreeArticleAlert) 17 | ) { 18 | return 19 | } 20 | 21 | return ( 22 |
    23 | {showFreeArticleAlert && } 24 | {!showFreeArticleAlert && } 25 | {showHighlightsCheckbox && } 26 | 27 |
    28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /components/x-teaser/__fixtures__/article.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "article", 3 | "id": "", 4 | "url": "#", 5 | "title": "Inside charity fundraiser where hostesses are put on show", 6 | "altTitle": "Men Only, the charity fundraiser with hostesses on show", 7 | "standfirst": "FT investigation finds groping and sexual harassment at secretive black-tie dinner", 8 | "altStandfirst": "Groping and sexual harassment at black-tie dinner charity event", 9 | "publishedDate": "2018-01-23T15:07:00.000Z", 10 | "firstPublishedDate": "2018-01-23T13:53:00.000Z", 11 | "metaPrefixText": "", 12 | "metaSuffixText": "", 13 | "metaLink": { 14 | "url": "#", 15 | "prefLabel": "Sexual misconduct allegations" 16 | }, 17 | "metaAltLink": { 18 | "url": "#", 19 | "prefLabel": "FT Investigations" 20 | }, 21 | "image": { 22 | "url": "http://prod-upp-image-read.ft.com/a25832ea-0053-11e8-9650-9c0ad2d7c5b5", 23 | "width": 2048, 24 | "height": 1152 25 | }, 26 | "indicators": { 27 | "isEditorsChoice": true 28 | }, 29 | "status": "", 30 | "headshotTint": "", 31 | "accessLevel": "free", 32 | "theme": "", 33 | "parentTheme": "", 34 | "modifiers": "" 35 | } 36 | -------------------------------------------------------------------------------- /components/x-increment/__tests__/x-increment.test.jsx: -------------------------------------------------------------------------------- 1 | const { h } = require('@financial-times/x-engine') 2 | const { mount } = require('@financial-times/x-test-utils/enzyme') 3 | 4 | const { Increment } = require('../') 5 | 6 | describe('x-increment', () => { 7 | it('should increment when action is triggered', async () => { 8 | const subject = mount() 9 | await subject.find('BaseIncrement').prop('actions').increment() 10 | 11 | expect(subject.find('span').text()).toEqual('2') 12 | }) 13 | 14 | it('should increment by amount from action arg', async () => { 15 | const subject = mount() 16 | await subject.find('BaseIncrement').prop('actions').increment({ amount: 2 }) 17 | 18 | expect(subject.find('span').text()).toEqual('3') 19 | }) 20 | 21 | it('should increment when clicked, waiting for timeout', async () => { 22 | const subject = mount() 23 | const start = Date.now() 24 | 25 | await subject.find('button').prop('onClick')() 26 | 27 | expect(Date.now() - start).toBeCloseTo(1000, -2) // negative precision ⇒ left of decimal point 28 | expect(subject.find('span').text()).toEqual('2') 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | // This configuration extends the existing Storybook Webpack config. 2 | // See https://storybook.js.org/configurations/custom-webpack-config/ for more info. 3 | 4 | const glob = require('glob') 5 | const xEngine = require('../packages/x-engine/src/webpack') 6 | const WritePlugin = require('write-file-webpack-plugin') 7 | 8 | module.exports = ({ config }) => { 9 | config.module.rules.push({ 10 | test: /\.(scss|sass)$/, 11 | use: [ 12 | { 13 | loader: require.resolve('style-loader') 14 | }, 15 | { 16 | loader: require.resolve('css-loader'), 17 | options: { 18 | url: false, 19 | import: false, 20 | importLoaders: 2 21 | } 22 | }, 23 | { 24 | loader: require.resolve('postcss-loader'), 25 | options: { 26 | postcssOptions: { 27 | plugins: [[require.resolve('postcss-import')]] 28 | } 29 | } 30 | }, 31 | { 32 | loader: require.resolve('sass-loader'), 33 | options: { 34 | sassOptions: { 35 | includePaths: glob.sync('./components/*/node_modules', { absolute: true }) 36 | } 37 | } 38 | } 39 | ] 40 | }) 41 | 42 | config.plugins.push(xEngine(), new WritePlugin()) 43 | 44 | return config 45 | } 46 | -------------------------------------------------------------------------------- /components/x-privacy-manager/src/privacy-manager.scss: -------------------------------------------------------------------------------- 1 | @import '@financial-times/o3-foundation/css/core.css'; 2 | @import '@financial-times/o3-button/css/core.css'; 3 | @import '@financial-times/o-grid/main'; 4 | 5 | @import './components/radio-btn'; 6 | 7 | %vertical-middle { 8 | display: inline-block; 9 | vertical-align: middle; 10 | } 11 | 12 | .x-privacy-manager__spinner { 13 | @extend %vertical-middle; 14 | } 15 | 16 | .x-privacy-manager__loading { 17 | @extend %vertical-middle; 18 | margin-left: var(--o3-spacing-2xs); 19 | } 20 | 21 | .x-privacy-manager { 22 | display: grid; 23 | gap: var(--o3-spacing-m); 24 | 25 | & * { 26 | box-sizing: border-box; 27 | } 28 | } 29 | 30 | .x-privacy-manager__consent-copy { 31 | padding: 0 var(--o3-spacing-xl); 32 | font-weight: 600; 33 | text-align: center; 34 | } 35 | 36 | .x-privacy-manager-form { 37 | display: grid; 38 | gap: var(--o3-spacing-m); 39 | } 40 | 41 | .x-privacy-manager-form__controls { 42 | @include oGridRespondTo($from: S) { 43 | display: flex; 44 | } 45 | 46 | margin-top: var(--o3-spacing-s); 47 | } 48 | 49 | .x-privacy-manager-form__submit { 50 | justify-self: center; 51 | 52 | display: block; 53 | padding: 0 var(--o3-spacing-xl); 54 | } 55 | -------------------------------------------------------------------------------- /components/x-teaser/src/concerns/date-time.js: -------------------------------------------------------------------------------- 1 | import { Newish, Recent } from './constants' 2 | 3 | /** 4 | * To Date 5 | * @param {Date | String | Number} date 6 | * @returns {Date} 7 | */ 8 | export function toDate(date) { 9 | if (typeof date === 'string') { 10 | return new Date(date) 11 | } 12 | 13 | if (typeof date === 'number') { 14 | return new Date(date) 15 | } 16 | 17 | return date 18 | } 19 | 20 | /** 21 | * Get Relative Date 22 | * @param {Date | String | Number} date 23 | * @returns {Number} 24 | */ 25 | export function getRelativeDate(date) { 26 | return Date.now() - toDate(date).getTime() 27 | } 28 | 29 | /** 30 | * Get Status 31 | * @param {Date | String | Number} publishedDate 32 | * @param {Date | String | Number} firstPublishedDate 33 | * @returns {String} 34 | */ 35 | export function getStatus(publishedDate, firstPublishedDate) { 36 | if (getRelativeDate(publishedDate) < Newish) { 37 | if (publishedDate === firstPublishedDate) { 38 | return 'new' 39 | } else { 40 | return 'updated' 41 | } 42 | } 43 | 44 | return '' 45 | } 46 | 47 | /** 48 | * Is Recent 49 | * @param {Number} relativeDate 50 | * @returns {Boolean} 51 | */ 52 | export function isRecent(relativeDate) { 53 | return relativeDate < Recent 54 | } 55 | -------------------------------------------------------------------------------- /components/x-gift-article/src/lib/tracking.js: -------------------------------------------------------------------------------- 1 | function dispatchEvent(detail) { 2 | const event = new CustomEvent('oTracking.event', { 3 | detail, 4 | bubbles: true 5 | }) 6 | 7 | document.body.dispatchEvent(event) 8 | } 9 | 10 | module.exports = { 11 | createGiftLink: (link, longUrl) => 12 | dispatchEvent({ 13 | category: 'gift-link', 14 | action: 'create', 15 | linkType: 'giftLink', 16 | link, 17 | longUrl 18 | }), 19 | 20 | createESLink: (link) => 21 | dispatchEvent({ 22 | category: 'gift-link', 23 | action: 'create', 24 | linkType: 'enterpriseLink', 25 | link 26 | }), 27 | 28 | createNonGiftLink: (link, longUrl) => 29 | dispatchEvent({ 30 | category: 'gift-link', 31 | action: 'create', 32 | linkType: 'nonGiftLink', 33 | link, 34 | longUrl 35 | }), 36 | 37 | initEnterpriseSharing: (status) => 38 | dispatchEvent({ 39 | category: 'gift-link', 40 | action: 'open', 41 | status 42 | }), 43 | 44 | copyLink: (linkType, link) => 45 | dispatchEvent({ 46 | category: 'gift-link', 47 | action: 'copy', 48 | linkType, 49 | link 50 | }), 51 | 52 | emailLink: (linkType, link) => 53 | dispatchEvent({ 54 | category: 'gift-link', 55 | action: 'mailto', 56 | linkType, 57 | link 58 | }) 59 | } 60 | -------------------------------------------------------------------------------- /components/x-teaser/__tests__/snapshots.test.js: -------------------------------------------------------------------------------- 1 | const { h } = require('@financial-times/x-engine') 2 | const renderer = require('react-test-renderer') 3 | const { Teaser, presets } = require('../') 4 | 5 | const storyData = { 6 | article: require('../__fixtures__/article.json'), 7 | 'article-with-data-image': require('../__fixtures__/article-with-data-image.json'), 8 | 'article-with-missing-image-url': require('../__fixtures__/article-with-missing-image-url.json'), 9 | opinion: require('../__fixtures__/opinion.json'), 10 | contentPackage: require('../__fixtures__/content-package.json'), 11 | packageItem: require('../__fixtures__/package-item.json'), 12 | podcast: require('../__fixtures__/podcast.json'), 13 | video: require('../__fixtures__/video.json'), 14 | promoted: require('../__fixtures__/promoted.json'), 15 | topStory: require('../__fixtures__/top-story.json') 16 | } 17 | 18 | describe('x-teaser / snapshots', () => { 19 | Object.entries(storyData).forEach(([type, data]) => { 20 | Object.entries(presets).forEach(([name, settings]) => { 21 | it(`renders a ${name} teaser with ${type} data`, () => { 22 | const props = { ...data, ...settings } 23 | const tree = renderer.create(h(Teaser, props)).toJSON() 24 | 25 | expect(tree).toMatchSnapshot() 26 | }) 27 | }) 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /components/x-teaser-timeline/storybook/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { TeaserTimeline } from '../src/TeaserTimeline' 3 | import BuildService from '../../../.storybook/build-service' 4 | import items from './content-items.json' 5 | 6 | import '../src/TeaserTimeline.scss' 7 | 8 | const dependencies = { 9 | 'o-date': '^7.0.1', 10 | 'o-labels': '^7.1.0', 11 | 'o-teaser': '^9.1.0', 12 | 'o-video': '^8.0.0' 13 | } 14 | 15 | export default { 16 | title: 'x-teaser-timeline' 17 | } 18 | 19 | export const Timeline = (args) => { 20 | return ( 21 |
    22 | {dependencies && } 23 | 24 |
    25 | ) 26 | } 27 | 28 | Timeline.args = { 29 | items, 30 | timezoneOffset: -60, 31 | localTodayDate: '2018-10-17', 32 | latestItemsTime: '2018-10-17T12:10:33.000Z', 33 | customSlotContent: 'Custom slot content', 34 | customSlotPosition: 3 35 | } 36 | Timeline.argTypes = { 37 | latestItemsTime: { 38 | control: { type: 'select', options: { None: '', '2018-10-17T12:10:33.000Z': '2018-10-17T12:10:33.000Z' } } 39 | }, 40 | customSlotContent: { 41 | control: { type: 'select', options: { None: '', Something: '---Custom slot content---' } } 42 | }, 43 | allowLiveTeaserStyling: { 44 | control: 'boolean' 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.storybook/build-service.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Helmet } from 'react-helmet' 3 | 4 | function buildServiceUrl(deps, type) { 5 | const modules = Object.keys(deps) 6 | .map((i) => `${i}@${deps[i]}`) 7 | .join(',') 8 | return `https://www.ft.com/__origami/service/build/v3/bundles/${type}?components=${modules}&brand=core&system_code=$$$-no-bizops-system-code-$$$` 9 | } 10 | 11 | class BuildService extends React.Component { 12 | constructor(props) { 13 | super(props) 14 | this.initialised = [] 15 | } 16 | 17 | componentDidUpdate() { 18 | if (window.hasOwnProperty('Origami')) { 19 | for (const component in Origami) { 20 | if (typeof Origami[component].init === 'function') { 21 | const instance = Origami[component].init() 22 | this.initialised.concat(instance) 23 | } 24 | } 25 | } 26 | } 27 | 28 | componentWillUnmount() { 29 | this.initialised.forEach((instance) => { 30 | if (typeof instance.destroy === 'function') { 31 | instance.destroy() 32 | } 33 | }) 34 | } 35 | 36 | render() { 37 | const js = buildServiceUrl(this.props.dependencies, 'js') 38 | const css = buildServiceUrl(this.props.dependencies, 'css') 39 | 40 | return ( 41 | 42 | 43 | 44 | 45 | ) 46 | } 47 | } 48 | 49 | export default BuildService 50 | -------------------------------------------------------------------------------- /components/x-teaser-list/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@financial-times/x-teaser-list", 3 | "version": "0.0.0", 4 | "description": "", 5 | "main": "dist/TeaserList.cjs.js", 6 | "module": "dist/TeaserList.esm.js", 7 | "browser": "dist/TeaserList.es5.js", 8 | "style": "src/TeaserList.scss", 9 | "scripts": { 10 | "build": "node rollup.js", 11 | "start": "node rollup.js --watch" 12 | }, 13 | "keywords": [ 14 | "x-dash" 15 | ], 16 | "author": "", 17 | "license": "ISC", 18 | "dependencies": { 19 | "@financial-times/x-article-save-button": "file:../x-article-save-button", 20 | "@financial-times/x-engine": "file:../../packages/x-engine", 21 | "@financial-times/x-teaser": "file:../x-teaser" 22 | }, 23 | "devDependencies": { 24 | "@financial-times/x-rollup": "file:../../packages/x-rollup", 25 | "sass": "^1.49.0" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "https://github.com/Financial-Times/x-dash.git" 30 | }, 31 | "homepage": "https://github.com/Financial-Times/x-dash/tree/master/components/x-teaser-list", 32 | "engines": { 33 | "node": "16.x || 18.x || 20.x" 34 | }, 35 | "publishConfig": { 36 | "access": "public" 37 | }, 38 | "volta": { 39 | "extends": "../../package.json" 40 | }, 41 | "peerDependencies": { 42 | "@financial-times/o3-foundation": "^3.1.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /components/x-teaser/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@financial-times/x-teaser", 3 | "version": "0.0.0", 4 | "description": "This module provides templates for use with o-teaser. Teasers are used to present content.", 5 | "source": "src/Teaser.jsx", 6 | "main": "dist/Teaser.cjs.js", 7 | "module": "dist/Teaser.esm.js", 8 | "browser": "dist/Teaser.es5.js", 9 | "style": "src/Teaser.scss", 10 | "types": "Props.d.ts", 11 | "scripts": { 12 | "build": "node rollup.js", 13 | "start": "node rollup.js --watch" 14 | }, 15 | "keywords": [ 16 | "x-dash" 17 | ], 18 | "author": "", 19 | "license": "ISC", 20 | "dependencies": { 21 | "@financial-times/x-engine": "file:../../packages/x-engine", 22 | "date-fns": "^2.30.0", 23 | "dateformat": "^3.0.3" 24 | }, 25 | "devDependencies": { 26 | "@financial-times/x-rollup": "file:../../packages/x-rollup", 27 | "check-engine": "^1.10.1" 28 | }, 29 | "repository": { 30 | "type": "git", 31 | "url": "https://github.com/Financial-Times/x-dash.git" 32 | }, 33 | "homepage": "https://github.com/Financial-Times/x-dash/tree/HEAD/components/x-teaser", 34 | "engines": { 35 | "node": "16.x || 18.x || 20.x", 36 | "npm": "7.x || 8.x || 9.x || 10.x" 37 | }, 38 | "publishConfig": { 39 | "access": "public" 40 | }, 41 | "volta": { 42 | "extends": "../../package.json" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 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. 2 | 3 | ## If you're creating a component: 4 | 5 | - Add the `Component` label to this Pull Request 6 | - 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 7 | - 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) 8 | - put a link to this Pull Request in the Project description 9 | - set the Project to `Automated kanban with reviews`, but remove the `To Do` column 10 | - If you're not using this workflow, add this Pull Request to the [Components board](https://github.com/Financial-Times/x-dash/projects/4). 11 | 12 | ## 13 | 14 | - Discuss features first 15 | - Update the documentation 16 | - **Must** be tested in FT.com and Apps before merge 17 | - No hacks, experiments or temporary workarounds 18 | - Reviewers are empowered to say no 19 | - Reference other issues 20 | - Update affected stories and snapshots 21 | - Follow the code style 22 | - Decide on a version (major, minor, or patch) 23 | -------------------------------------------------------------------------------- /components/x-interaction/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@financial-times/x-interaction", 3 | "version": "0.0.0", 4 | "description": "This module enables you to write x-dash components that respond to events and change their own data.", 5 | "source": "src/Interaction.jsx", 6 | "main": "dist/Interaction.cjs.js", 7 | "module": "dist/Interaction.esm.js", 8 | "browser": "dist/Interaction.es5.js", 9 | "scripts": { 10 | "build": "node rollup.js", 11 | "start": "node rollup.js --watch" 12 | }, 13 | "keywords": [ 14 | "x-dash" 15 | ], 16 | "author": "", 17 | "license": "ISC", 18 | "dependencies": { 19 | "@financial-times/x-engine": "file:../../packages/x-engine", 20 | "@quarterto/short-id": "^1.1.0" 21 | }, 22 | "devDependencies": { 23 | "@financial-times/x-rollup": "file:../../packages/x-rollup", 24 | "@financial-times/x-test-utils": "file:../../packages/x-test-utils", 25 | "check-engine": "^1.10.1" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "https://github.com/Financial-Times/x-dash.git" 30 | }, 31 | "homepage": "https://github.com/Financial-Times/x-dash/tree/HEAD/components/x-interaction", 32 | "engines": { 33 | "node": "16.x || 18.x || 20.x", 34 | "npm": "7.x || 8.x || 9.x || 10.x" 35 | }, 36 | "publishConfig": { 37 | "access": "public" 38 | }, 39 | "volta": { 40 | "extends": "../../package.json" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /components/x-teaser/src/Status.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | import TimeStamp from './TimeStamp' 3 | import RelativeTime from './RelativeTime' 4 | import LiveBlogStatus from './LiveBlogStatus' 5 | import AlwaysShowTimestamp from './AlwaysShowTimestamp' 6 | import PremiumLabel from './PremiumLabel' 7 | import ScoopLabel from './ScoopLabel' 8 | 9 | export default (props) => { 10 | if (props.showStatus && props.status) { 11 | return 12 | } 13 | 14 | if ( 15 | props.showScoopLabel && 16 | props?.indicators?.isScoop && 17 | // We plan to show the Scoop label only on homepages. 18 | // If we later show it on other pages, this cutoff date will need review. 19 | // The `isScoop` property already exists, but Editorial will use it differently after 2025-10-01. 20 | new Date(props.firstPublishedDate) >= new Date('2025-10-01T00:00:00.000Z') 21 | ) { 22 | return 23 | } 24 | 25 | if (props.showPremiumLabel && props?.indicators?.accessLevel === 'premium') { 26 | return 27 | } 28 | 29 | if (props.showStatus && props.publishedDate) { 30 | if (props.useRelativeTimeIfToday) { 31 | return 32 | } else if (props.useRelativeTime) { 33 | return 34 | } else { 35 | return 36 | } 37 | } 38 | 39 | return null 40 | } 41 | -------------------------------------------------------------------------------- /components/x-topic-search/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@financial-times/x-topic-search", 3 | "version": "0.0.0", 4 | "description": "", 5 | "main": "dist/TopicSearch.cjs.js", 6 | "module": "dist/TopicSearch.esm.js", 7 | "browser": "dist/TopicSearch.es5.js", 8 | "style": "src/TopicSearch.scss", 9 | "scripts": { 10 | "build": "node rollup.js", 11 | "start": "node rollup.js --watch" 12 | }, 13 | "keywords": [ 14 | "x-dash" 15 | ], 16 | "author": "", 17 | "license": "ISC", 18 | "dependencies": { 19 | "@financial-times/x-engine": "file:../../packages/x-engine", 20 | "@financial-times/x-follow-button": "file:../x-follow-button", 21 | "debounce-promise": "^3.1.0" 22 | }, 23 | "devDependencies": { 24 | "@financial-times/x-rollup": "file:../../packages/x-rollup", 25 | "@financial-times/x-test-utils": "file:../../packages/x-test-utils", 26 | "sass": "^1.49.0" 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "https://github.com/Financial-Times/x-dash.git" 31 | }, 32 | "homepage": "https://github.com/Financial-Times/x-dash/tree/master/components/x-topic-search", 33 | "engines": { 34 | "node": "16.x || 18.x || 20.x" 35 | }, 36 | "publishConfig": { 37 | "access": "public" 38 | }, 39 | "volta": { 40 | "extends": "../../package.json" 41 | }, 42 | "peerDependencies": { 43 | "@financial-times/o3-foundation": "^3.1.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /components/x-article-save-button/src/ArticleSaveButton.scss: -------------------------------------------------------------------------------- 1 | $system-code: 'github:Financial-Times/x-dash' !default; 2 | 3 | @import '@financial-times/o3-foundation/css/core.css'; 4 | 5 | $icon-size: 38px; 6 | 7 | .x-article-save-button { 8 | display: inline-block; 9 | } 10 | 11 | .x-article-save-button__button { 12 | 13 | line-height: var(--o3-font-lineheight-metric2-negative-2); 14 | font-size: var(--o3-font-size-metric2-negative-2); 15 | font-family: var(--o3-font-family-metric); 16 | border: 0; 17 | width: $icon-size; 18 | padding: 0; 19 | color: var(--o3-color-palette-black); 20 | background-color: transparent; 21 | text-align: center; 22 | 23 | &:focus { 24 | outline: none; 25 | } 26 | 27 | // Only apply hover state for non-touch-device 28 | body:not(.touch-device) &:not(:focus):hover .x-article-save-button__icon { 29 | background-color: var(--o3-color-palette-black-50); 30 | mask-image: var(--o3-icon-bookmark-filled); 31 | } 32 | 33 | &[aria-pressed='true'] .x-article-save-button__icon { 34 | background-color: var(--o3-color-palette-claret); 35 | mask-image: var(--o3-icon-bookmark-filled); 36 | } 37 | } 38 | 39 | .x-article-save-button__icon { 40 | display: inline-block; 41 | width: 24px; 42 | height: 24px; 43 | mask-repeat: no-repeat; 44 | mask-size: contain; 45 | margin: var(--o3-spacing-4xs) 0 -3px; 46 | 47 | background-color: var(--o3-color-palette-black); 48 | mask-image: var(--o3-icon-bookmark); 49 | } 50 | -------------------------------------------------------------------------------- /components/x-teaser/src/RelativeTime.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | import { isRecent, getRelativeDate, getStatus } from './concerns/date-time' 3 | import dateformat from 'dateformat' 4 | 5 | /** 6 | * Display Time 7 | * @param {Number} date 8 | * @returns {String} 9 | */ 10 | const displayTime = (date) => { 11 | const hours = Math.floor(Math.abs(date / 3600000)) 12 | const plural = hours === 1 ? 'hour' : 'hours' 13 | const suffix = hours === 0 ? '' : `${plural} ago` 14 | 15 | return `${hours} ${suffix}` 16 | } 17 | 18 | export default ({ publishedDate, firstPublishedDate, showAlways = false }) => { 19 | const relativeDate = getRelativeDate(publishedDate) 20 | const status = getStatus(publishedDate, firstPublishedDate) 21 | 22 | return showAlways === true || isRecent(relativeDate) ? ( 23 |
    24 | {status ? ( 25 | {` ${status} `} 26 | ) : ( 27 | 36 | )} 37 |
    38 | ) : null 39 | } 40 | -------------------------------------------------------------------------------- /components/x-topic-search/src/SuggestionList.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | import { FollowButton } from '@financial-times/x-follow-button' 3 | 4 | const defaultFollowButtonRender = (concept, csrfToken, followedTopicIds) => ( 5 | 11 | ) 12 | 13 | export default ({ suggestions, renderFollowButton, searchTerm, csrfToken, followedTopicIds = [] }) => { 14 | renderFollowButton = 15 | typeof renderFollowButton === 'function' ? renderFollowButton : defaultFollowButtonRender 16 | 17 | return ( 18 |
      19 | {suggestions.map((suggestion) => ( 20 |
    • 27 | 32 | {suggestion.prefLabel} 33 | 34 | 35 | {renderFollowButton(suggestion, csrfToken, followedTopicIds)} 36 |
    • 37 | ))} 38 |
    39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /components/x-gift-article/src/Header.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | import { ShareType } from './lib/constants' 3 | 4 | export const Header = (props) => { 5 | const { 6 | title, 7 | isGiftUrlCreated, 8 | shareType, 9 | isNonGiftUrlShortened, 10 | showFreeArticleAlert, 11 | isMPRArticle, 12 | enterpriseEnabled, 13 | enterpriseRequestAccess 14 | } = props 15 | // when a gift link is created or shortened, the title is "Sharing link" 16 | if ( 17 | isGiftUrlCreated || 18 | (shareType === ShareType.nonGift && isNonGiftUrlShortened && !showFreeArticleAlert) 19 | ) { 20 | return ( 21 |
    22 |

    Sharing link

    23 |
    24 | ) 25 | } 26 | 27 | if (isMPRArticle) { 28 | return ( 29 |
    30 |

    31 | 32 | {enterpriseEnabled && !enterpriseRequestAccess 33 | ? 'Share this article using:' 34 | : 'Share this article'} 35 | 36 |

    37 |
    38 | ) 39 | } 40 | 41 | return ( 42 |
    43 |

    44 | 45 | {title} 46 | 47 |

    48 |
    49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /components/x-teaser-timeline/src/TeaserTimeline.scss: -------------------------------------------------------------------------------- 1 | @import '@financial-times/o3-foundation/css/core.css'; 2 | @import '@financial-times/o-grid/main'; 3 | 4 | @import '@financial-times/x-article-save-button/src/ArticleSaveButton'; 5 | @import '@financial-times/x-teaser/src/Teaser'; 6 | 7 | .x-teaser-timeline__item-group { 8 | border-top: 4px solid var(--o3-color-palette-black); 9 | 10 | @include oGridRespondTo($from: M) { 11 | display: grid; 12 | grid-gap: 0 20px; 13 | grid-template-columns: 1fr 3fr; 14 | grid-template-areas: 'heading articles'; 15 | } 16 | } 17 | 18 | .x-teaser-timeline__heading { 19 | margin-top: var(--o3-spacing-4xs); 20 | margin-bottom: var(--o3-spacing-4xs); 21 | 22 | @include oGridRespondTo($from: M) { 23 | grid-area: heading; 24 | -ms-grid-row: 1; 25 | -ms-grid-column: 1; 26 | } 27 | } 28 | 29 | .x-teaser-timeline__items { 30 | list-style-type: none; 31 | padding: 0; 32 | margin-top: var(--o3-spacing-4xs); 33 | 34 | @include oGridRespondTo($from: M) { 35 | grid-area: articles; 36 | -ms-grid-row: 1; 37 | -ms-grid-column: 3; 38 | } 39 | } 40 | 41 | .x-teaser-timeline__item { 42 | display: flex; 43 | justify-content: space-between; 44 | margin-bottom: var(--o3-spacing-4xs); 45 | 46 | :global { 47 | .o-teaser--timeline-teaser { 48 | border-bottom: 0; 49 | padding-bottom: 0; 50 | } 51 | } 52 | } 53 | 54 | .x-teaser-timeline__item-actions { 55 | flex: 0 1 auto; 56 | padding-left: 10px; 57 | } 58 | -------------------------------------------------------------------------------- /components/x-gift-article/src/CreateLinkButton.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | import { ShareType } from './lib/constants' 3 | import oShare from '@financial-times/o-share/main' 4 | import { canShareWithNonSubscribers, isNonSubscriberOption } from './lib/highlightsHelpers' 5 | 6 | export const CreateLinkButton = (props) => { 7 | const { shareType, actions, enterpriseEnabled, isFreeArticle, isRegisteredUser } = props 8 | 9 | const _canShareWithNonSubscribers = canShareWithNonSubscribers(props) 10 | const _isNonSubscriberOption = isNonSubscriberOption(props) 11 | 12 | const createLinkHandler = async () => { 13 | switch (shareType) { 14 | case ShareType.gift: 15 | await actions.createGiftUrl() 16 | break 17 | case ShareType.nonGift: 18 | await actions.shortenNonGiftUrl() 19 | break 20 | case ShareType.enterprise: 21 | await actions.createEnterpriseUrl() 22 | break 23 | default: 24 | } 25 | new oShare(document.querySelector('#social-share-buttons')) 26 | } 27 | return ( 28 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /components/x-teaser/src/MetaLink.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | 3 | const sameId = (context = {}, id) => { 4 | return id && context && context.parentId && id === context.parentId 5 | } 6 | 7 | const sameLabel = (context = {}, label) => { 8 | return label && context && context.parentLabel && label === context.parentLabel 9 | } 10 | 11 | export default ({ metaPrefixText, metaLink, metaAltLink, metaSuffixText, context }) => { 12 | const showPrefixText = metaPrefixText && !sameLabel(context, metaPrefixText) 13 | const showSuffixText = metaSuffixText && !sameLabel(context, metaSuffixText) 14 | const linkId = metaLink && metaLink.id 15 | const linkLabel = metaLink && metaLink.prefLabel 16 | const useAltLink = sameId(context, linkId) || sameLabel(context, linkLabel) 17 | const displayLink = useAltLink ? metaAltLink : metaLink 18 | 19 | return ( 20 |
    21 | {showPrefixText ? {metaPrefixText} : null} 22 | {displayLink?.prefLabel ? ( 23 | 29 | {displayLink.prefLabel} 30 | 31 | ) : null} 32 | {showSuffixText ? {metaSuffixText} : null} 33 |
    34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /packages/x-rollup/src/rollup-config.js: -------------------------------------------------------------------------------- 1 | const babel = require('rollup-plugin-babel') 2 | const commonjs = require('rollup-plugin-commonjs') 3 | const babelConfig = require('./babel-config') 4 | 5 | module.exports = ({ input, pkg }) => { 6 | // Don't bundle any dependencies 7 | const external = [...Object.keys(pkg.dependencies), ...Object.keys(pkg.peerDependencies || {})] 8 | 9 | const plugins = [ 10 | // Convert CommonJS modules to ESM so they can be included in the bundle 11 | commonjs({ extensions: ['.js', '.jsx'] }) 12 | ] 13 | 14 | // Pairs of input and output options 15 | return [ 16 | [ 17 | { 18 | input, 19 | external, 20 | plugins: [ 21 | babel( 22 | babelConfig({ 23 | targets: { node: '16' } 24 | }) 25 | ), 26 | ...plugins 27 | ] 28 | }, 29 | { 30 | file: pkg.module, 31 | format: 'es' 32 | } 33 | ], 34 | [ 35 | { 36 | input, 37 | external, 38 | plugins: [ 39 | babel( 40 | babelConfig({ 41 | targets: { node: '16' } 42 | }) 43 | ), 44 | ...plugins 45 | ] 46 | }, 47 | { 48 | file: pkg.main, 49 | format: 'cjs' 50 | } 51 | ], 52 | [ 53 | { 54 | input, 55 | external, 56 | plugins: [ 57 | babel( 58 | babelConfig({ 59 | targets: { ie: '11' } 60 | }) 61 | ), 62 | ...plugins 63 | ] 64 | }, 65 | { 66 | file: pkg.browser, 67 | format: 'cjs' 68 | } 69 | ] 70 | ] 71 | } 72 | -------------------------------------------------------------------------------- /components/x-interaction/src/concerns/serialiser.js: -------------------------------------------------------------------------------- 1 | import { h, render } from '@financial-times/x-engine' 2 | import { HydrationData } from '../HydrationData' 3 | import { getComponent, getComponentName } from './register-component' 4 | 5 | export class Serialiser { 6 | constructor() { 7 | this.destroyed = false 8 | this.data = [] 9 | } 10 | 11 | addData({ id, Component, props }) { 12 | const registeredComponent = getComponent(Component) 13 | 14 | if (!registeredComponent) { 15 | throw new Error( 16 | `a Serialiser's addData was called for an unregistered ${getComponentName(Component)} component with id ${id}. ensure you're registering your component before attempting to output the hydration data` 17 | ) 18 | } 19 | 20 | if (this.destroyed) { 21 | throw new Error( 22 | `a ${getComponentName(Component)} component was rendered after flushHydrationData was called. ensure you're outputting the hydration data after rendering every component` 23 | ) 24 | } 25 | 26 | this.data.push({ 27 | id, 28 | component: getComponentName(Component), 29 | props 30 | }) 31 | } 32 | 33 | flushHydrationData() { 34 | if (this.destroyed) { 35 | throw new Error( 36 | `a Serialiser's flushHydrationData was called twice. ensure you're not reusing a Serialiser between requests` 37 | ) 38 | } 39 | 40 | this.destroyed = true 41 | return this.data 42 | } 43 | 44 | outputHydrationData() { 45 | return render(h(HydrationData, { serialiser: this })) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /components/x-gift-article/src/ShareArticleDialog.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | import { Header } from './Header' 3 | import { GiftLinkSection } from './GiftLinkSection' 4 | import { Footer } from './Footer' 5 | import { SharingOptionsToggler } from './SharingOptionsToggler' 6 | import { ShareType } from './lib/constants' 7 | 8 | export default (props) => { 9 | const { 10 | isGiftUrlCreated, 11 | shareType, 12 | isNonGiftUrlShortened, 13 | showFreeArticleAlert, 14 | isFreeArticle, 15 | enterpriseEnabled, 16 | enterpriseRequestAccess, 17 | isRegisteredUser, 18 | isMPRArticle 19 | } = props 20 | 21 | return ( 22 | 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | 4 | - package-ecosystem: "npm" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | commit-message: 9 | prefix: "fix:" 10 | prefix-development: "chore:" 11 | groups: 12 | aws-sdk: 13 | patterns: 14 | - "@aws-sdk/*" 15 | update-types: 16 | - "minor" 17 | - "patch" 18 | development-dependencies: 19 | dependency-type: "development" 20 | update-types: 21 | - "minor" 22 | - "patch" 23 | origami: 24 | patterns: 25 | - "@financial-times/o-*" 26 | update-types: 27 | - "minor" 28 | - "patch" 29 | page-kit: 30 | patterns: 31 | - "@financial-times/dotcom-*" 32 | update-types: 33 | - "minor" 34 | - "patch" 35 | privacy: 36 | patterns: 37 | - "@financial-times/privacy-*" 38 | update-types: 39 | - "minor" 40 | - "patch" 41 | reliability-kit: 42 | patterns: 43 | - "@dotcom-reliability-kit/*" 44 | update-types: 45 | - "minor" 46 | - "patch" 47 | tool-kit: 48 | patterns: 49 | - "@dotcom-tool-kit/*" 50 | update-types: 51 | - "minor" 52 | - "patch" 53 | x-dash: 54 | patterns: 55 | - "@financial-times/x-*" 56 | update-types: 57 | - "minor" 58 | - "patch" 59 | -------------------------------------------------------------------------------- /components/x-teaser/src/Video.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | import Image from './Image' 3 | 4 | // Re-format the data for use with o-video 5 | const formatData = (props) => 6 | JSON.stringify({ 7 | renditions: [props.video], 8 | mainImageUrl: props.image ? props.image.url : null 9 | }) 10 | 11 | // To prevent React from touching the DOM after mounting… return an empty
    12 | // 13 | const Embed = (props) => { 14 | const showGuidance = typeof props.showGuidance === 'boolean' ? props.showGuidance.toString() : 'true' 15 | return ( 16 |
    17 |
    32 |
    33 | ) 34 | } 35 | 36 | export default (props) => ( 37 |
    38 |
    39 | 40 |
    41 |
    42 | 43 |
    44 |
    45 | ) 46 | -------------------------------------------------------------------------------- /components/x-article-save-button/src/ArticleSaveButton.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | 3 | export const ArticleSaveButton = (props) => { 4 | const getLabel = (props) => { 5 | if (props.saved) { 6 | return 'Saved to myFT' 7 | } 8 | 9 | return props.contentTitle 10 | ? `Save ${props.contentTitle} to myFT for later` 11 | : 'Save this article to myFT for later' 12 | } 13 | 14 | return ( 15 |
    { 21 | event.preventDefault() 22 | const detail = { 23 | action: props.saved ? 'remove' : 'add', 24 | actorType: 'user', 25 | relationshipName: 'saved', 26 | subjectType: 'content', 27 | subjectId: props.contentId, 28 | token: props.csrfToken 29 | } 30 | 31 | event.target.dispatchEvent(new CustomEvent('x-article-save-button', { bubbles: true, detail })) 32 | }} 33 | > 34 | {props.csrfToken && } 35 | 46 |
    47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /components/x-gift-article/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@financial-times/x-gift-article", 3 | "version": "0.0.0", 4 | "description": "This module provides gift article form", 5 | "main": "dist/GiftArticle.cjs.js", 6 | "browser": "dist/GiftArticle.es5.js", 7 | "module": "dist/GiftArticle.esm.js", 8 | "style": "src/main.scss", 9 | "scripts": { 10 | "build": "node rollup.js", 11 | "start": "node rollup.js --watch" 12 | }, 13 | "keywords": [ 14 | "x-dash" 15 | ], 16 | "author": "", 17 | "license": "ISC", 18 | "dependencies": { 19 | "@financial-times/x-engine": "file:../../packages/x-engine", 20 | "@financial-times/x-interaction": "file:../x-interaction", 21 | "classnames": "^2.2.6" 22 | }, 23 | "devDependencies": { 24 | "@financial-times/x-rollup": "file:../../packages/x-rollup", 25 | "check-engine": "^1.10.1", 26 | "sass": "^1.49.0" 27 | }, 28 | "engines": { 29 | "node": "16.x || 18.x || 20.x", 30 | "npm": "7.x || 8.x || 9.x || 10.x" 31 | }, 32 | "volta": { 33 | "extends": "../../package.json" 34 | }, 35 | "peerDependencies": { 36 | "@financial-times/o-banner": "^5.0.0", 37 | "@financial-times/o-forms": "^10.0.1", 38 | "@financial-times/o-labels": "^7.0.0", 39 | "@financial-times/o-loading": "^6.0.0", 40 | "@financial-times/o-message": "^6.0.0", 41 | "@financial-times/o-share": "^11.0.0", 42 | "@financial-times/o-visual-effects": "^5.0.1", 43 | "@financial-times/o3-button": "^3.0.1", 44 | "@financial-times/o3-foundation": "^3.1.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /components/x-topic-search/storybook/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { TopicSearch } from '../src/TopicSearch' 3 | import BuildService from '../../../.storybook/build-service' 4 | 5 | import '../src/TopicSearch.scss' 6 | 7 | // Set up basic document styling using the Origami build service 8 | const dependencies = { 9 | 'o-fonts': '^5.3.0' 10 | } 11 | 12 | export default { 13 | title: 'x-topic-search' 14 | } 15 | 16 | export const _TopicSearchBar = (args) => { 17 | return ( 18 |
    19 | 20 | 21 |
    22 | ) 23 | } 24 | 25 | _TopicSearchBar.args = { 26 | minSearchLength: 2, 27 | maxSuggestions: 10, 28 | apiUrl: '//tag-facets-api.ft.com/annotations', 29 | followedTopicIds: ['f95d1e16-2307-4feb-b3ff-6f224798aa49'], 30 | csrfToken: 'csrfToken' 31 | } 32 | 33 | _TopicSearchBar.argTypes = { 34 | minSearchLength: { name: 'Minimum search start length' }, 35 | maxSuggestions: { name: 'Maximum sugggestions to show' }, 36 | apiUrl: { name: 'URL of the API to use' }, 37 | followedTopicIds: { 38 | type: 'select', 39 | name: 'Followed Topics', 40 | options: { 41 | None: [], 42 | 'World Elephant Water Polo': ['f95d1e16-2307-4feb-b3ff-6f224798aa49'], 43 | 'Brexit, Britain after Brexit, Brexit Unspun Podcast': [ 44 | '19b95057-4614-45fb-9306-4d54049354db', 45 | '464cc2f2-395e-4c36-bb29-01727fc95558', 46 | 'c4e899ed-157e-4446-86f0-5a65803dc07a' 47 | ] 48 | } 49 | }, 50 | csrfToken: { name: 'CSRF Token' } 51 | } 52 | -------------------------------------------------------------------------------- /components/x-teaser/src/Teaser.jsx: -------------------------------------------------------------------------------- 1 | import { h } from '@financial-times/x-engine' 2 | import Container from './Container' 3 | import Content from './Content' 4 | import CustomSlot from './CustomSlot' 5 | import Headshot from './Headshot' 6 | import Image from './Image' 7 | import Meta from './Meta' 8 | import RelatedLinks from './RelatedLinks' 9 | import Status from './Status' 10 | import Standfirst from './Standfirst' 11 | import Title from './Title' 12 | import Video from './Video' 13 | import PromotionalContent from './PromotionaContent' 14 | import { media } from './concerns/rules' 15 | import presets from './concerns/presets' 16 | 17 | const Teaser = (props) => ( 18 | 19 | 20 | {props.showMeta ? : null} 21 | {media(props) === 'video' ?