@financial-times/dotcom-ui-shell 7.3.1 → 7.3.2
Sign up to get free protection for your applications and to get access to all the features.
- package/package.json +6 -2
- package/src/__test__/components/Content.test.tsx +23 -0
- package/src/__test__/components/DocumentHead.test.tsx +38 -0
- package/src/__test__/components/GTMBody.test.tsx +36 -0
- package/src/__test__/components/GTMHead.test.tsx +27 -0
- package/src/__test__/components/LinkedData.test.tsx +31 -0
- package/src/__test__/components/OpenGraph.test.tsx +25 -0
- package/src/__test__/components/ResourceHints.test.tsx +18 -0
- package/src/__test__/components/Shell.test.tsx +29 -0
- package/src/__test__/components/Stylesheets.test.tsx +22 -0
- package/src/__test__/components/__snapshots__/Content.test.tsx.snap +22 -0
- package/src/__test__/components/__snapshots__/DocumentHead.test.tsx.snap +87 -0
- package/src/__test__/components/__snapshots__/GTMBody.test.tsx.snap +21 -0
- package/src/__test__/components/__snapshots__/GTMHead.test.tsx.snap +17 -0
- package/src/__test__/components/__snapshots__/LinkedData.test.tsx.snap +41 -0
- package/src/__test__/components/__snapshots__/OpenGraph.test.tsx.snap +42 -0
- package/src/__test__/components/__snapshots__/ResourceHints.test.tsx.snap +54 -0
- package/src/__test__/components/__snapshots__/Shell.test.tsx.snap +317 -0
- package/src/__test__/components/__snapshots__/Stylesheets.test.tsx.snap +50 -0
- package/src/__test__/lib/flattenOpenGraphData.spec.ts +37 -0
- package/src/__test__/lib/formatAttributeNames.spec.ts +38 -0
- package/src/__test__/lib/getResourceType.spec.ts +28 -0
- package/src/components/Content.tsx +25 -0
- package/src/components/DocumentHead.tsx +103 -0
- package/src/components/GTMBody.tsx +29 -0
- package/src/components/GTMHead.tsx +23 -0
- package/src/components/LinkedData.tsx +38 -0
- package/src/components/OpenGraph.tsx +20 -0
- package/src/components/ResourceHints.tsx +63 -0
- package/src/components/Shell.tsx +97 -0
- package/src/components/StyleSheets.tsx +35 -0
- package/src/index.ts +1 -0
- package/src/lib/flattenOpenGraphData.ts +24 -0
- package/src/lib/formatAttributeNames.ts +36 -0
- package/src/lib/getResourceType.ts +34 -0
- package/src/lib/imageServiceIconURL.ts +24 -0
- package/src/lib/loadAsyncStylesheets.ts +27 -0
@@ -0,0 +1,38 @@
|
|
1
|
+
import React from 'react'
|
2
|
+
|
3
|
+
export type TLinkedDataProps = {
|
4
|
+
jsonLd?: { [key: string]: any }
|
5
|
+
}
|
6
|
+
|
7
|
+
const LinkedData = ({ jsonLd }: TLinkedDataProps) => (
|
8
|
+
<React.Fragment>
|
9
|
+
{Array.isArray(jsonLd) &&
|
10
|
+
jsonLd.map((data, i) => (
|
11
|
+
<script
|
12
|
+
key={`jsonld-${i}`}
|
13
|
+
type="application/ld+json"
|
14
|
+
dangerouslySetInnerHTML={{
|
15
|
+
__html: JSON.stringify(data)
|
16
|
+
}}
|
17
|
+
/>
|
18
|
+
))}
|
19
|
+
<script
|
20
|
+
type="application/ld+json"
|
21
|
+
dangerouslySetInnerHTML={{
|
22
|
+
__html: JSON.stringify({
|
23
|
+
'@context': 'http://schema.org',
|
24
|
+
'@type': 'WebSite',
|
25
|
+
name: 'Financial Times',
|
26
|
+
alternateName: 'FT.com',
|
27
|
+
url: 'http://www.ft.com'
|
28
|
+
})
|
29
|
+
}}
|
30
|
+
/>
|
31
|
+
</React.Fragment>
|
32
|
+
)
|
33
|
+
|
34
|
+
LinkedData.defaultProps = {
|
35
|
+
jsonLd: []
|
36
|
+
}
|
37
|
+
|
38
|
+
export default LinkedData
|
@@ -0,0 +1,20 @@
|
|
1
|
+
import React from 'react'
|
2
|
+
import flattenOpenGraphData, { TOpenGraphData } from '../lib/flattenOpenGraphData'
|
3
|
+
|
4
|
+
export type TOpenGraphProps = {
|
5
|
+
openGraph?: TOpenGraphData
|
6
|
+
}
|
7
|
+
|
8
|
+
const OpenGraph = ({ openGraph }: TOpenGraphProps) => (
|
9
|
+
<React.Fragment>
|
10
|
+
{flattenOpenGraphData(openGraph).map(([property, content], i) => (
|
11
|
+
<meta key={`og-${i}`} property={property} content={content} />
|
12
|
+
))}
|
13
|
+
</React.Fragment>
|
14
|
+
)
|
15
|
+
|
16
|
+
OpenGraph.defaultProps = {
|
17
|
+
openGraph: {}
|
18
|
+
}
|
19
|
+
|
20
|
+
export default OpenGraph
|
@@ -0,0 +1,63 @@
|
|
1
|
+
import React from 'react'
|
2
|
+
import mimeTypes from 'mime-types'
|
3
|
+
import getResourceType from '../lib/getResourceType'
|
4
|
+
|
5
|
+
export type TResourceHintsProps = {
|
6
|
+
resourceHints?: string[]
|
7
|
+
}
|
8
|
+
|
9
|
+
const ResourceHints = (props: TResourceHintsProps) => {
|
10
|
+
return (
|
11
|
+
<React.Fragment>
|
12
|
+
{/*
|
13
|
+
Spoor is the API which receives the tracking events sent by the o-tracking library
|
14
|
+
<https://github.com/Financial-Times/o-tracking>
|
15
|
+
*/}
|
16
|
+
<link rel="preconnect" href="https://spoor-api.ft.com" />
|
17
|
+
{/*
|
18
|
+
The session API is used to validate users and retrieve information about them
|
19
|
+
<https://github.com/Financial-Times/next-session>
|
20
|
+
*/}
|
21
|
+
<link rel="preconnect" href="https://session-next.ft.com" crossOrigin="use-credentials" />
|
22
|
+
{/*
|
23
|
+
The ads API is used to fetch ad targeting information for the current page
|
24
|
+
<https://github.com/Financial-Times/next-ads-api>
|
25
|
+
*/}
|
26
|
+
<link rel="preconnect" href="https://ads-api.ft.com" />
|
27
|
+
{/*
|
28
|
+
The Google Publisher Tag library (GPT) is hosted here which is used to deliver ads
|
29
|
+
<https://github.com/Financial-Times/o-ads/blob/HEAD/src/js/ad-servers/gpt.js>
|
30
|
+
*/}
|
31
|
+
<link rel="preconnect" href="https://securepubads.g.doubleclick.net" />
|
32
|
+
|
33
|
+
{props.resourceHints.map((resource, i) => {
|
34
|
+
const contentType = getResourceType(resource)
|
35
|
+
const mimeType =
|
36
|
+
mimeTypes.lookup(resource) ||
|
37
|
+
mimeTypes.lookup(resource.match(/(?<=font_format=)([a-z0-9]+)/)?.[0]) ||
|
38
|
+
null
|
39
|
+
|
40
|
+
const attributes: React.LinkHTMLAttributes<HTMLLinkElement> = {
|
41
|
+
as: contentType,
|
42
|
+
href: resource,
|
43
|
+
type: mimeType
|
44
|
+
}
|
45
|
+
|
46
|
+
// Fonts are expected to be fetched anonymously by the browser, and the preload request is
|
47
|
+
// only made anonymous by using the crossorigin attribute.
|
48
|
+
// <https://developer.mozilla.org/en-US/docs/Web/HTML/Preloading_content>
|
49
|
+
if (contentType === 'font') {
|
50
|
+
attributes.crossOrigin = 'anonymous'
|
51
|
+
}
|
52
|
+
|
53
|
+
return <link key={`hint-${i}`} rel="preload" {...attributes} />
|
54
|
+
})}
|
55
|
+
</React.Fragment>
|
56
|
+
)
|
57
|
+
}
|
58
|
+
|
59
|
+
ResourceHints.defaultProps = {
|
60
|
+
resourceHints: []
|
61
|
+
}
|
62
|
+
|
63
|
+
export default ResourceHints
|
@@ -0,0 +1,97 @@
|
|
1
|
+
import React from 'react'
|
2
|
+
import Content, { TContentProps } from './Content'
|
3
|
+
import DocumentHead, { TDocumentHeadProps } from './DocumentHead'
|
4
|
+
import StyleSheets, { TStylesheetProps } from './StyleSheets'
|
5
|
+
import ResourceHints, { TResourceHintsProps } from './ResourceHints'
|
6
|
+
import { AppContextEmbed, TAppContextProps } from '@financial-times/dotcom-ui-app-context'
|
7
|
+
import {
|
8
|
+
fontFaceURLs,
|
9
|
+
documentStyles,
|
10
|
+
LoadFontsEmbed,
|
11
|
+
loadCustomFontsClassNames
|
12
|
+
} from '@financial-times/dotcom-ui-base-styles'
|
13
|
+
import { FlagsEmbed, TFlagsEmbedProps } from '@financial-times/dotcom-ui-flags'
|
14
|
+
import { Bootstrap, TBootstrapProps } from '@financial-times/dotcom-ui-bootstrap'
|
15
|
+
import * as polyfillService from '@financial-times/dotcom-ui-polyfill-service'
|
16
|
+
import formatAttributeNames, { TAttributeData } from '../lib/formatAttributeNames'
|
17
|
+
import GTMHead from './GTMHead'
|
18
|
+
import GTMBody from './GTMBody'
|
19
|
+
|
20
|
+
type TShellProps = TDocumentHeadProps &
|
21
|
+
TAppContextProps &
|
22
|
+
TStylesheetProps &
|
23
|
+
TResourceHintsProps &
|
24
|
+
TContentProps &
|
25
|
+
TFlagsEmbedProps & {
|
26
|
+
scripts?: string[]
|
27
|
+
children?: any
|
28
|
+
initialProps?: any
|
29
|
+
bodyAttributes?: TAttributeData
|
30
|
+
htmlAttributes?: TAttributeData
|
31
|
+
}
|
32
|
+
|
33
|
+
function Shell(props: TShellProps) {
|
34
|
+
const bootstrapProps: TBootstrapProps = {
|
35
|
+
coreScripts: [polyfillService.core()],
|
36
|
+
enhancedScripts: [polyfillService.enhanced(), ...props.scripts]
|
37
|
+
}
|
38
|
+
|
39
|
+
const resourceHints = [
|
40
|
+
polyfillService.enhanced(),
|
41
|
+
// There is no need to include stylesheets here as any <link rel="stylesheet" /> tags
|
42
|
+
// should be found by the browser's speculative parser.
|
43
|
+
...props.scripts,
|
44
|
+
...props.resourceHints,
|
45
|
+
...fontFaceURLs
|
46
|
+
]
|
47
|
+
|
48
|
+
return (
|
49
|
+
<html
|
50
|
+
{...formatAttributeNames(props.htmlAttributes)}
|
51
|
+
lang="en-GB"
|
52
|
+
className={`no-js core ${loadCustomFontsClassNames}`}
|
53
|
+
data-o-component="o-typography"
|
54
|
+
style={documentStyles}
|
55
|
+
>
|
56
|
+
<head>
|
57
|
+
<DocumentHead {...props} />
|
58
|
+
<ResourceHints resourceHints={resourceHints} />
|
59
|
+
{/* TODO: refactor initial props, flags data, and context data to the bottom of body */}
|
60
|
+
<script
|
61
|
+
id="initial-props"
|
62
|
+
type="application/json"
|
63
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(props.initialProps) }}
|
64
|
+
/>
|
65
|
+
<StyleSheets
|
66
|
+
criticalStyles={props.criticalStyles}
|
67
|
+
stylesheets={props.stylesheets}
|
68
|
+
asyncStylesheets={props.asyncStylesheets}
|
69
|
+
/>
|
70
|
+
<Bootstrap {...bootstrapProps} />
|
71
|
+
<GTMHead flags={props.flags} />
|
72
|
+
<LoadFontsEmbed />
|
73
|
+
</head>
|
74
|
+
<body {...formatAttributeNames(props.bodyAttributes)}>
|
75
|
+
<GTMBody flags={props.flags} />
|
76
|
+
<Content contents={props.contents || props.children} />
|
77
|
+
<AppContextEmbed appContext={props.appContext} />
|
78
|
+
<FlagsEmbed flags={props.flags} />
|
79
|
+
</body>
|
80
|
+
</html>
|
81
|
+
)
|
82
|
+
}
|
83
|
+
|
84
|
+
Shell.defaultProps = {
|
85
|
+
scripts: [],
|
86
|
+
stylesheets: [],
|
87
|
+
asyncStylesheets: [],
|
88
|
+
resourceHints: [],
|
89
|
+
htmlAttributes: {},
|
90
|
+
bodyAttributes: {}
|
91
|
+
}
|
92
|
+
|
93
|
+
export { Shell }
|
94
|
+
export type { TShellProps }
|
95
|
+
|
96
|
+
// Export sub-components to more-easily enable custom integrations
|
97
|
+
export { DocumentHead, ResourceHints, Content }
|
@@ -0,0 +1,35 @@
|
|
1
|
+
import React from 'react'
|
2
|
+
import loadAsyncStylesheetsString from '../lib/loadAsyncStylesheets'
|
3
|
+
|
4
|
+
export type TStylesheetProps = {
|
5
|
+
criticalStyles?: string
|
6
|
+
stylesheets?: string[]
|
7
|
+
asyncStylesheets?: string[]
|
8
|
+
}
|
9
|
+
|
10
|
+
const Stylesheets = ({ criticalStyles, stylesheets, asyncStylesheets }: TStylesheetProps) => (
|
11
|
+
<React.Fragment>
|
12
|
+
{criticalStyles && <style dangerouslySetInnerHTML={{ __html: criticalStyles }} />}
|
13
|
+
{Array.isArray(stylesheets) &&
|
14
|
+
stylesheets.map((stylesheet, i) => <link rel="stylesheet" key={`stylesheet-${i}`} href={stylesheet} />)}
|
15
|
+
{Array.isArray(asyncStylesheets) && !!asyncStylesheets.length && (
|
16
|
+
<React.Fragment>
|
17
|
+
<noscript>
|
18
|
+
{asyncStylesheets.map((stylesheet, i) => (
|
19
|
+
<link rel="stylesheet" href={stylesheet} key={`async-stylesheet-${i}`} />
|
20
|
+
))}
|
21
|
+
</noscript>
|
22
|
+
<script
|
23
|
+
data-stylesheets={asyncStylesheets.join()}
|
24
|
+
dangerouslySetInnerHTML={{ __html: loadAsyncStylesheetsString }}></script>
|
25
|
+
</React.Fragment>
|
26
|
+
)}
|
27
|
+
</React.Fragment>
|
28
|
+
)
|
29
|
+
|
30
|
+
Stylesheets.defaultProps = {
|
31
|
+
stylesheets: [],
|
32
|
+
asyncStylesheets: []
|
33
|
+
}
|
34
|
+
|
35
|
+
export default Stylesheets
|
package/src/index.ts
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
export * from './components/Shell'
|
@@ -0,0 +1,24 @@
|
|
1
|
+
// Flattens a nested object into an array of key/value pairs
|
2
|
+
// { foo: { bar: { baz: 123 } } } => [['foo:bar:baz', 123]]
|
3
|
+
|
4
|
+
export type TOpenGraphData = {
|
5
|
+
[key: string]: any | any[] | TOpenGraphData
|
6
|
+
}
|
7
|
+
|
8
|
+
export default function flattenData(data: TOpenGraphData, prefix?: string): Array<string[]> {
|
9
|
+
const output = []
|
10
|
+
|
11
|
+
for (const [key, value] of Object.entries(data)) {
|
12
|
+
const property = prefix ? `${prefix}:${key}` : key
|
13
|
+
|
14
|
+
if (value && value.constructor === Object) {
|
15
|
+
output.push(...flattenData(value, property))
|
16
|
+
} else if (Array.isArray(value)) {
|
17
|
+
output.push(...value.map((value) => [property, value]))
|
18
|
+
} else {
|
19
|
+
output.push([property, value])
|
20
|
+
}
|
21
|
+
}
|
22
|
+
|
23
|
+
return output
|
24
|
+
}
|
@@ -0,0 +1,36 @@
|
|
1
|
+
export type TAttributeData = {
|
2
|
+
[key: string]: string | number | boolean
|
3
|
+
}
|
4
|
+
|
5
|
+
export default function formatAttributeNames(data: TAttributeData = {}) {
|
6
|
+
const output = {}
|
7
|
+
|
8
|
+
for (const [key, value] of Object.entries(data)) {
|
9
|
+
const hyphenatedKey = hyphenateString(key)
|
10
|
+
|
11
|
+
// Let's render boolean data attributes properly
|
12
|
+
// as per https://github.com/Financial-Times/dotcom-page-kit/issues/370
|
13
|
+
if (hyphenatedKey.startsWith('data-') && typeof value === 'boolean') {
|
14
|
+
// Where react is concerned, a `true` boolean data attribute
|
15
|
+
// is one where the attribute value is an empty string (because
|
16
|
+
// it is not possible to render an attribute without a value),
|
17
|
+
// and a `false` boolean data attribute is one where the attribute
|
18
|
+
// has not been specified altogether
|
19
|
+
if (value) {
|
20
|
+
output[hyphenatedKey] = ''
|
21
|
+
}
|
22
|
+
} else {
|
23
|
+
output[hyphenatedKey] = value
|
24
|
+
}
|
25
|
+
}
|
26
|
+
|
27
|
+
return output
|
28
|
+
}
|
29
|
+
|
30
|
+
function hyphenateChar(char) {
|
31
|
+
return '-' + char.toLowerCase()
|
32
|
+
}
|
33
|
+
|
34
|
+
function hyphenateString(prop) {
|
35
|
+
return prop.replace(/([A-Z])/g, hyphenateChar)
|
36
|
+
}
|
@@ -0,0 +1,34 @@
|
|
1
|
+
import path from 'path'
|
2
|
+
import url from 'url'
|
3
|
+
|
4
|
+
const StyleFiles = new Set(['.css'])
|
5
|
+
|
6
|
+
const ScriptFiles = new Set(['.js', '.mjs'])
|
7
|
+
|
8
|
+
const ImageFiles = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp'])
|
9
|
+
|
10
|
+
export default (file: string): string => {
|
11
|
+
// Always parse the file so that we can ignore any domain names, query strings etc.
|
12
|
+
// Node's old URL API is able to parse anything inc. filenames, paths, and URLs.
|
13
|
+
const { pathname } = url.parse(file)
|
14
|
+
|
15
|
+
const extension = path.extname(pathname)
|
16
|
+
|
17
|
+
if (StyleFiles.has(extension)) {
|
18
|
+
return 'style'
|
19
|
+
}
|
20
|
+
|
21
|
+
if (ScriptFiles.has(extension)) {
|
22
|
+
return 'script'
|
23
|
+
}
|
24
|
+
|
25
|
+
if (ImageFiles.has(extension)) {
|
26
|
+
return 'image'
|
27
|
+
}
|
28
|
+
|
29
|
+
if (file.includes('font_format=woff')) {
|
30
|
+
return 'font'
|
31
|
+
}
|
32
|
+
|
33
|
+
throw Error(`Unknown filename extension "${extension}`)
|
34
|
+
}
|
@@ -0,0 +1,24 @@
|
|
1
|
+
import querystring from 'querystring'
|
2
|
+
|
3
|
+
function imageServiceIconURL(image: string, size: number, format = 'png'): string {
|
4
|
+
const serviceURL = 'https://www.ft.com/__origami/service/image/v2/images/raw/'
|
5
|
+
|
6
|
+
const serviceParameters = {
|
7
|
+
source: 'update-logos',
|
8
|
+
format: format,
|
9
|
+
width: size,
|
10
|
+
height: size
|
11
|
+
}
|
12
|
+
|
13
|
+
// Do not add width and height if format is svg because svg files scale automatically
|
14
|
+
if (format === 'svg') {
|
15
|
+
delete serviceParameters.width
|
16
|
+
delete serviceParameters.height
|
17
|
+
}
|
18
|
+
|
19
|
+
const queryString = querystring.stringify(serviceParameters)
|
20
|
+
|
21
|
+
return `${serviceURL}${encodeURIComponent(image)}?${queryString}`
|
22
|
+
}
|
23
|
+
|
24
|
+
export default imageServiceIconURL
|
@@ -0,0 +1,27 @@
|
|
1
|
+
/*
|
2
|
+
Load stylesheets asyncronously. See:
|
3
|
+
• https://www.filamentgroup.com/lab/load-css-simpler/
|
4
|
+
• https://w3c.github.io/preload/#example-5
|
5
|
+
|
6
|
+
@NOTE: This is in ES5 syntax, because it's not compiled, because it's server-side code.
|
7
|
+
(You don't need to compile server-side code because you get to set whichever version of node you want.)
|
8
|
+
Its stringified and given to the client via "dangerouslySetInnerHTML" in a <script> tag.
|
9
|
+
Because it runs in the client, it needs to be ES5 so it's compatible with older browsers.
|
10
|
+
*/
|
11
|
+
function loadAsyncStylesheets() {
|
12
|
+
var currentScript = document.scripts[document.scripts.length - 1]
|
13
|
+
var stylesheets = currentScript.getAttribute('data-stylesheets').split(',')
|
14
|
+
|
15
|
+
for (var i = 0, len = stylesheets.length; i < len; i++) {
|
16
|
+
var link = document.createElement('link')
|
17
|
+
link.href = stylesheets[i]
|
18
|
+
link.rel = 'stylesheet'
|
19
|
+
link.media = 'print' // <-- 'print' is intentional; on load, it changes to 'all'.
|
20
|
+
link.onload = function (event) {
|
21
|
+
var target = event.target as HTMLLinkElement
|
22
|
+
target.media = 'all'
|
23
|
+
}
|
24
|
+
currentScript.parentNode.insertBefore(link, currentScript)
|
25
|
+
}
|
26
|
+
}
|
27
|
+
export default '(' + loadAsyncStylesheets.toString() + ')()'
|