/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 |
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 |
15 | click to add to hello
16 |
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 |
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 |
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 | increment()} disabled={isLoading}>
20 | {customSlot}
21 | {isLoading ? 'Loading...' : 'Increment'}
22 |
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 |
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 |
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 |
33 | {/* Let o-date handle anything < 1 hour on the client */}
34 | {displayTime(relativeDate)}
35 |
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 |
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 |
36 | Get sharing link
37 |
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 |
29 |
30 |
31 |
32 | {!isFreeArticle &&
33 | !(
34 | isMPRArticle ||
35 | isGiftUrlCreated ||
36 | isRegisteredUser ||
37 | (shareType === ShareType.nonGift && isNonGiftUrlShortened && !showFreeArticleAlert)
38 | ) ? (
39 |
40 | ) : null}
41 |
42 |
43 |
44 |
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 |
33 | )
34 | }
35 |
36 | export default (props) => (
37 |
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 |
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' ? : null}
22 | {props.showTitle ? : null}
23 | {props.showStandfirst ? : null}
24 |
25 | {props.showCustomSlot ? : null}
26 | {media(props) === 'headshot' ? : null}
27 |
28 | {media(props) === 'promotionalContent' ? : null}
29 | {media(props) === 'image' ? : null}
30 | {props.showRelatedLinks ? : null}
31 |
32 | )
33 |
34 | export {
35 | Container,
36 | Content,
37 | CustomSlot,
38 | Headshot,
39 | Image,
40 | Meta,
41 | RelatedLinks,
42 | Standfirst,
43 | Status,
44 | Teaser,
45 | Title,
46 | Video,
47 | presets
48 | }
49 |
--------------------------------------------------------------------------------
/components/x-gift-article/storybook/share-article-modal.js:
--------------------------------------------------------------------------------
1 | const articleId = 'e4b5ade3-01d1-4db8-b197-257051656684'
2 | const articleUrl = 'https://www.ft.com/content/e4b5ade3-01d1-4db8-b197-257051656684'
3 | const articleUrlRedeemed = 'https://enterprise-sharing.ft.com/gift-url-redeemed'
4 | const nonGiftArticleUrl = `${articleUrl}?shareType=nongift`
5 |
6 | exports.args = {
7 | title: 'Share this article with:',
8 | isFreeArticle: false,
9 | article: {
10 | id: articleId,
11 | url: articleUrl,
12 | title: 'Equinor and Daimler Truck cut Russia ties as Volvo and JLR halt car deliveries'
13 | },
14 | id: 'base-gift-article-static-id',
15 | enterpriseApiBaseUrl: `https://enterprise-sharing-api.ft.com`
16 | }
17 |
18 | exports.fetchMock = (fetchMock) => {
19 | fetchMock
20 | .restore()
21 | .get('path:/article/gift-credits', {
22 | allowance: 20,
23 | consumedCredits: 5,
24 | remainingCredits: 15,
25 | renewalDate: '2018-08-01T00:00:00Z'
26 | })
27 | .get(`path:/article/shorten-url/${encodeURIComponent(articleUrlRedeemed)}`, {
28 | shortenedUrl: 'https://shortened-gift-url'
29 | })
30 | .get(`path:/article/shorten-url/${encodeURIComponent(nonGiftArticleUrl)}`, {
31 | shortenedUrl: 'https://shortened-non-gift-url'
32 | })
33 | .get(`path:/article/gift-link/${encodeURIComponent(articleId)}`, {
34 | redemptionUrl: articleUrlRedeemed,
35 | redemptionLimit: 3,
36 | remainingAllowance: 1
37 | })
38 | .get('path:/v1/users/me/allowance', 403)
39 | .post('path:/v1/shares', {
40 | url: articleUrlRedeemed,
41 | redeemLimit: 120
42 | })
43 | }
44 |
--------------------------------------------------------------------------------
/components/x-gift-article/storybook/share-article-modal-no-credits.jsx:
--------------------------------------------------------------------------------
1 | const articleId = 'e4b5ade3-01d1-4db8-b197-257051656684'
2 | const articleUrl = 'https://www.ft.com/content/e4b5ade3-01d1-4db8-b197-257051656684'
3 | const articleUrlRedeemed = 'https://enterprise-sharing.ft.com/gift-url-redeemed'
4 | const nonGiftArticleUrl = `${articleUrl}?shareType=nongift`
5 |
6 | exports.args = {
7 | title: 'Share this article with:',
8 | isFreeArticle: false,
9 | article: {
10 | id: articleId,
11 | url: articleUrl,
12 | title: 'Equinor and Daimler Truck cut Russia ties as Volvo and JLR halt car deliveries'
13 | },
14 | id: 'base-gift-article-static-id',
15 | enterpriseApiBaseUrl: `https://enterprise-sharing-api.ft.com`
16 | }
17 |
18 | exports.fetchMock = (fetchMock) => {
19 | fetchMock
20 | .restore()
21 | .get('path:/article/gift-credits', {
22 | allowance: 20,
23 | consumedCredits: 20,
24 | remainingCredits: 0,
25 | renewalDate: '2018-08-01T00:00:00Z'
26 | })
27 | .get(`path:/article/shorten-url/${encodeURIComponent(articleUrlRedeemed)}`, {
28 | shortenedUrl: 'https://shortened-gift-url'
29 | })
30 | .get(`path:/article/shorten-url/${encodeURIComponent(nonGiftArticleUrl)}`, {
31 | shortenedUrl: 'https://shortened-non-gift-url'
32 | })
33 | .get(`path:/article/gift-link/${encodeURIComponent(articleId)}`, {
34 | redemptionUrl: articleUrlRedeemed,
35 | redemptionLimit: 3,
36 | remainingAllowance: 1
37 | })
38 | .get('path:/v1/users/me/allowance', 403)
39 | .post('path:/v1/shares', {
40 | url: articleUrlRedeemed,
41 | redeemLimit: 120
42 | })
43 | }
44 |
--------------------------------------------------------------------------------
/components/x-gift-article/storybook/share-article-modal-b2b-free-article.js:
--------------------------------------------------------------------------------
1 | const articleId = 'e4b5ade3-01d1-4db8-b197-257051656684'
2 | const articleUrl = 'https://www.ft.com/content/e4b5ade3-01d1-4db8-b197-257051656684'
3 | const articleUrlRedeemed = 'https://enterprise-sharing.ft.com/gift-url-redeemed'
4 | const nonGiftArticleUrl = `${articleUrl}?shareType=nongift`
5 |
6 | exports.args = {
7 | title: 'Share this article with:',
8 | isFreeArticle: true,
9 | article: {
10 | id: articleId,
11 | url: articleUrl,
12 | title: 'Equinor and Daimler Truck cut Russia ties as Volvo and JLR halt car deliveries'
13 | },
14 | id: 'base-gift-article-static-id',
15 | enterpriseApiBaseUrl: `https://enterprise-sharing-api.ft.com`
16 | }
17 |
18 | exports.fetchMock = (fetchMock) => {
19 | fetchMock
20 | .restore()
21 | .get('path:/article/gift-credits', {
22 | allowance: 20,
23 | consumedCredits: 5,
24 | remainingCredits: 15,
25 | renewalDate: '2018-08-01T00:00:00Z'
26 | })
27 | .get(`path:/article/shorten-url/${encodeURIComponent(articleUrlRedeemed)}`, {
28 | shortenedUrl: 'https://shortened-gift-url'
29 | })
30 | .get(`path:/article/shorten-url/${encodeURIComponent(nonGiftArticleUrl)}`, {
31 | shortenedUrl: 'https://shortened-non-gift-url'
32 | })
33 | .get(`path:/article/gift-link/${encodeURIComponent(articleId)}`, {
34 | redemptionUrl: articleUrlRedeemed,
35 | redemptionLimit: 3,
36 | remainingAllowance: 1
37 | })
38 | .get('path:/v1/users/me/allowance', 403)
39 | .post('path:/v1/shares', {
40 | url: articleUrlRedeemed,
41 | redeemLimit: 120
42 | })
43 | }
44 |
--------------------------------------------------------------------------------
/components/x-privacy-manager/src/components/radio-btn.jsx:
--------------------------------------------------------------------------------
1 | import { h } from '@financial-times/x-engine'
2 |
3 | /**
4 | * @param {{
5 | * name: string,
6 | * type: "allow" | "block",
7 | * checked: boolean,
8 | * trackingKeys: XPrivacyManager.TrackingKeys,
9 | * onChange: (value: boolean) => void,
10 | * }} args
11 | *
12 | * @returns {JSX.Element}
13 | */
14 | export function RadioBtn({ name, type, checked, trackingKeys, buttonText, onChange }) {
15 | const value = type === 'allow'
16 | const id = `${name}-${value}`
17 | const trackingId = trackingKeys[`advertising-toggle-${type}`]
18 |
19 | return (
20 |
21 | onChange(value)}
30 | />
31 |
32 |
33 | {buttonText[type].label}
34 | {buttonText[type].text}
35 |
36 |
37 |
43 |
44 |
45 |
46 |
47 |
48 | )
49 | }
50 |
--------------------------------------------------------------------------------
/components/x-gift-article/storybook/share-article-modal-no-credits-mpr-version.jsx:
--------------------------------------------------------------------------------
1 | const articleId = 'e4b5ade3-01d1-4db8-b197-257051656684'
2 | const articleUrl = 'https://www.ft.com/content/e4b5ade3-01d1-4db8-b197-257051656684'
3 | const articleUrlRedeemed = 'https://enterprise-sharing.ft.com/gift-url-redeemed'
4 | const nonGiftArticleUrl = `${articleUrl}?shareType=nongift`
5 |
6 | exports.args = {
7 | title: 'Share this article',
8 | isFreeArticle: false,
9 | isMPRArticle: true,
10 | article: {
11 | id: articleId,
12 | url: articleUrl,
13 | title: 'Equinor and Daimler Truck cut Russia ties as Volvo and JLR halt car deliveries'
14 | },
15 | id: 'base-gift-article-static-id',
16 | enterpriseApiBaseUrl: `https://enterprise-sharing-api.ft.com`
17 | }
18 |
19 | exports.fetchMock = (fetchMock) => {
20 | fetchMock
21 | .restore()
22 | .get('path:/article/gift-credits', {
23 | allowance: 20,
24 | consumedCredits: 20,
25 | remainingCredits: 0,
26 | renewalDate: '2018-08-01T00:00:00Z'
27 | })
28 | .get(`path:/article/shorten-url/${encodeURIComponent(articleUrlRedeemed)}`, {
29 | shortenedUrl: 'https://shortened-gift-url'
30 | })
31 | .get(`path:/article/shorten-url/${encodeURIComponent(nonGiftArticleUrl)}`, {
32 | shortenedUrl: 'https://shortened-non-gift-url'
33 | })
34 | .get(`path:/article/gift-link/${encodeURIComponent(articleId)}`, {
35 | redemptionUrl: articleUrlRedeemed,
36 | redemptionLimit: 3,
37 | remainingAllowance: 1
38 | })
39 | .get('path:/v1/users/me/allowance', 403)
40 | .post('path:/v1/shares', {
41 | url: articleUrlRedeemed,
42 | redeemLimit: 120
43 | })
44 | }
45 |
--------------------------------------------------------------------------------
/e2e/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "x-dash-e2e",
3 | "version": "0.0.0",
4 | "private": true,
5 | "description": "This module enables you to write x-dash components that respond to events and change their own data.",
6 | "keywords": [
7 | "x-dash"
8 | ],
9 | "author": "",
10 | "license": "ISC",
11 | "main": "index.js",
12 | "x-dash": {
13 | "engine": {
14 | "server": {
15 | "runtime": "react",
16 | "factory": "createElement",
17 | "component": "Component",
18 | "fragment": "Fragment",
19 | "renderModule": "react-dom/server",
20 | "render": "renderToStaticMarkup"
21 | },
22 | "browser": "react"
23 | }
24 | },
25 | "repository": {
26 | "type": "git",
27 | "url": "https://github.com/Financial-Times/x-dash.git"
28 | },
29 | "engines": {
30 | "node": "16.x || 18.x || 20.x",
31 | "npm": "7.x || 8.x || 9.x || 10.x"
32 | },
33 | "publishConfig": {
34 | "access": "public"
35 | },
36 | "dependencies": {
37 | "react": "^17.0.2",
38 | "react-dom": "^17.0.2",
39 | "@financial-times/x-engine": "file:../packages/x-engine",
40 | "@financial-times/x-interaction": "file:../components/x-interaction"
41 | },
42 | "devDependencies": {
43 | "@babel/core": "^7.17.4",
44 | "babel-loader": "^8.2.3",
45 | "check-engine": "^1.10.1",
46 | "puppeteer": "^10.4.0",
47 | "webpack": "^4.46.0",
48 | "webpack-cli": "^4.8.0"
49 | },
50 | "scripts": {
51 | "pretest": "webpack",
52 | "test": "jest -c jest.e2e.config.js"
53 | },
54 | "volta": {
55 | "extends": "../package.json"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/components/x-gift-article/storybook/share-article-modal-b2b-save-highlights-message.jsx:
--------------------------------------------------------------------------------
1 | const articleId = 'e4b5ade3-01d1-4db8-b197-257051656684'
2 | const articleUrl = 'https://www.ft.com/content/e4b5ade3-01d1-4db8-b197-257051656684'
3 | const articleUrlRedeemed = 'https://enterprise-sharing.ft.com/gift-url-redeemed'
4 | const nonGiftArticleUrl = `${articleUrl}?shareType=nongift`
5 |
6 | exports.args = {
7 | title: 'Share this article with:',
8 | isFreeArticle: false,
9 | article: {
10 | id: articleId,
11 | url: articleUrl,
12 | title: 'Equinor and Daimler Truck cut Russia ties as Volvo and JLR halt car deliveries'
13 | },
14 | id: 'base-gift-article-static-id',
15 | enterpriseApiBaseUrl: `https://enterprise-sharing-api.ft.com`,
16 | showHighlightsCheckbox: false
17 | }
18 |
19 | exports.fetchMock = (fetchMock) => {
20 | fetchMock
21 | .restore()
22 | .get('path:/article/gift-credits', {
23 | allowance: 20,
24 | consumedCredits: 5,
25 | remainingCredits: 15,
26 | renewalDate: '2018-08-01T00:00:00Z'
27 | })
28 | .get(`path:/article/shorten-url/${encodeURIComponent(articleUrlRedeemed)}`, {
29 | shortenedUrl: 'https://shortened-gift-url'
30 | })
31 | .get(`path:/article/shorten-url/${encodeURIComponent(nonGiftArticleUrl)}`, {
32 | shortenedUrl: 'https://shortened-non-gift-url'
33 | })
34 | .get(`path:/article/gift-link/${encodeURIComponent(articleId)}`, {
35 | redemptionUrl: articleUrlRedeemed,
36 | redemptionLimit: 3,
37 | remainingAllowance: 1
38 | })
39 | .get('path:/v1/users/me/allowance', 403)
40 | .post('path:/v1/shares', {
41 | url: articleUrlRedeemed,
42 | redeemLimit: 120
43 | })
44 | }
45 |
--------------------------------------------------------------------------------
/components/x-follow-button/storybook/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { FollowButton } from '../src/FollowButton'
3 | import BuildService from '../../../.storybook/build-service'
4 |
5 | import '../src/styles/main.scss'
6 |
7 | const dependencies = {
8 | 'o-fonts': '^5.3.0'
9 | }
10 |
11 | export default {
12 | title: 'x-follow-button'
13 | }
14 |
15 | export const _FollowButton = (args) => (
16 |
17 |
18 |
22 |
26 |
30 |
31 |
Alphaville
32 |
33 |
34 |
35 |
Monochrome
36 |
37 |
38 |
39 |
Inverse Monochrome
40 |
41 |
42 |
43 | )
44 |
45 | _FollowButton.args = {
46 | conceptNameAsButtonText: false,
47 | isFollowed: false,
48 | conceptName: 'UK politics & policy',
49 | followPlusDigestEmail: true
50 | }
51 |
--------------------------------------------------------------------------------
/components/x-teaser/src/concerns/rules.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Rules are sets of exclusive properties.
3 | * They are used to ensure that only one property can take precedence.
4 | */
5 | const rulesets = {
6 | media: (props) => {
7 | // If this condition evaluates to true then no headshot nor image will be displayed.
8 | if (props.showVideo && props.video && props.video.url) {
9 | return 'video'
10 | }
11 |
12 | if (props.showHeadshot && props.headshot && props.indicators.isColumn) {
13 | return 'headshot'
14 | }
15 |
16 | if (props.showImage && props.image && props.image.url) {
17 | return 'image'
18 | }
19 |
20 | if (props.showPromotionalContent && props.promotionalContent) {
21 | return 'promotionalContent'
22 | }
23 | },
24 | theme: (props) => {
25 | if (props.theme) {
26 | return props.theme
27 | }
28 |
29 | if (props.status === 'inprogress' && props.allowLiveTeaserStyling) {
30 | return 'live'
31 | }
32 |
33 | if (props.indicators && props.indicators.isOpinion) {
34 | return 'opinion'
35 | }
36 |
37 | if (props.indicators && props.indicators.isEditorsChoice) {
38 | return 'highlight'
39 | }
40 |
41 | if (props.parentTheme) {
42 | return props.parentTheme
43 | }
44 | }
45 | }
46 |
47 | /**
48 | * Rules
49 | * @param {String} rule
50 | * @param {Props} props
51 | * @returns {String|null}
52 | */
53 | export default function rules(rule, props) {
54 | if (rulesets.hasOwnProperty(rule)) {
55 | return rulesets[rule](props)
56 | } else {
57 | throw Error(`No ruleset available named ${rule}`)
58 | }
59 | }
60 |
61 | export const media = (props) => rules('media', props)
62 | export const theme = (props) => rules('theme', props)
63 |
--------------------------------------------------------------------------------
/components/x-teaser/__fixtures__/top-story.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 | "dataTrackable": "slice:hero-slice-1;slicePos:1;layout:standaloneimage;listPos:1",
12 | "metaPrefixText": "",
13 | "metaSuffixText": "",
14 | "metaLink": {
15 | "url": "#",
16 | "prefLabel": "Sexual misconduct allegations"
17 | },
18 | "metaAltLink": {
19 | "url": "#",
20 | "prefLabel": "FT Investigations"
21 | },
22 | "image": {
23 | "url": "http://prod-upp-image-read.ft.com/a25832ea-0053-11e8-9650-9c0ad2d7c5b5",
24 | "width": 2048,
25 | "height": 1152
26 | },
27 | "relatedLinks": [
28 | {
29 | "id": "",
30 | "relativeUrl": "#",
31 | "type": "article",
32 | "title": "Removing the fig leaf of charity"
33 | },
34 | {
35 | "id": "",
36 | "relativeUrl": "#",
37 | "type": "article",
38 | "title": "A dinner that demeaned both women and men"
39 | },
40 | {
41 | "id": "",
42 | "relativeUrl": "#",
43 | "type": "video",
44 | "title": "PM speaks out after Presidents Club dinner"
45 | }
46 | ],
47 | "status": "",
48 | "headshotTint": "",
49 | "accessLevel": "free",
50 | "theme": "",
51 | "parentTheme": "",
52 | "modifiers": ""
53 | }
54 |
--------------------------------------------------------------------------------
/components/x-gift-article/storybook/share-article-modal-b2c.jsx:
--------------------------------------------------------------------------------
1 | const articleId = 'e4b5ade3-01d1-4db8-b197-257051656684'
2 | const articleUrl = 'https://www.ft.com/content/e4b5ade3-01d1-4db8-b197-257051656684'
3 | const articleUrlRedeemed = 'https://enterprise-sharing.ft.com/gift-url-redeemed'
4 | const nonGiftArticleUrl = `${articleUrl}?shareType=nongift`
5 |
6 | exports.args = {
7 | title: 'Share this article with:',
8 | isFreeArticle: false,
9 | article: {
10 | id: articleId,
11 | url: articleUrl,
12 | title: 'Equinor and Daimler Truck cut Russia ties as Volvo and JLR halt car deliveries'
13 | },
14 | id: 'base-gift-article-static-id',
15 | enterpriseApiBaseUrl: `https://enterprise-sharing-api.ft.com`
16 | }
17 |
18 | exports.fetchMock = (fetchMock) => {
19 | fetchMock
20 | .restore()
21 | .get('path:/article/gift-credits', {
22 | allowance: 20,
23 | consumedCredits: 5,
24 | remainingCredits: 15,
25 | renewalDate: '2018-08-01T00:00:00Z'
26 | })
27 | .get(`path:/article/shorten-url/${encodeURIComponent(articleUrlRedeemed)}`, {
28 | shortenedUrl: 'https://shortened-gift-url'
29 | })
30 | .get(`path:/article/shorten-url/${encodeURIComponent(nonGiftArticleUrl)}`, {
31 | shortenedUrl: 'https://shortened-non-gift-url'
32 | })
33 | .get(`path:/article/gift-link/${encodeURIComponent(articleId)}`, {
34 | redemptionUrl: articleUrlRedeemed,
35 | redemptionLimit: 3,
36 | remainingAllowance: 1
37 | })
38 | .get(
39 | 'path:/v1/users/me/allowance',
40 | new Response(
41 | JSON.stringify({
42 | message: 'NotFoundError: b2c'
43 | }),
44 | { status: 404 }
45 | )
46 | )
47 | .post('path:/v1/shares', {
48 | url: articleUrlRedeemed,
49 | redeemLimit: 120
50 | })
51 | }
52 |
--------------------------------------------------------------------------------
/components/x-gift-article/storybook/share-article-modal-with-advanced-sharing-save-highlights-message.jsx:
--------------------------------------------------------------------------------
1 | const articleId = 'e4b5ade3-01d1-4db8-b197-257051656684'
2 | const articleUrl = 'https://www.ft.com/content/e4b5ade3-01d1-4db8-b197-257051656684'
3 | const articleUrlRedeemed = 'https://enterprise-sharing.ft.com/gift-url-redeemed'
4 | const nonGiftArticleUrl = `${articleUrl}?shareType=nongift`
5 |
6 | exports.args = {
7 | title: 'Share this article with:',
8 | isFreeArticle: false,
9 | article: {
10 | id: articleId,
11 | url: articleUrl,
12 | title: 'Equinor and Daimler Truck cut Russia ties as Volvo and JLR halt car deliveries'
13 | },
14 | id: 'base-gift-article-static-id',
15 | enterpriseApiBaseUrl: `https://enterprise-sharing-api.ft.com`,
16 | showHighlightsCheckbox: false
17 | }
18 |
19 | exports.fetchMock = (fetchMock) => {
20 | fetchMock
21 | .restore()
22 | .get('path:/article/gift-credits', {
23 | allowance: 20,
24 | consumedCredits: 5,
25 | remainingCredits: 15,
26 | renewalDate: '2018-08-01T00:00:00Z'
27 | })
28 | .get(`path:/article/shorten-url/${encodeURIComponent(articleUrlRedeemed)}`, {
29 | shortenedUrl: 'https://shortened-gift-url'
30 | })
31 | .get(`path:/article/shorten-url/${encodeURIComponent(nonGiftArticleUrl)}`, {
32 | shortenedUrl: 'https://shortened-non-gift-url'
33 | })
34 | .get(`path:/article/gift-link/${encodeURIComponent(articleId)}`, {
35 | redemptionUrl: articleUrlRedeemed,
36 | redemptionLimit: 3,
37 | remainingAllowance: 1
38 | })
39 | .get('path:/v1/users/me/allowance', {
40 | limit: 120,
41 | budget: 100,
42 | hasCredits: true
43 | })
44 | .post('path:/v1/shares', {
45 | url: articleUrlRedeemed,
46 | redeemLimit: 120
47 | })
48 | }
49 |
--------------------------------------------------------------------------------
/components/x-gift-article/storybook/share-article-modal-b2c-free-article.jsx:
--------------------------------------------------------------------------------
1 | const articleId = 'e4b5ade3-01d1-4db8-b197-257051656684'
2 | const articleUrl = 'https://www.ft.com/content/e4b5ade3-01d1-4db8-b197-257051656684'
3 | const articleUrlRedeemed = 'https://enterprise-sharing.ft.com/gift-url-redeemed'
4 | const nonGiftArticleUrl = `${articleUrl}?shareType=nongift`
5 |
6 | exports.args = {
7 | title: 'Share this article with:',
8 | isFreeArticle: true,
9 | article: {
10 | id: articleId,
11 | url: articleUrl,
12 | title: 'Equinor and Daimler Truck cut Russia ties as Volvo and JLR halt car deliveries'
13 | },
14 | id: 'base-gift-article-static-id',
15 | enterpriseApiBaseUrl: `https://enterprise-sharing-api.ft.com`
16 | }
17 |
18 | exports.fetchMock = (fetchMock) => {
19 | fetchMock
20 | .restore()
21 | .get('path:/article/gift-credits', {
22 | allowance: 20,
23 | consumedCredits: 5,
24 | remainingCredits: 15,
25 | renewalDate: '2018-08-01T00:00:00Z'
26 | })
27 | .get(`path:/article/shorten-url/${encodeURIComponent(articleUrlRedeemed)}`, {
28 | shortenedUrl: 'https://shortened-gift-url'
29 | })
30 | .get(`path:/article/shorten-url/${encodeURIComponent(nonGiftArticleUrl)}`, {
31 | shortenedUrl: 'https://shortened-non-gift-url'
32 | })
33 | .get(`path:/article/gift-link/${encodeURIComponent(articleId)}`, {
34 | redemptionUrl: articleUrlRedeemed,
35 | redemptionLimit: 3,
36 | remainingAllowance: 1
37 | })
38 | .get(
39 | 'path:/v1/users/me/allowance',
40 | new Response(
41 | JSON.stringify({
42 | message: 'NotFoundError: b2c'
43 | }),
44 | { status: 404 }
45 | )
46 | )
47 | .post('path:/v1/shares', {
48 | url: articleUrlRedeemed,
49 | redeemLimit: 120
50 | })
51 | }
52 |
--------------------------------------------------------------------------------
/components/x-gift-article/storybook/share-article-modal-b2c-no-credits.jsx:
--------------------------------------------------------------------------------
1 | const articleId = 'e4b5ade3-01d1-4db8-b197-257051656684'
2 | const articleUrl = 'https://www.ft.com/content/e4b5ade3-01d1-4db8-b197-257051656684'
3 | const articleUrlRedeemed = 'https://enterprise-sharing.ft.com/gift-url-redeemed'
4 | const nonGiftArticleUrl = `${articleUrl}?shareType=nongift`
5 |
6 | exports.args = {
7 | title: 'Share this article with:',
8 | isFreeArticle: false,
9 | article: {
10 | id: articleId,
11 | url: articleUrl,
12 | title: 'Equinor and Daimler Truck cut Russia ties as Volvo and JLR halt car deliveries'
13 | },
14 | id: 'base-gift-article-static-id',
15 | enterpriseApiBaseUrl: `https://enterprise-sharing-api.ft.com`
16 | }
17 |
18 | exports.fetchMock = (fetchMock) => {
19 | fetchMock
20 | .restore()
21 | .get('path:/article/gift-credits', {
22 | allowance: 20,
23 | consumedCredits: 20,
24 | remainingCredits: 0,
25 | renewalDate: '2018-08-01T00:00:00Z'
26 | })
27 | .get(`path:/article/shorten-url/${encodeURIComponent(articleUrlRedeemed)}`, {
28 | shortenedUrl: 'https://shortened-gift-url'
29 | })
30 | .get(`path:/article/shorten-url/${encodeURIComponent(nonGiftArticleUrl)}`, {
31 | shortenedUrl: 'https://shortened-non-gift-url'
32 | })
33 | .get(`path:/article/gift-link/${encodeURIComponent(articleId)}`, {
34 | redemptionUrl: articleUrlRedeemed,
35 | redemptionLimit: 3,
36 | remainingAllowance: 1
37 | })
38 | .get(
39 | 'path:/v1/users/me/allowance',
40 | new Response(
41 | JSON.stringify({
42 | message: 'NotFoundError: b2c'
43 | }),
44 | { status: 404 }
45 | )
46 | )
47 | .post('path:/v1/shares', {
48 | url: articleUrlRedeemed,
49 | redeemLimit: 120
50 | })
51 | }
52 |
--------------------------------------------------------------------------------
/components/x-gift-article/storybook/share-article-modal-registered.jsx:
--------------------------------------------------------------------------------
1 | const articleId = 'e4b5ade3-01d1-4db8-b197-257051656684'
2 | const articleUrl = 'https://www.ft.com/content/e4b5ade3-01d1-4db8-b197-257051656684'
3 | const articleUrlRedeemed = 'https://enterprise-sharing.ft.com/gift-url-redeemed'
4 | const nonGiftArticleUrl = `${articleUrl}?shareType=nongift`
5 |
6 | exports.args = {
7 | title: 'Share this article with:',
8 | isFreeArticle: false,
9 | article: {
10 | id: articleId,
11 | url: articleUrl,
12 | title: 'Equinor and Daimler Truck cut Russia ties as Volvo and JLR halt car deliveries'
13 | },
14 | id: 'base-gift-article-static-id',
15 | enterpriseApiBaseUrl: `https://enterprise-sharing-api.ft.com`
16 | }
17 |
18 | exports.fetchMock = (fetchMock) => {
19 | fetchMock
20 | .restore()
21 | .get('path:/article/gift-credits', {
22 | allowance: 20,
23 | consumedCredits: 5,
24 | remainingCredits: 15,
25 | renewalDate: '2018-08-01T00:00:00Z'
26 | })
27 | .get(`path:/article/shorten-url/${encodeURIComponent(articleUrlRedeemed)}`, {
28 | shortenedUrl: 'https://shortened-gift-url'
29 | })
30 | .get(`path:/article/shorten-url/${encodeURIComponent(nonGiftArticleUrl)}`, {
31 | shortenedUrl: 'https://shortened-non-gift-url'
32 | })
33 | .get(`path:/article/gift-link/${encodeURIComponent(articleId)}`, {
34 | redemptionUrl: articleUrlRedeemed,
35 | redemptionLimit: 3,
36 | remainingAllowance: 1
37 | })
38 | .get(
39 | 'path:/v1/users/me/allowance',
40 | new Response(
41 | JSON.stringify({
42 | message: 'NotFoundError: registered'
43 | }),
44 | { status: 404 }
45 | )
46 | )
47 | .post('path:/v1/shares', {
48 | url: articleUrlRedeemed,
49 | redeemLimit: 120
50 | })
51 | }
52 |
--------------------------------------------------------------------------------
/components/x-teaser-timeline/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@financial-times/x-teaser-timeline",
3 | "version": "0.0.0",
4 | "description": "Display a list of teasers grouped by day and/or last visit time",
5 | "main": "dist/TeaserTimeline.cjs.js",
6 | "module": "dist/TeaserTimeline.esm.js",
7 | "browser": "dist/TeaserTimeline.es5.js",
8 | "style": "src/TeaserTimeline.scss",
9 | "types": "typings/x-teaser-timeline.d.ts",
10 | "scripts": {
11 | "build": "node rollup.js",
12 | "start": "node rollup.js --watch"
13 | },
14 | "keywords": [
15 | "x-dash"
16 | ],
17 | "author": "",
18 | "license": "ISC",
19 | "dependencies": {
20 | "@financial-times/x-article-save-button": "file:../x-article-save-button",
21 | "@financial-times/x-engine": "file:../../packages/x-engine",
22 | "@financial-times/x-teaser": "file:../x-teaser",
23 | "date-fns": "^2.30.0"
24 | },
25 | "devDependencies": {
26 | "@financial-times/o-grid": "^6.1.8",
27 | "@financial-times/x-rollup": "file:../../packages/x-rollup",
28 | "@financial-times/x-test-utils": "file:../../packages/x-test-utils",
29 | "check-engine": "^1.10.1",
30 | "sass": "^1.49.0"
31 | },
32 | "repository": {
33 | "type": "git",
34 | "url": "https://github.com/Financial-Times/x-dash.git"
35 | },
36 | "homepage": "https://github.com/Financial-Times/x-dash/tree/HEAD/components/x-teaser-timeline",
37 | "engines": {
38 | "node": "16.x || 18.x || 20.x",
39 | "npm": "7.x || 8.x || 9.x || 10.x"
40 | },
41 | "publishConfig": {
42 | "access": "public"
43 | },
44 | "volta": {
45 | "extends": "../../package.json"
46 | },
47 | "peerDependencies": {
48 | "@financial-times/o-grid": "^6.1.8",
49 | "@financial-times/o3-foundation": "^3.1.0"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/components/x-gift-article/src/lib/highlightsApi.js:
--------------------------------------------------------------------------------
1 | import { HIGHLIGHTS_BASE_URL } from './constants'
2 |
3 | export default class HighlightsApiClient {
4 | constructor(baseUrl = HIGHLIGHTS_BASE_URL) {
5 | this.baseUrl = baseUrl
6 | }
7 |
8 | /**
9 | * Concatenates protocol, domain and path URLs.
10 | * @param {string} path URL Path
11 | * @returns {string} Fetch URL
12 | * @throws {Error} if baseURL is empty
13 | */
14 | getFetchUrl(path) {
15 | if (!this.baseUrl) {
16 | throw new Error('User Annotations API base url missing')
17 | }
18 |
19 | return `${this.baseUrl}${path}`
20 | }
21 |
22 | /**
23 | * Makes a fetch request to the path with additional options
24 | * @param {string} path URL path
25 | * @param {RequestInit} additionalOptions fetch additional options
26 | * @returns {Promise} A promise that resolves to the requested URL response parsed from json
27 | */
28 | async fetchJson(path, additionalOptions) {
29 | const url = this.getFetchUrl(path)
30 | const options = Object.assign(
31 | {
32 | credentials: 'include',
33 | headers: {
34 | 'Content-Type': 'application/json'
35 | }
36 | },
37 | additionalOptions
38 | )
39 |
40 | const response = await fetch(url, options)
41 |
42 | if (!response.ok) {
43 | throw new Error(`failed to fetch ${url}, received ${response.status}`)
44 | }
45 |
46 | const responseJSON = await response.json()
47 | return responseJSON
48 | }
49 |
50 | async shareHighlights(articleId, includeHighlights = false) {
51 | try {
52 | if (!includeHighlights) {
53 | return {}
54 | }
55 |
56 | return await this.fetchJson('/create-token', {
57 | method: 'POST',
58 | body: JSON.stringify({ articleId })
59 | })
60 | } catch (error) {
61 | return {}
62 | }
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/components/x-privacy-manager/src/__tests__/utils.test.js:
--------------------------------------------------------------------------------
1 | const { getTrackingKeys, getConsentProxyEndpoints } = require('../utils')
2 |
3 | describe('getTrackingKeys', () => {
4 | it('Creates legislation-specific tracking event names', () => {
5 | expect(getTrackingKeys('ccpa')).toEqual({
6 | 'advertising-toggle-block': 'ccpa-advertising-toggle-block',
7 | 'advertising-toggle-allow': 'ccpa-advertising-toggle-allow',
8 | 'consent-allow': 'ccpa-consent-allow',
9 | 'consent-block': 'ccpa-consent-block'
10 | })
11 | })
12 | })
13 |
14 | describe('getConsentProxyEndpoints', () => {
15 | const params = {
16 | userId: 'abcde',
17 | consentProxyApiHost: 'https://consent.ft.com',
18 | cookieDomain: '.ft.com'
19 | }
20 |
21 | const defaultEndpoint = 'https://consent.ft.com/__consent/consent-record-cookie'
22 |
23 | it('generates endpoints for logged-in users', () => {
24 | expect(getConsentProxyEndpoints(params)).toEqual({
25 | core: `https://consent.ft.com/__consent/consent-record/FTPINK/abcde`,
26 | enhanced: `https://consent.ft.com/__consent/consent/FTPINK/abcde`,
27 | createOrUpdateRecord: `https://consent.ft.com/__consent/consent-record/FTPINK/abcde`
28 | })
29 | })
30 |
31 | it('generates endpoints for logged-out users', () => {
32 | const loggedOutParams = { ...params, userId: undefined }
33 | expect(getConsentProxyEndpoints(loggedOutParams)).toEqual({
34 | core: defaultEndpoint,
35 | enhanced: defaultEndpoint,
36 | createOrUpdateRecord: defaultEndpoint
37 | })
38 | })
39 |
40 | it('generates endpoints for cookie-only circumstances', () => {
41 | const loggedOutParams = { ...params, cookiesOnly: true }
42 | expect(getConsentProxyEndpoints(loggedOutParams)).toEqual({
43 | core: defaultEndpoint,
44 | enhanced: defaultEndpoint,
45 | createOrUpdateRecord: defaultEndpoint
46 | })
47 | })
48 | })
49 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: '@typescript-eslint/parser',
3 | plugins: ['jsx-a11y'],
4 | extends: [
5 | '@financial-times/eslint-config-next',
6 | 'plugin:jest/recommended',
7 | 'plugin:react/recommended',
8 | 'plugin:jsx-a11y/recommended',
9 | '@dotcom-reliability-kit/eslint-config',
10 | 'prettier'
11 | ],
12 | parserOptions: {
13 | ecmaFeatures: {
14 | jsx: true
15 | }
16 | },
17 | settings: {
18 | react: {
19 | version: '16.8'
20 | }
21 | },
22 | rules: {
23 | // We don't expect consumers of x-dash to use prop types
24 | 'react/prop-types': 'off',
25 | // We don't use display names for SFCs
26 | 'react/display-name': 'off',
27 | // This rule is intended to catch < or > but it's too eager
28 | 'react/no-unescaped-entities': 'off',
29 | // this rule is deprecated and replaced with label-has-associated-control
30 | 'jsx-a11y/label-has-for': 'off',
31 | 'jsx-a11y/label-has-associated-control': 'error'
32 | },
33 | overrides: [
34 | {
35 | // Components in x-dash interact with x-engine rather than React
36 | files: ['components/*/src/**/*.jsx', 'components/*/__tests__/**/*.jsx'],
37 | settings: {
38 | react: {
39 | pragma: 'h',
40 | createClass: 'Component'
41 | }
42 | },
43 | rules: {
44 | 'react/prefer-stateless-function': 'error'
45 | }
46 | },
47 | {
48 | files: ['*.js', '*.jsx'],
49 | rules: {
50 | // We are still using CommonJS imports in our JS files
51 | '@typescript-eslint/no-var-requires': 'off'
52 | }
53 | },
54 | {
55 | files: [
56 | 'components/**/__tests__/*.js',
57 | 'components/**/__tests__/*.jsx',
58 | 'components/**/storybook/*.jsx'
59 | ],
60 | rules: {
61 | // We are still using CommonJS imports in our JS files
62 | '@typescript-eslint/no-empty-function': 'off'
63 | }
64 | }
65 | ]
66 | }
67 |
--------------------------------------------------------------------------------
/components/x-privacy-manager/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@financial-times/x-privacy-manager",
3 | "version": "1.1.0",
4 | "description": "A component to let users give or withhold consent to the use of their data",
5 | "author": "Oliver Turner ",
6 | "license": "ISC",
7 | "keywords": [
8 | "x-dash"
9 | ],
10 | "main": "dist/privacy-manager.cjs.js",
11 | "module": "dist/privacy-manager.esm.js",
12 | "browser": "dist/privacy-manager.es5.js",
13 | "style": "src/privacy-manager.scss",
14 | "types": "typings/x-privacy-manager.d.ts",
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/components/x-privacy-manager",
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 | "dependencies": {
28 | "@financial-times/x-engine": "file:../../packages/x-engine",
29 | "@financial-times/x-interaction": "file:../x-interaction"
30 | },
31 | "devDependencies": {
32 | "@financial-times/x-rollup": "file:../../packages/x-rollup",
33 | "@financial-times/x-test-utils": "file:../../packages/x-test-utils",
34 | "check-engine": "^1.10.1",
35 | "fetch-mock-jest": "^1.3.0",
36 | "sass": "^1.26.5"
37 | },
38 | "scripts": {
39 | "build": "node rollup.js",
40 | "start": "node rollup.js --watch"
41 | },
42 | "volta": {
43 | "extends": "../../package.json"
44 | },
45 | "peerDependencies": {
46 | "@financial-times/o-grid": "^6.1.5",
47 | "@financial-times/o-loading": "^6.0.0",
48 | "@financial-times/o-message": "^6.0.0",
49 | "@financial-times/o3-button": "^3.0.1",
50 | "@financial-times/o3-foundation": "^3.1.0"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/components/x-teaser/storybook/argTypes.js:
--------------------------------------------------------------------------------
1 | exports.argTypes = {
2 | status: {
3 | name: 'Live Blog Status',
4 | control: {
5 | type: 'select',
6 | options: {
7 | None: '',
8 | 'Coming soon': 'comingsoon',
9 | 'In progress': 'inprogress',
10 | Closed: 'closed'
11 | }
12 | }
13 | },
14 | imageSize: {
15 | name: 'Image Size',
16 | control: { type: 'select', options: ['XS', 'Small', 'Medium', 'Large', 'XL', 'XXL'] }
17 | },
18 | headshotTint: { name: 'Headshot tint', control: { type: 'select', options: { Default: '' } } },
19 | accessLevel: {
20 | name: 'Access level',
21 | control: { type: 'select', options: ['free', 'registered', 'subscribed', 'premium'] }
22 | },
23 | layout: { name: 'Layout', control: { type: 'select', options: ['small', 'large', 'hero', 'top-story'] } },
24 | theme: {
25 | name: 'Theme',
26 | control: { type: 'select', options: { None: '', Extra: 'extra-article', 'Special Report': 'highlight' } }
27 | },
28 | parentTheme: {
29 | name: 'Parent Theme',
30 | control: { type: 'select', options: { None: '', Extra: 'extra-article', 'Special Report': 'highlight' } }
31 | },
32 | modifiers: {
33 | name: 'Modifiers',
34 | control: {
35 | type: 'select',
36 | options: {
37 | None: '',
38 | 'Small stacked': 'stacked',
39 | 'Small image on right': 'image-on-right',
40 | 'Large portrait': 'large-portrait',
41 | 'Large landscape': 'large-landscape',
42 | 'Hero centre': 'centre',
43 | 'Hero image': 'hero-image',
44 | 'Top story landscape': 'landscape',
45 | 'Top story big': 'big-story'
46 | }
47 | }
48 | },
49 | publishedDate: { name: 'Published Date', control: { type: 'date' } },
50 | firstPublishedDate: { name: 'First Published Date', control: { type: 'date' } },
51 | allowLiveTeaserStyling: {
52 | name: 'allowLiveTeaserStyling',
53 | control: 'boolean'
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/components/x-gift-article/src/lib/share-link-actions.js:
--------------------------------------------------------------------------------
1 | const { trimHighlights } = require('./highlightsHelpers')
2 | function getGreeting() {
3 | const hours = new Date().getHours()
4 | // Determine the appropriate greeting based on the current hour
5 | if (hours < 12) {
6 | return 'Good morning'
7 | }
8 |
9 | if (hours >= 12 && hours <= 17) {
10 | return 'Good afternoon'
11 | }
12 |
13 | return 'Good evening'
14 | }
15 |
16 | function createMailtoUrl({ article, includeHighlights, highlight }, shareUrl) {
17 | const subject = 'Read this article from the Financial Times'
18 | const greeting = getGreeting()
19 | const sharedText = includeHighlights ? `${article.title} - "${trimHighlights(highlight)}"` : article.title
20 | const body = encodeURIComponent(
21 | `${greeting},\n\nI read this article from the Financial Times and thought it would interest you.\n\n${sharedText}\n${shareUrl}\n\nBest wishes,`
22 | )
23 |
24 | return `mailto:?subject=${subject}&body=${body}`
25 | }
26 |
27 | function copyToClipboard(event) {
28 | const urlSection = event.target.closest('.js-gift-article__url-section')
29 | const inputEl = urlSection.querySelector('#share-link')
30 | const oldContentEditable = inputEl.contentEditable
31 | const oldReadOnly = inputEl.readOnly
32 | const range = document.createRange()
33 |
34 | inputEl.contenteditable = true
35 | inputEl.readonly = false
36 | inputEl.focus()
37 | range.selectNodeContents(inputEl)
38 |
39 | const selection = window.getSelection()
40 |
41 | try {
42 | selection.removeAllRanges()
43 | selection.addRange(range)
44 | inputEl.setSelectionRange(0, 999999)
45 | } catch (err) {
46 | inputEl.select() // IE11 etc.
47 | }
48 | inputEl.contentEditable = oldContentEditable
49 | inputEl.readOnly = oldReadOnly
50 | document.execCommand('copy')
51 | inputEl.blur()
52 | }
53 |
54 | module.exports = {
55 | createMailtoUrl,
56 | copyToClipboard
57 | }
58 |
--------------------------------------------------------------------------------
/components/x-interaction/src/InteractionClass.jsx:
--------------------------------------------------------------------------------
1 | import { h, Component } from '@financial-times/x-engine'
2 | import { InteractionRender } from './InteractionRender'
3 | import mapValues from './concerns/map-values'
4 |
5 | export class InteractionClass extends Component {
6 | constructor(props, ...args) {
7 | super(props, ...args)
8 |
9 | this.state = {
10 | state: {},
11 | inFlight: 0
12 | }
13 |
14 | this.createActions(props)
15 | }
16 |
17 | createActions(props) {
18 | this.actions = mapValues(props.actions, (func) => async (...args) => {
19 | // mark as loading one microtask later. if the action is synchronous then
20 | // setting loading back to false will happen in the same microtask and no
21 | // additional render will be scheduled.
22 | Promise.resolve().then(() => {
23 | this.setState(({ inFlight }) => ({ inFlight: inFlight + 1 }))
24 | })
25 |
26 | const stateUpdate = await Promise.resolve(func(...args))
27 |
28 | const nextState =
29 | typeof stateUpdate === 'function'
30 | ? Object.assign(
31 | this.state.state,
32 | await Promise.resolve(stateUpdate(Object.assign({}, props.initialState, this.state.state)))
33 | )
34 | : Object.assign(this.state.state, stateUpdate)
35 |
36 | return new Promise((resolve) =>
37 | this.setState({ state: nextState }, () =>
38 | this.setState(({ inFlight }) => ({ inFlight: inFlight - 1 }), resolve)
39 | )
40 | )
41 | })
42 | }
43 |
44 | componentWillReceiveProps(props) {
45 | this.createActions(props)
46 | }
47 |
48 | componentDidMount() {
49 | if (this.props.actionsRef) {
50 | this.props.actionsRef(this.actions)
51 | }
52 | }
53 |
54 | componentWillUnmount() {
55 | if (this.props.actionsRef) {
56 | this.props.actionsRef(null)
57 | }
58 | }
59 |
60 | render() {
61 | return
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/components/x-privacy-manager/src/utils.js:
--------------------------------------------------------------------------------
1 | /** @type {XPrivacyManager.TrackingKey[]} */
2 | const trackingKeys = [
3 | 'advertising-toggle-block',
4 | 'advertising-toggle-allow',
5 | 'consent-allow',
6 | 'consent-block'
7 | ]
8 |
9 | /**
10 | * Create a look-up table legislationId-specific tracking event names
11 | * e.g. { 'advertising-toggle-block': 'gdpr-advertising-toggle-block' }
12 | *
13 | * @param {string} legislationId
14 | *
15 | * @returns {XPrivacyManager.TrackingKeys}
16 | */
17 | export function getTrackingKeys(legislationId) {
18 | /** @type Record */
19 | const dict = {}
20 | for (const key of trackingKeys) {
21 | dict[key] = `${legislationId}-${key}`
22 | }
23 |
24 | return dict
25 | }
26 |
27 | /**
28 | * @param {{
29 | * userId: string;
30 | * consentProxyApiHost: string;
31 | * cookiesOnly?: boolean;
32 | * cookieDomain?: string;
33 | * }} param
34 | *
35 | * @returns {XPrivacyManager.ConsentProxyEndpoint}
36 | */
37 | export function getConsentProxyEndpoints({
38 | userId,
39 | consentProxyApiHost,
40 | cookiesOnly = false,
41 | cookieDomain = ''
42 | }) {
43 | if (cookieDomain.length > 0) {
44 | // Override the domain so that set-cookie headers in consent api responses are respected
45 | consentProxyApiHost = consentProxyApiHost.replace('.ft.com', cookieDomain)
46 | }
47 |
48 | const endpointDefault = `${consentProxyApiHost}/__consent/consent-record-cookie`
49 |
50 | if (userId && !cookiesOnly) {
51 | const endpointCore = `${consentProxyApiHost}/__consent/consent-record/FTPINK/${userId}`
52 | const endpointEnhanced = `${consentProxyApiHost}/__consent/consent/FTPINK/${userId}`
53 |
54 | return {
55 | core: endpointCore,
56 | enhanced: endpointEnhanced,
57 | createOrUpdateRecord: endpointCore
58 | }
59 | }
60 |
61 | return {
62 | core: endpointDefault,
63 | enhanced: endpointDefault,
64 | createOrUpdateRecord: endpointDefault
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/e2e/e2e.test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment node
3 | */
4 |
5 | const { h } = require('@financial-times/x-engine') // required for
6 | const { Serialiser, HydrationData } = require('@financial-times/x-interaction')
7 | const puppeteer = require('puppeteer')
8 | const ReactDOMServer = require('react-dom/server')
9 | const express = require('express')
10 | import React from 'react'
11 | import { GreetingComponent } from './common'
12 |
13 | describe('x-interaction-e2e', () => {
14 | let browser
15 | let page
16 | let app
17 | let server
18 |
19 | beforeAll(async () => {
20 | app = express()
21 | server = app.listen(3004)
22 | app.use(express.static(__dirname))
23 | browser = await puppeteer.launch()
24 | page = await browser.newPage()
25 | })
26 |
27 | it('attaches the event listener to SSR components on hydration', async () => {
28 | const ClientComponent = () => {
29 | // main.js is the transpiled version of index.js, which contains the registered GreetingComponent, and invokes hydrate
30 | return
31 | }
32 |
33 | const serialiser = new Serialiser()
34 | const htmlString = ReactDOMServer.renderToString(
35 | <>
36 |
37 |
38 |
39 | >
40 | )
41 |
42 | app.get('/', (req, res) => {
43 | res.send(htmlString)
44 | })
45 |
46 | // go to page and click button
47 | await page.goto('http://localhost:3004')
48 | await page.waitForSelector('.greeting-button')
49 | await page.click('.greeting-button')
50 | const text = await page.$eval('.greeting-text', (e) => e.textContent)
51 | expect(text).toContain('hello world')
52 | })
53 |
54 | afterAll(async () => {
55 | try {
56 | ;(await browser) && browser.close()
57 | await server.close()
58 | } catch (e) {
59 | console.log(e)
60 | }
61 | })
62 | })
63 |
--------------------------------------------------------------------------------
/packages/x-node-jsx/readme.md:
--------------------------------------------------------------------------------
1 | # x-node-jsx
2 |
3 | This module extends Node's `require()` function to enable the use of `.jsx` files at runtime. It uses [Pirates] to safely add a require hook and [Sucrase] to transform code on-the-fly.
4 |
5 | [Pirates]: https://github.com/ariporad/pirates
6 | [Sucrase]: https://github.com/alangpierce/sucrase
7 |
8 |
9 | ## Installation
10 |
11 | This module is supported on Node 12 and is distributed on npm.
12 |
13 | ```bash
14 | npm install -S @financial-times/x-node-jsx
15 | ```
16 |
17 | To add the require hook you only need to import the register module. You can do this programmatically in your application code:
18 |
19 | ```js
20 | require('@financial-times/x-node-jsx/register');
21 | ```
22 |
23 | Or use the `--require` or `-r` flag when invoking Node:
24 |
25 | ```bash
26 | node --require "@financial-times/x-node-jsx/register"
27 | ```
28 |
29 | You can also add the require hook manually. This will return a function which can be used to later remove the hook:
30 |
31 | ```js
32 | const addHook = require('@financial-times/x-node-jsx');
33 |
34 | const removeHook = addHook();
35 |
36 | // Some time later...
37 | removeHook();
38 | ```
39 |
40 | An options object may also be provided, the options and their defaults are shown below:
41 |
42 | ```js
43 | const addHook = require('@financial-times/x-node-jsx');
44 |
45 | addHook({
46 | production: true,
47 | transforms: ['imports', 'jsx']
48 | });
49 | ```
50 |
51 | These options will be passed to the Sucrase module. To see more options take a look at the [Sucrase documentation].
52 |
53 | After the hook has been added `.jsx` files can be imported and will be transformed on-the-fly:
54 |
55 | ```js
56 | // Add the hook
57 | require('@financial-times/x-node-jsx/register');
58 |
59 | // Transparently require .jsx files
60 | const App = require('./components/App.jsx');
61 | ```
62 |
63 | [Sucrase documentation]: https://github.com/alangpierce/sucrase#transforms
64 |
65 |
66 |
--------------------------------------------------------------------------------
/components/x-teaser-timeline/__tests__/lib/date.test.js:
--------------------------------------------------------------------------------
1 | import { getLocalisedISODate, getTitleForItemGroup } from '../../src/lib/date'
2 |
3 | describe('getLocalisedISODate', () => {
4 | it('should return dates with future timezones in the format of date + time difference', () => {
5 | const dateInFuture = getLocalisedISODate('2020-01-01T00:00:00.000Z', 60)
6 | expect(dateInFuture).toEqual('2019-12-31T23:00:00.000-01:00')
7 | })
8 | it('should return dates with past timezones in the format of date - time difference', () => {
9 | const dateInPast = getLocalisedISODate('2020-01-01T00:00:00.000Z', -60)
10 | expect(dateInPast).toEqual('2020-01-01T01:00:00.000+01:00')
11 | })
12 | it('should return dates with no timezones in the format of date + 0 time difference', () => {
13 | const dateInPresent = getLocalisedISODate('2020-01-01T00:00:00.000Z', 0)
14 | expect(dateInPresent).toEqual('2020-01-01T00:00:00.000+00:00')
15 | })
16 | })
17 |
18 | describe('getTitleForItemGroup', () => {
19 | it('Should return string matchings for today-latest and today-earlier', () => {
20 | const latest = getTitleForItemGroup('today-latest', 'foo')
21 |
22 | expect(latest).toBe('Latest News')
23 |
24 | const earlier = getTitleForItemGroup('today-earlier', 'foo')
25 | expect(earlier).toBe('Earlier Today')
26 | })
27 | it('should return Earlier Today when provided with matching dates', () => {
28 | const earlier = getTitleForItemGroup(new Date().toDateString(), new Date().toDateString())
29 | expect(earlier).toBe('Earlier Today')
30 | })
31 | it('Should return yesterday when provided two dates that are one day apart', () => {
32 | const yesterday = getTitleForItemGroup('2019-01-01', '2019-01-02')
33 | expect(yesterday).toBe('Yesterday')
34 | })
35 | describe('multiple days apart', () => {
36 | it('should return the date in the format of MMMM d, yyyy', () => {
37 | const date = getTitleForItemGroup('2019-01-01', '2019-01-03')
38 | expect(date).toBe('January 1, 2019')
39 | })
40 | })
41 | })
42 |
--------------------------------------------------------------------------------
/components/x-gift-article/src/UrlSection.jsx:
--------------------------------------------------------------------------------
1 | import { h } from '@financial-times/x-engine'
2 | import { trimHighlights } from './lib/highlightsHelpers'
3 | import CopyConfirmation from './CopyConfirmation'
4 | import { ShareType } from './lib/constants'
5 | import { SocialShareButtons } from './SocialShareButtons'
6 |
7 | export const UrlSection = (props) => {
8 | const {
9 | urlType,
10 | url,
11 | actions,
12 | shareType,
13 | showCopyConfirmation,
14 | enterpriseEnabled,
15 | includeHighlights,
16 | article,
17 | highlight
18 | } = props
19 |
20 | const copyLinkHandler = (event) => {
21 | switch (shareType) {
22 | case ShareType.gift:
23 | actions.copyGiftUrl(event)
24 | break
25 | case ShareType.enterprise:
26 | actions.copyEnterpriseUrl(event)
27 | break
28 | case ShareType.nonGift:
29 | actions.copyNonGiftUrl(event)
30 | break
31 | default:
32 | }
33 | }
34 |
35 | return (
36 |
37 |
42 | {includeHighlights ? (
43 |
51 | ) : (
52 |
53 | )}
54 |
60 | {includeHighlights ? 'Copy' : 'Copy link'}
61 |
62 | {showCopyConfirmation && }
63 |
64 |
65 |
66 | )
67 | }
68 |
--------------------------------------------------------------------------------
/packages/x-engine/src/server.js:
--------------------------------------------------------------------------------
1 | const deepGet = require('./concerns/deep-get')
2 | const resolvePkg = require('./concerns/resolve-pkg')
3 | const resolvePeer = require('./concerns/resolve-peer')
4 | const formatConfig = require('./concerns/format-config')
5 |
6 | // 1. try to load the application's package manifest
7 | const pkgPath = resolvePkg()
8 | const pkg = require(pkgPath)
9 |
10 | // 2. if we have the manifest then find the engine configuration
11 | const raw = deepGet(pkg, 'x-dash.engine.server')
12 |
13 | if (!raw) {
14 | throw new Error(`x-engine server configuration not found in ${pkg.name}'s package.json (${pkgPath}). this configuration is required so that x-engine knows which JSX runtime to use. follow the configuration guide to add this configuration: https://github.com/Financial-Times/x-dash/tree/main/packages/x-engine#configuration`)
15 | }
16 |
17 | // 3. format the configuration we've loaded
18 | const config = formatConfig(raw)
19 |
20 | // 4. if this module is a linked dependency then resolve required runtime to CWD
21 | const runtime = require(resolvePeer(config.runtime))
22 |
23 | // 5. if we've loaded the runtime then find its create element factory function
24 | const factory = config.factory ? runtime[config.factory] : runtime
25 |
26 | // 6. if we've loaded the runtime then find its Component constructor
27 | const component = config.component ? runtime[config.component] : null
28 |
29 | // 7. if we've loaded the runtime then find its Fragment object
30 | const fragment = config.fragment ? runtime[config.fragment] : null
31 |
32 | // 8. if the rendering module is different to the runtime, load it
33 | const renderModule = config.renderModule ? require(resolvePeer(config.renderModule)) : runtime
34 |
35 | // 9. if we've got the render module then find its render method
36 | const render = config.render ? renderModule[config.render] : renderModule
37 |
38 | module.exports.h = factory
39 | module.exports.render = render
40 | module.exports.Component = component
41 | module.exports.Fragment = fragment
42 |
--------------------------------------------------------------------------------
/components/x-gift-article/src/IncludeHighlights.jsx:
--------------------------------------------------------------------------------
1 | import { h } from '@financial-times/x-engine'
2 | import { canShareWithNonSubscribers, isNonSubscriberOption, trimHighlights } from './lib/highlightsHelpers'
3 |
4 | export const IncludeHighlights = (props) => {
5 | const { actions, highlight, enterpriseEnabled, includeHighlights, highlightClassName } = props
6 | const _canShareWithNonSubscribers = canShareWithNonSubscribers(props)
7 | const _isNonSubscriberOption = isNonSubscriberOption(props)
8 |
9 | const includeHighlightsHandler = (event) => {
10 | actions.setIncludeHighlights(!event.target.checked)
11 | }
12 |
13 | return highlight !== undefined &&
14 | enterpriseEnabled &&
15 | (_canShareWithNonSubscribers || !_isNonSubscriberOption) ? (
16 |
20 | {includeHighlights && (
21 |
22 |
23 |
24 | Highlighted text when shared:
25 |
26 |
27 |
28 |
29 | {trimHighlights(highlight)}
30 |
31 |
32 | )}
33 |
34 |
35 |
44 | Don't include highlights
45 |
46 |
47 |
48 | ) : null
49 | }
50 |
--------------------------------------------------------------------------------
/components/x-gift-article/storybook/share-article-modal-b2b-highlights.jsx:
--------------------------------------------------------------------------------
1 | const articleId = 'e4b5ade3-01d1-4db8-b197-257051656684'
2 | const articleUrl = 'https://www.ft.com/content/e4b5ade3-01d1-4db8-b197-257051656684'
3 | const articleUrlRedeemed = 'https://enterprise-sharing.ft.com/gift-url-redeemed'
4 | const nonGiftArticleUrl = `${articleUrl}?shareType=nongift`
5 |
6 | exports.args = {
7 | title: 'Share this article with:',
8 | isFreeArticle: false,
9 | article: {
10 | id: articleId,
11 | url: articleUrl,
12 | title: 'Equinor and Daimler Truck cut Russia ties as Volvo and JLR halt car deliveries'
13 | },
14 | id: 'base-gift-article-static-id',
15 | enterpriseApiBaseUrl: `https://enterprise-sharing-api.ft.com`,
16 | highlight:
17 | 'Lorem ipsum dolor sit amet consectetur, adipisicing elit. Dolorum quos, quis quas ad, minima fuga at nemo deleniti hic repellendus totam. Impedit mollitia quam repellat harum. Nostrum sapiente minima soluta , Lorem ipsum dolor sit amet consectetur, adipisicing elit. Dolorum quos, quis quas ad, minima fuga at nemo deleniti hic repellendus totam. Impedit mollitia quam repellat harum. Nostrum sapiente minima soluta.'
18 | }
19 |
20 | exports.fetchMock = (fetchMock) => {
21 | fetchMock
22 | .restore()
23 | .get('path:/article/gift-credits', {
24 | allowance: 20,
25 | consumedCredits: 5,
26 | remainingCredits: 15,
27 | renewalDate: '2018-08-01T00:00:00Z'
28 | })
29 | .get(`path:/article/shorten-url/${encodeURIComponent(articleUrlRedeemed)}`, {
30 | shortenedUrl: 'https://shortened-gift-url'
31 | })
32 | .get(`path:/article/shorten-url/${encodeURIComponent(nonGiftArticleUrl)}`, {
33 | shortenedUrl: 'https://shortened-non-gift-url'
34 | })
35 | .get(`path:/article/gift-link/${encodeURIComponent(articleId)}`, {
36 | redemptionUrl: articleUrlRedeemed,
37 | redemptionLimit: 3,
38 | remainingAllowance: 1
39 | })
40 | .get('path:/v1/users/me/allowance', 403)
41 | .post('path:/v1/shares', {
42 | url: articleUrlRedeemed,
43 | redeemLimit: 120
44 | })
45 | }
46 |
--------------------------------------------------------------------------------
/components/x-gift-article/storybook/share-article-modal-mpr-version.js:
--------------------------------------------------------------------------------
1 | const articleId = 'e4b5ade3-01d1-4db8-b197-257051656684'
2 | const articleUrl = 'https://www.ft.com/content/e4b5ade3-01d1-4db8-b197-257051656684'
3 | const articleUrlRedeemed = 'https://enterprise-sharing.ft.com/gift-url-redeemed'
4 | const nonGiftArticleUrl = `${articleUrl}?shareType=nongift`
5 |
6 | exports.args = {
7 | title: 'Share this article',
8 | isFreeArticle: false,
9 | isMPRArticle: true,
10 | article: {
11 | id: articleId,
12 | url: articleUrl,
13 | title: 'Equinor and Daimler Truck cut Russia ties as Volvo and JLR halt car deliveries'
14 | },
15 | id: 'base-gift-article-static-id',
16 | enterpriseApiBaseUrl: `https://enterprise-sharing-api.ft.com`,
17 | highlight:
18 | 'Lorem ipsum dolor sit amet consectetur, adipisicing elit. Dolorum quos, quis quas ad, minima fuga at nemo deleniti hic repellendus totam. Impedit mollitia quam repellat harum. Nostrum sapiente minima soluta , Lorem ipsum dolor sit amet consectetur, adipisicing elit. Dolorum quos, quis quas ad, minima fuga at nemo deleniti hic repellendus totam. Impedit mollitia quam repellat harum. Nostrum sapiente minima soluta.'
19 | }
20 |
21 | exports.fetchMock = (fetchMock) => {
22 | fetchMock
23 | .restore()
24 | .get('path:/article/gift-credits', {
25 | allowance: 20,
26 | consumedCredits: 5,
27 | remainingCredits: 15,
28 | renewalDate: '2018-08-01T00:00:00Z'
29 | })
30 | .get(`path:/article/shorten-url/${encodeURIComponent(articleUrlRedeemed)}`, {
31 | shortenedUrl: 'https://shortened-gift-url'
32 | })
33 | .get(`path:/article/shorten-url/${encodeURIComponent(nonGiftArticleUrl)}`, {
34 | shortenedUrl: 'https://shortened-non-gift-url'
35 | })
36 | .get(`path:/article/gift-link/${encodeURIComponent(articleId)}`, {
37 | redemptionUrl: articleUrlRedeemed,
38 | redemptionLimit: 3,
39 | remainingAllowance: 1
40 | })
41 | .get('path:/v1/users/me/allowance', 403)
42 | .post('path:/v1/shares', {
43 | url: articleUrlRedeemed,
44 | redeemLimit: 120
45 | })
46 | }
47 |
--------------------------------------------------------------------------------
/components/x-teaser/src/Image.jsx:
--------------------------------------------------------------------------------
1 | import { h } from '@financial-times/x-engine'
2 | import { ImageSizes } from './concerns/constants'
3 | import imageService from './concerns/image-service'
4 | import Link from './Link'
5 |
6 | /**
7 | * Aspect Ratio
8 | * @param {{ width: Number, height: Number }} image
9 | * @returns {String|null}
10 | */
11 | const aspectRatio = ({ width, height }) => {
12 | if (typeof width === 'number' && typeof height === 'number') {
13 | return { aspectRatio: `${width}/${height}` }
14 | }
15 | return {}
16 | }
17 |
18 | const NormalImage = ({ src, alt }) =>
19 |
20 | const LazyImage = ({ src, lazyLoad, alt }) => {
21 | const lazyClassName = typeof lazyLoad === 'string' ? lazyLoad : ''
22 | return
23 | }
24 |
25 | export default ({ relativeUrl, url, image, imageSize, imageLazyLoad, imageHighestQuality, ...props }) => {
26 | if (!image || (image && !image.url)) {
27 | return null
28 | }
29 | const displayUrl = relativeUrl || url
30 | const useImageService = !(image.url.startsWith('data:') || image.url.startsWith('blob:'))
31 | const options = imageSize === 'XXL' && imageHighestQuality ? { quality: 'highest' } : {}
32 | const imageSrc = useImageService ? imageService(image.url, ImageSizes[imageSize], options) : image.url
33 | const alt = (image.altText || '').trim()
34 | const ImageComponent = imageLazyLoad ? LazyImage : NormalImage
35 |
36 | return (
37 |
38 |
48 |
49 |
50 |
51 |
52 |
53 | )
54 | }
55 |
--------------------------------------------------------------------------------
/components/x-gift-article/storybook/share-article-modal-with-advanced-sharing-no-both-credits.jsx:
--------------------------------------------------------------------------------
1 | const articleId = 'e4b5ade3-01d1-4db8-b197-257051656684'
2 | const articleUrl = 'https://www.ft.com/content/e4b5ade3-01d1-4db8-b197-257051656684'
3 | const articleUrlRedeemed = 'https://enterprise-sharing.ft.com/gift-url-redeemed'
4 | const nonGiftArticleUrl = `${articleUrl}?shareType=nongift`
5 |
6 | exports.args = {
7 | title: 'Share this article with:',
8 | isFreeArticle: false,
9 | article: {
10 | id: articleId,
11 | url: articleUrl,
12 | title: 'Equinor and Daimler Truck cut Russia ties as Volvo and JLR halt car deliveries'
13 | },
14 | id: 'base-gift-article-static-id',
15 | enterpriseApiBaseUrl: `https://enterprise-sharing-api.ft.com`,
16 | highlight:
17 | 'Lorem ipsum dolor sit amet consectetur, adipisicing elit. Dolorum quos, quis quas ad, minima fuga at nemo deleniti hic repellendus totam. Impedit mollitia quam repellat harum. Nostrum sapiente minima soluta , Lorem ipsum dolor sit amet consectetur, adipisicing elit. Dolorum quos, quis quas ad, minima fuga at nemo deleniti hic repellendus totam. Impedit mollitia quam repellat harum. Nostrum sapiente minima soluta.'
18 | }
19 |
20 | exports.fetchMock = (fetchMock) => {
21 | fetchMock
22 | .restore()
23 | .get('path:/article/gift-credits', {
24 | allowance: 20,
25 | consumedCredits: 5,
26 | remainingCredits: 0,
27 | renewalDate: '2018-08-01T00:00:00Z'
28 | })
29 | .get(`path:/article/shorten-url/${encodeURIComponent(articleUrlRedeemed)}`, {
30 | shortenedUrl: 'https://shortened-gift-url'
31 | })
32 | .get(`path:/article/shorten-url/${encodeURIComponent(nonGiftArticleUrl)}`, {
33 | shortenedUrl: 'https://shortened-non-gift-url'
34 | })
35 | .get(`path:/article/gift-link/${encodeURIComponent(articleId)}`, {
36 | redemptionUrl: articleUrlRedeemed,
37 | redemptionLimit: 3,
38 | remainingAllowance: 1
39 | })
40 | .get('path:/v1/users/me/allowance', {
41 | limit: 120,
42 | hasCredits: false
43 | })
44 | .post('path:/v1/shares', {
45 | url: articleUrlRedeemed,
46 | redeemLimit: 120
47 | })
48 | }
49 |
--------------------------------------------------------------------------------
/components/x-gift-article/storybook/share-article-modal-with-advanced-sharing-no-enterprise-credits.jsx:
--------------------------------------------------------------------------------
1 | const articleId = 'e4b5ade3-01d1-4db8-b197-257051656684'
2 | const articleUrl = 'https://www.ft.com/content/e4b5ade3-01d1-4db8-b197-257051656684'
3 | const articleUrlRedeemed = 'https://enterprise-sharing.ft.com/gift-url-redeemed'
4 | const nonGiftArticleUrl = `${articleUrl}?shareType=nongift`
5 |
6 | exports.args = {
7 | title: 'Share this article with:',
8 | isFreeArticle: false,
9 | article: {
10 | id: articleId,
11 | url: articleUrl,
12 | title: 'Equinor and Daimler Truck cut Russia ties as Volvo and JLR halt car deliveries'
13 | },
14 | id: 'base-gift-article-static-id',
15 | enterpriseApiBaseUrl: `https://enterprise-sharing-api.ft.com`,
16 | highlight:
17 | 'Lorem ipsum dolor sit amet consectetur, adipisicing elit. Dolorum quos, quis quas ad, minima fuga at nemo deleniti hic repellendus totam. Impedit mollitia quam repellat harum. Nostrum sapiente minima soluta , Lorem ipsum dolor sit amet consectetur, adipisicing elit. Dolorum quos, quis quas ad, minima fuga at nemo deleniti hic repellendus totam. Impedit mollitia quam repellat harum. Nostrum sapiente minima soluta.'
18 | }
19 |
20 | exports.fetchMock = (fetchMock) => {
21 | fetchMock
22 | .restore()
23 | .get('path:/article/gift-credits', {
24 | allowance: 20,
25 | consumedCredits: 5,
26 | remainingCredits: 15,
27 | renewalDate: '2018-08-01T00:00:00Z'
28 | })
29 | .get(`path:/article/shorten-url/${encodeURIComponent(articleUrlRedeemed)}`, {
30 | shortenedUrl: 'https://shortened-gift-url'
31 | })
32 | .get(`path:/article/shorten-url/${encodeURIComponent(nonGiftArticleUrl)}`, {
33 | shortenedUrl: 'https://shortened-non-gift-url'
34 | })
35 | .get(`path:/article/gift-link/${encodeURIComponent(articleId)}`, {
36 | redemptionUrl: articleUrlRedeemed,
37 | redemptionLimit: 3,
38 | remainingAllowance: 1
39 | })
40 | .get('path:/v1/users/me/allowance', {
41 | limit: 120,
42 | hasCredits: false
43 | })
44 | .post('path:/v1/shares', {
45 | url: articleUrlRedeemed,
46 | redeemLimit: 120
47 | })
48 | }
49 |
--------------------------------------------------------------------------------
/components/x-gift-article/storybook/share-article-modal-with-advanced-sharing.jsx:
--------------------------------------------------------------------------------
1 | const articleId = 'e4b5ade3-01d1-4db8-b197-257051656684'
2 | const articleUrl = 'https://www.ft.com/content/e4b5ade3-01d1-4db8-b197-257051656684'
3 | const articleUrlRedeemed = 'https://enterprise-sharing.ft.com/gift-url-redeemed'
4 | const nonGiftArticleUrl = `${articleUrl}?shareType=nongift`
5 |
6 | exports.args = {
7 | title: 'Share this article with:',
8 | isFreeArticle: false,
9 | article: {
10 | id: articleId,
11 | url: articleUrl,
12 | title: 'Equinor and Daimler Truck cut Russia ties as Volvo and JLR halt car deliveries'
13 | },
14 | id: 'base-gift-article-static-id',
15 | enterpriseApiBaseUrl: `https://enterprise-sharing-api.ft.com`,
16 | highlight:
17 | 'Lorem ipsum dolor sit amet consectetur, adipisicing elit. Dolorum quos, quis quas ad, minima fuga at nemo deleniti hic repellendus totam. Impedit mollitia quam repellat harum. Nostrum sapiente minima soluta , Lorem ipsum dolor sit amet consectetur, adipisicing elit. Dolorum quos, quis quas ad, minima fuga at nemo deleniti hic repellendus totam. Impedit mollitia quam repellat harum. Nostrum sapiente minima soluta.'
18 | }
19 |
20 | exports.fetchMock = (fetchMock) => {
21 | fetchMock
22 | .restore()
23 | .get('path:/article/gift-credits', {
24 | allowance: 20,
25 | consumedCredits: 5,
26 | remainingCredits: 15,
27 | renewalDate: '2018-08-01T00:00:00Z'
28 | })
29 | .get(`path:/article/shorten-url/${encodeURIComponent(articleUrlRedeemed)}`, {
30 | shortenedUrl: 'https://shortened-gift-url'
31 | })
32 | .get(`path:/article/shorten-url/${encodeURIComponent(nonGiftArticleUrl)}`, {
33 | shortenedUrl: 'https://shortened-non-gift-url'
34 | })
35 | .get(`path:/article/gift-link/${encodeURIComponent(articleId)}`, {
36 | redemptionUrl: articleUrlRedeemed,
37 | redemptionLimit: 3,
38 | remainingAllowance: 1
39 | })
40 | .get('path:/v1/users/me/allowance', {
41 | limit: 120,
42 | budget: 100,
43 | hasCredits: true
44 | })
45 | .post('path:/v1/shares', {
46 | url: articleUrlRedeemed,
47 | redeemLimit: 120
48 | })
49 | }
50 |
--------------------------------------------------------------------------------
/components/x-privacy-manager/storybook/data.js:
--------------------------------------------------------------------------------
1 | import fetchMock from 'fetch-mock'
2 |
3 | export const CONSENT_API = 'https://mock-consent.ft.com'
4 |
5 | const legislations = ['gdpr', 'ccpa']
6 |
7 | export const redirectUrls = {
8 | 'FT.com': 'https://www.ft.com',
9 | 'FT.com: article': 'https://www.ft.com/content/6d2655e2-7c7d-4bbc-91af-035b1a074fb1',
10 | Specialist: 'https://www.exec-appointments.com',
11 | 'Specialist: article':
12 | 'https://www.exec-appointments.com/job/1632533/director-loans-and-social-development-directorate/?LinkSource=PremiumListing',
13 | Empty: '',
14 | None: undefined
15 | }
16 |
17 | export const defaultArgs = {
18 | userId: 'fakeUserId',
19 | legislationId: 'ccpa',
20 | redirectUrl: redirectUrls['FT.com'],
21 | loginUrl: 'https://www.ft.com/login?location=/',
22 | fow: {
23 | id: 'privacyCCPA',
24 | version: 'H0IeyQBalorD.6nTqqzhNTKECSgOPJCG'
25 | },
26 | consent: true,
27 | consentSource: 'next-control-centre',
28 | consentProxyApiHost: CONSENT_API,
29 | buttonText: {
30 | allow: {
31 | label: 'Allow',
32 | text: 'See personalised adverts'
33 | },
34 | block: {
35 | label: 'Block',
36 | text: 'Opt out of personalised adverts'
37 | },
38 | submit: {
39 | label: 'Save'
40 | }
41 | }
42 | }
43 |
44 | export const defaultArgTypes = {
45 | userId: {
46 | name: 'Authentication',
47 | control: { type: 'select', options: { loggedIn: defaultArgs.userId, loggedOut: undefined } }
48 | },
49 | legislationId: { control: { type: 'select', options: legislations } },
50 | redirectUrl: { control: { type: 'select', options: redirectUrls } },
51 | consent: { control: { type: 'boolean' }, name: 'consent' },
52 | fow: { table: { disable: true } },
53 | consentSource: { table: { disable: true } },
54 | consentProxyApiHost: { table: { disable: true } },
55 | buttonText: { table: { disable: true } }
56 | }
57 |
58 | export const getFetchMock = (status = 200, options = {}) => {
59 | fetchMock.reset()
60 | fetchMock.mock('https://mock-consent.ft.com/__consent/consent-record/FTPINK/fakeUserId', status, {
61 | delay: 1000,
62 | ...options
63 | })
64 | }
65 |
--------------------------------------------------------------------------------
/components/x-gift-article/storybook/share-article-modal-with-advanced-sharing-free-article.jsx:
--------------------------------------------------------------------------------
1 | const articleId = 'e4b5ade3-01d1-4db8-b197-257051656684'
2 | const articleUrl = 'https://www.ft.com/content/e4b5ade3-01d1-4db8-b197-257051656684'
3 | const articleUrlRedeemed = 'https://enterprise-sharing.ft.com/gift-url-redeemed'
4 | const nonGiftArticleUrl = `${articleUrl}?shareType=nongift`
5 |
6 | exports.args = {
7 | title: 'Share this article with:',
8 | isFreeArticle: true,
9 | article: {
10 | id: articleId,
11 | url: articleUrl,
12 | title: 'Equinor and Daimler Truck cut Russia ties as Volvo and JLR halt car deliveries'
13 | },
14 | id: 'base-gift-article-static-id',
15 | enterpriseApiBaseUrl: `https://enterprise-sharing-api.ft.com`,
16 | highlight:
17 | 'Lorem ipsum dolor sit amet consectetur, adipisicing elit. Dolorum quos, quis quas ad, minima fuga at nemo deleniti hic repellendus totam. Impedit mollitia quam repellat harum. Nostrum sapiente minima soluta , Lorem ipsum dolor sit amet consectetur, adipisicing elit. Dolorum quos, quis quas ad, minima fuga at nemo deleniti hic repellendus totam. Impedit mollitia quam repellat harum. Nostrum sapiente minima soluta.'
18 | }
19 |
20 | exports.fetchMock = (fetchMock) => {
21 | fetchMock
22 | .restore()
23 | .get('path:/article/gift-credits', {
24 | allowance: 20,
25 | consumedCredits: 5,
26 | remainingCredits: 15,
27 | renewalDate: '2018-08-01T00:00:00Z'
28 | })
29 | .get(`path:/article/shorten-url/${encodeURIComponent(articleUrlRedeemed)}`, {
30 | shortenedUrl: 'https://shortened-gift-url'
31 | })
32 | .get(`path:/article/shorten-url/${encodeURIComponent(nonGiftArticleUrl)}`, {
33 | shortenedUrl: 'https://shortened-non-gift-url'
34 | })
35 | .get(`path:/article/gift-link/${encodeURIComponent(articleId)}`, {
36 | redemptionUrl: articleUrlRedeemed,
37 | redemptionLimit: 3,
38 | remainingAllowance: 1
39 | })
40 | .get('path:/v1/users/me/allowance', {
41 | limit: 120,
42 | budget: 100,
43 | hasCredits: true
44 | })
45 | .post('path:/v1/shares', {
46 | url: articleUrlRedeemed,
47 | redeemLimit: 120
48 | })
49 | }
50 |
--------------------------------------------------------------------------------
/components/x-gift-article/storybook/share-article-modal-with-advanced-sharing-no-gift-credits.jsx:
--------------------------------------------------------------------------------
1 | const articleId = 'e4b5ade3-01d1-4db8-b197-257051656684'
2 | const articleUrl = 'https://www.ft.com/content/e4b5ade3-01d1-4db8-b197-257051656684'
3 | const articleUrlRedeemed = 'https://enterprise-sharing.ft.com/gift-url-redeemed'
4 | const nonGiftArticleUrl = `${articleUrl}?shareType=nongift`
5 |
6 | exports.args = {
7 | title: 'Share this article with:',
8 | isFreeArticle: false,
9 | article: {
10 | id: articleId,
11 | url: articleUrl,
12 | title: 'Equinor and Daimler Truck cut Russia ties as Volvo and JLR halt car deliveries'
13 | },
14 | id: 'base-gift-article-static-id',
15 | enterpriseApiBaseUrl: `https://enterprise-sharing-api.ft.com`,
16 | highlight:
17 | 'Lorem ipsum dolor sit amet consectetur, adipisicing elit. Dolorum quos, quis quas ad, minima fuga at nemo deleniti hic repellendus totam. Impedit mollitia quam repellat harum. Nostrum sapiente minima soluta , Lorem ipsum dolor sit amet consectetur, adipisicing elit. Dolorum quos, quis quas ad, minima fuga at nemo deleniti hic repellendus totam. Impedit mollitia quam repellat harum. Nostrum sapiente minima soluta.'
18 | }
19 |
20 | exports.fetchMock = (fetchMock) => {
21 | fetchMock
22 | .restore()
23 | .get('path:/article/gift-credits', {
24 | allowance: 20,
25 | consumedCredits: 20,
26 | remainingCredits: 0,
27 | renewalDate: '2018-08-01T00:00:00Z'
28 | })
29 | .get(`path:/article/shorten-url/${encodeURIComponent(articleUrlRedeemed)}`, {
30 | shortenedUrl: 'https://shortened-gift-url'
31 | })
32 | .get(`path:/article/shorten-url/${encodeURIComponent(nonGiftArticleUrl)}`, {
33 | shortenedUrl: 'https://shortened-non-gift-url'
34 | })
35 | .get(`path:/article/gift-link/${encodeURIComponent(articleId)}`, {
36 | redemptionUrl: articleUrlRedeemed,
37 | redemptionLimit: 3,
38 | remainingAllowance: 1
39 | })
40 | .get('path:/v1/users/me/allowance', {
41 | limit: 120,
42 | budget: 100,
43 | hasCredits: true
44 | })
45 | .post('path:/v1/shares', {
46 | url: articleUrlRedeemed,
47 | redeemLimit: 120
48 | })
49 | }
50 |
--------------------------------------------------------------------------------
/components/x-privacy-manager/src/__tests__/config.test.jsx:
--------------------------------------------------------------------------------
1 | const { h } = require('@financial-times/x-engine')
2 | const { mount } = require('@financial-times/x-test-utils/enzyme')
3 |
4 | import { defaultProps } from './helpers'
5 |
6 | import { BasePrivacyManager } from '../privacy-manager'
7 |
8 | describe('Config', () => {
9 | it('renders the default UI', () => {
10 | const subject = mount( )
11 | const labelTrue = subject.find('label[htmlFor="consent-true"]')
12 | const labelFalse = subject.find('label[htmlFor="consent-false"]')
13 |
14 | expect(labelTrue.text()).toBe('Allow' + 'See personalised adverts')
15 | expect(labelFalse.text()).toBe('Block' + 'Opt out of personalised adverts')
16 | })
17 |
18 | it('renders custom Button text', () => {
19 | const buttonText = {
20 | allow: {
21 | label: 'Custom label',
22 | text: 'Custom allow text'
23 | },
24 | submit: { label: 'Custom save' }
25 | }
26 | const props = { ...defaultProps, buttonText }
27 |
28 | const subject = mount( )
29 | const labelTrue = subject.find('[data-trackable="ccpa-advertising-toggle-allow"] + label')
30 | const labelFalse = subject.find('[data-trackable="ccpa-advertising-toggle-block"] + label')
31 | const btnSave = subject.find('[data-trackable="ccpa-consent-block"]')
32 |
33 | expect(labelTrue.text()).toBe('Custom label' + 'Custom allow text')
34 | expect(labelFalse.text()).toBe('Block' + 'Opt out of personalised adverts')
35 | expect(btnSave.text()).toBe('Custom save')
36 | })
37 |
38 | it('renders legislation-specific data-trackable attrs', () => {
39 | const props = { ...defaultProps, legislationId: 'gdpr' }
40 | const subject = mount( )
41 |
42 | const inputTrue = subject.find('[data-trackable="gdpr-advertising-toggle-allow"] + label')
43 | const inputFalse = subject.find('[data-trackable="gdpr-advertising-toggle-block"] + label')
44 |
45 | expect(inputTrue.text()).toBe('Allow' + 'See personalised adverts')
46 | expect(inputFalse.text()).toBe('Block' + 'Opt out of personalised adverts')
47 | })
48 | })
49 |
--------------------------------------------------------------------------------
/components/x-gift-article/storybook/share-article-modal-with-advanced-sharing-no-enterprise-credits-mpr.jsx:
--------------------------------------------------------------------------------
1 | const articleId = 'e4b5ade3-01d1-4db8-b197-257051656684'
2 | const articleUrl = 'https://www.ft.com/content/e4b5ade3-01d1-4db8-b197-257051656684'
3 | const articleUrlRedeemed = 'https://enterprise-sharing.ft.com/gift-url-redeemed'
4 | const nonGiftArticleUrl = `${articleUrl}?shareType=nongift`
5 |
6 | exports.args = {
7 | title: 'Share this article using:',
8 | isFreeArticle: false,
9 | isMPRArticle: true,
10 | article: {
11 | id: articleId,
12 | url: articleUrl,
13 | title: 'Equinor and Daimler Truck cut Russia ties as Volvo and JLR halt car deliveries'
14 | },
15 | id: 'base-gift-article-static-id',
16 | enterpriseApiBaseUrl: `https://enterprise-sharing-api.ft.com`,
17 | highlight:
18 | 'Lorem ipsum dolor sit amet consectetur, adipisicing elit. Dolorum quos, quis quas ad, minima fuga at nemo deleniti hic repellendus totam. Impedit mollitia quam repellat harum. Nostrum sapiente minima soluta , Lorem ipsum dolor sit amet consectetur, adipisicing elit. Dolorum quos, quis quas ad, minima fuga at nemo deleniti hic repellendus totam. Impedit mollitia quam repellat harum. Nostrum sapiente minima soluta.'
19 | }
20 |
21 | exports.fetchMock = (fetchMock) => {
22 | fetchMock
23 | .restore()
24 | .get('path:/article/gift-credits', {
25 | allowance: 20,
26 | consumedCredits: 5,
27 | remainingCredits: 0,
28 | renewalDate: '2018-08-01T00:00:00Z'
29 | })
30 | .get(`path:/article/shorten-url/${encodeURIComponent(articleUrlRedeemed)}`, {
31 | shortenedUrl: 'https://shortened-gift-url'
32 | })
33 | .get(`path:/article/shorten-url/${encodeURIComponent(nonGiftArticleUrl)}`, {
34 | shortenedUrl: 'https://shortened-non-gift-url'
35 | })
36 | .get(`path:/article/gift-link/${encodeURIComponent(articleId)}`, {
37 | redemptionUrl: articleUrlRedeemed,
38 | redemptionLimit: 3,
39 | remainingAllowance: 0
40 | })
41 | .get('path:/v1/users/me/allowance', {
42 | limit: 120,
43 | hasCredits: false
44 | })
45 | .post('path:/v1/shares', {
46 | url: articleUrlRedeemed,
47 | redeemLimit: 120
48 | })
49 | }
50 |
--------------------------------------------------------------------------------
/components/x-gift-article/storybook/share-article-modal-with-advanced-sharing-mpr-version.jsx:
--------------------------------------------------------------------------------
1 | const articleId = 'e4b5ade3-01d1-4db8-b197-257051656684'
2 | const articleUrl = 'https://www.ft.com/content/e4b5ade3-01d1-4db8-b197-257051656684'
3 | const articleUrlRedeemed = 'https://enterprise-sharing.ft.com/gift-url-redeemed'
4 | const nonGiftArticleUrl = `${articleUrl}?shareType=nongift`
5 |
6 | exports.args = {
7 | title: 'Share this article using:',
8 | isFreeArticle: false,
9 | isMPRArticle: true,
10 | article: {
11 | id: articleId,
12 | url: articleUrl,
13 | title: 'Equinor and Daimler Truck cut Russia ties as Volvo and JLR halt car deliveries'
14 | },
15 | id: 'base-gift-article-static-id',
16 | enterpriseApiBaseUrl: `https://enterprise-sharing-api.ft.com`,
17 | highlight:
18 | 'Lorem ipsum dolor sit amet consectetur, adipisicing elit. Dolorum quos, quis quas ad, minima fuga at nemo deleniti hic repellendus totam. Impedit mollitia quam repellat harum. Nostrum sapiente minima soluta , Lorem ipsum dolor sit amet consectetur, adipisicing elit. Dolorum quos, quis quas ad, minima fuga at nemo deleniti hic repellendus totam. Impedit mollitia quam repellat harum. Nostrum sapiente minima soluta.'
19 | }
20 |
21 | exports.fetchMock = (fetchMock) => {
22 | fetchMock
23 | .restore()
24 | .get('path:/article/gift-credits', {
25 | allowance: 20,
26 | consumedCredits: 5,
27 | remainingCredits: 15,
28 | renewalDate: '2018-08-01T00:00:00Z'
29 | })
30 | .get(`path:/article/shorten-url/${encodeURIComponent(articleUrlRedeemed)}`, {
31 | shortenedUrl: 'https://shortened-gift-url'
32 | })
33 | .get(`path:/article/shorten-url/${encodeURIComponent(nonGiftArticleUrl)}`, {
34 | shortenedUrl: 'https://shortened-non-gift-url'
35 | })
36 | .get(`path:/article/gift-link/${encodeURIComponent(articleId)}`, {
37 | redemptionUrl: articleUrlRedeemed,
38 | redemptionLimit: 3,
39 | remainingAllowance: 1
40 | })
41 | .get('path:/v1/users/me/allowance', {
42 | limit: 120,
43 | budget: 100,
44 | hasCredits: true
45 | })
46 | .post('path:/v1/shares', {
47 | url: articleUrlRedeemed,
48 | redeemLimit: 120
49 | })
50 | }
51 |
--------------------------------------------------------------------------------
/components/x-interaction/__tests__/registerComponent.test.js:
--------------------------------------------------------------------------------
1 | const {
2 | registerComponent,
3 | getComponentByName,
4 | getComponent,
5 | getComponentName
6 | } = require('../src/concerns/register-component')
7 | const { withActions } = require('../')
8 |
9 | describe('registerComponent', () => {
10 | let name
11 | let Component
12 | beforeAll(() => {
13 | name = 'testComponent'
14 | Component = withActions({})(() => null)
15 | })
16 |
17 | it(`should register a component in registerComponent`, () => {
18 | registerComponent(Component, name)
19 |
20 | const actualComponent = getComponentByName(name)
21 | expect(actualComponent).toBeTruthy()
22 | })
23 |
24 | it('should throw an error if the component has already been registered', () => {
25 | expect(() => registerComponent(Component, name)).toThrow(
26 | 'x-interaction a component has already been registered under that name, please use another name.'
27 | )
28 | })
29 |
30 | it('should throw an error if the component is not x-interaction wrapped', () => {
31 | const unwrappedComponentName = 'unwrappedComponent'
32 | const unwrappedComponent = { _wraps: null }
33 |
34 | expect(() => registerComponent(unwrappedComponent, unwrappedComponentName)).toThrow(
35 | 'only x-interaction wrapped components (i.e. the component returned from withActions) can be registered'
36 | )
37 | })
38 |
39 | it('should get component that is already registered in getComponent', () => {
40 | expect(getComponent(Component)).toBeTruthy()
41 | })
42 |
43 | it('should get component by name in getComponentByName', () => {
44 | const actualComponent = getComponentByName(name)
45 |
46 | expect(actualComponent).toEqual(Component)
47 | })
48 |
49 | it('should get component name in getComponentName', () => {
50 | const actualName = getComponentName(Component)
51 |
52 | expect(actualName).toBe(name)
53 | })
54 |
55 | it('should return Unknown if Component is not registered in getComponentName', () => {
56 | const unregisteredComponent = withActions({})(() => null)
57 |
58 | const actualName = getComponentName(unregisteredComponent)
59 |
60 | expect(actualName).toBe('Unknown')
61 | })
62 | })
63 |
--------------------------------------------------------------------------------
/components/x-teaser-timeline/src/lib/date.js:
--------------------------------------------------------------------------------
1 | import { differenceInCalendarDays, format, isAfter, subMinutes, parseISO, parse } from 'date-fns'
2 |
3 | /**
4 | * Takes a UTC ISO date/time and turns it into a ISO date for a particular timezone
5 | * @param {string} isoDate A UTC ISO date, e.g. '2018-07-19T12:00:00.000Z'
6 | * @param {number} timezoneOffset Minutes ahead (negative) or behind UTC
7 | * @return {string} A localised ISO date, e.g. '2018-07-19T00:30:00.000+01:00' for UTC+1
8 | */
9 | export const getLocalisedISODate = (isoDate, timezoneOffset) => {
10 | const localisedDate = parseISO(isoDate)
11 | const dateWithoutTimezone = subMinutes(localisedDate, timezoneOffset).toISOString().substring(0, 23)
12 | const future = timezoneOffset <= 0
13 | const offsetMinutes = Math.abs(timezoneOffset)
14 | const hours = Math.floor(offsetMinutes / 60)
15 | const minutes = offsetMinutes % 60
16 | const pad = (n) => String(n).padStart(2, '0')
17 |
18 | return `${dateWithoutTimezone}${future ? '+' : '-'}${pad(hours)}:${pad(minutes)}`
19 | }
20 |
21 | /**
22 | * @param {string} localDate
23 | * @param {string} localTodayDate
24 | * @returns {string}
25 | */
26 | export const getTitleForItemGroup = (localDate, localTodayDate) => {
27 | if (localDate === 'today-latest') {
28 | return 'Latest News'
29 | }
30 |
31 | if (localDate === 'today-earlier' || localDate === localTodayDate) {
32 | return 'Earlier Today'
33 | }
34 |
35 | if (
36 | differenceInCalendarDays(
37 | parse(localTodayDate, 'yyyy-MM-dd', new Date()),
38 | parse(localDate, 'yyyy-MM-dd', new Date())
39 | ) === 1
40 | ) {
41 | return 'Yesterday'
42 | }
43 |
44 | return format(parse(localDate, 'yyyy-MM-dd', new Date()), 'MMMM d, yyyy')
45 | }
46 |
47 | export const splitLatestEarlier = (items, splitDate) => {
48 | const latestItems = []
49 | const earlierItems = []
50 |
51 | items.forEach((item) => {
52 | if (isAfter(item.localisedLastUpdated, splitDate)) {
53 | latestItems.push(item)
54 | } else {
55 | earlierItems.push(item)
56 | }
57 | })
58 |
59 | return { latestItems, earlierItems }
60 | }
61 |
62 | /**
63 | * @param {string} date
64 | * @returns {string}
65 | */
66 | export const getDateOnly = (date) => format(parseISO(date), 'yyyy-MM-dd')
67 |
--------------------------------------------------------------------------------
/packages/x-engine/src/webpack.js:
--------------------------------------------------------------------------------
1 | const assignDeep = require('assign-deep')
2 | const deepGet = require('./concerns/deep-get')
3 | const resolvePkg = require('./concerns/resolve-pkg')
4 | const resolvePeer = require('./concerns/resolve-peer')
5 | const formatConfig = require('./concerns/format-config')
6 |
7 | module.exports = function () {
8 | // 1. try to load the application's package manifest
9 | const pkg = require(resolvePkg())
10 |
11 | // 2. if we have the manifest then find the engine configuration
12 | const raw = deepGet(pkg, 'x-dash.engine.browser')
13 |
14 | if (!raw) {
15 | throw new Error(`x-engine requires a browser runtime to be specified. none found in ${pkg.name}`)
16 | }
17 |
18 | // 3. format the configuration we've loaded
19 | const config = formatConfig(raw)
20 |
21 | const webpack = require('webpack')
22 | const runtimeResolution = resolvePeer(config.runtime)
23 | const renderResolution = resolvePeer(config.renderModule)
24 |
25 | return {
26 | apply(compiler) {
27 | const alias = compiler?.options?.resolve?.alias
28 | const configRuntimePath = alias && alias[config.runtime] ? alias[config.runtime] : runtimeResolution
29 | const configRenderModule =
30 | alias && alias[config.renderModule] ? alias[config.renderModule] : renderResolution
31 | assignDeep(compiler.options, {
32 | resolve: {
33 | alias: {
34 | //Do not override current alias resolvers
35 | [config.runtime]: configRuntimePath,
36 | [config.renderModule]: configRenderModule
37 | }
38 | }
39 | })
40 |
41 | const replacements = {
42 | X_ENGINE_RUNTIME_MODULE: `"${config.runtime}"`,
43 | X_ENGINE_FACTORY: config.factory ? `runtime["${config.factory}"]` : 'runtime',
44 | X_ENGINE_COMPONENT: config.component ? `runtime["${config.component}"]` : 'null',
45 | X_ENGINE_FRAGMENT: config.fragment ? `runtime["${config.fragment}"]` : 'null',
46 | X_ENGINE_RENDER_MODULE: `"${config.renderModule}"`,
47 | X_ENGINE_RENDER: config.render ? `render["${config.render}"]` : 'null'
48 | }
49 |
50 | // The define plugin performs direct text replacement
51 | //
52 | const define = new webpack.DefinePlugin(replacements)
53 |
54 | define.apply(compiler)
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------