@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.
Files changed (37) hide show
  1. package/package.json +6 -2
  2. package/src/__test__/components/Content.test.tsx +23 -0
  3. package/src/__test__/components/DocumentHead.test.tsx +38 -0
  4. package/src/__test__/components/GTMBody.test.tsx +36 -0
  5. package/src/__test__/components/GTMHead.test.tsx +27 -0
  6. package/src/__test__/components/LinkedData.test.tsx +31 -0
  7. package/src/__test__/components/OpenGraph.test.tsx +25 -0
  8. package/src/__test__/components/ResourceHints.test.tsx +18 -0
  9. package/src/__test__/components/Shell.test.tsx +29 -0
  10. package/src/__test__/components/Stylesheets.test.tsx +22 -0
  11. package/src/__test__/components/__snapshots__/Content.test.tsx.snap +22 -0
  12. package/src/__test__/components/__snapshots__/DocumentHead.test.tsx.snap +87 -0
  13. package/src/__test__/components/__snapshots__/GTMBody.test.tsx.snap +21 -0
  14. package/src/__test__/components/__snapshots__/GTMHead.test.tsx.snap +17 -0
  15. package/src/__test__/components/__snapshots__/LinkedData.test.tsx.snap +41 -0
  16. package/src/__test__/components/__snapshots__/OpenGraph.test.tsx.snap +42 -0
  17. package/src/__test__/components/__snapshots__/ResourceHints.test.tsx.snap +54 -0
  18. package/src/__test__/components/__snapshots__/Shell.test.tsx.snap +317 -0
  19. package/src/__test__/components/__snapshots__/Stylesheets.test.tsx.snap +50 -0
  20. package/src/__test__/lib/flattenOpenGraphData.spec.ts +37 -0
  21. package/src/__test__/lib/formatAttributeNames.spec.ts +38 -0
  22. package/src/__test__/lib/getResourceType.spec.ts +28 -0
  23. package/src/components/Content.tsx +25 -0
  24. package/src/components/DocumentHead.tsx +103 -0
  25. package/src/components/GTMBody.tsx +29 -0
  26. package/src/components/GTMHead.tsx +23 -0
  27. package/src/components/LinkedData.tsx +38 -0
  28. package/src/components/OpenGraph.tsx +20 -0
  29. package/src/components/ResourceHints.tsx +63 -0
  30. package/src/components/Shell.tsx +97 -0
  31. package/src/components/StyleSheets.tsx +35 -0
  32. package/src/index.ts +1 -0
  33. package/src/lib/flattenOpenGraphData.ts +24 -0
  34. package/src/lib/formatAttributeNames.ts +36 -0
  35. package/src/lib/getResourceType.ts +34 -0
  36. package/src/lib/imageServiceIconURL.ts +24 -0
  37. 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() + ')()'