@financial-times/dotcom-middleware-navigation 7.3.1 → 7.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -5
- package/src/__test__/handleEdition.spec.ts +64 -0
- package/src/__test__/navigation.spec.ts +184 -0
- package/src/handleEdition.ts +28 -0
- package/src/index.ts +1 -0
- package/src/navigation.ts +57 -0
- package/src/normalizePath.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@financial-times/dotcom-middleware-navigation",
|
|
3
|
-
"version": "7.3.
|
|
3
|
+
"version": "7.3.3",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "dist/node/index.js",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
"author": "",
|
|
19
19
|
"license": "MIT",
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@financial-times/dotcom-server-navigation": "
|
|
22
|
-
"@financial-times/dotcom-types-navigation": "
|
|
21
|
+
"@financial-times/dotcom-server-navigation": "^7.3.3",
|
|
22
|
+
"@financial-times/dotcom-types-navigation": "^7.3.3"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"check-engine": "^1.10.1",
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
"npm": "7.x || 8.x"
|
|
31
31
|
},
|
|
32
32
|
"files": [
|
|
33
|
-
"dist/"
|
|
33
|
+
"dist/",
|
|
34
|
+
"src/"
|
|
34
35
|
],
|
|
35
36
|
"repository": {
|
|
36
37
|
"type": "git",
|
|
@@ -41,4 +42,4 @@
|
|
|
41
42
|
"volta": {
|
|
42
43
|
"extends": "../../package.json"
|
|
43
44
|
}
|
|
44
|
-
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import subject from '../handleEdition'
|
|
2
|
+
import httpMocks from 'node-mocks-http'
|
|
3
|
+
|
|
4
|
+
describe('dotcom-middleware-navigation/src/handleEdition', () => {
|
|
5
|
+
describe('with no query string parameter or cookie set', () => {
|
|
6
|
+
let request
|
|
7
|
+
let response
|
|
8
|
+
let result
|
|
9
|
+
|
|
10
|
+
beforeAll(() => {
|
|
11
|
+
request = httpMocks.createRequest()
|
|
12
|
+
response = httpMocks.createResponse()
|
|
13
|
+
result = subject(request, response)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('returns the default edition', () => {
|
|
17
|
+
expect(result).toEqual('uk')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('sets an edition vary header', () => {
|
|
21
|
+
expect(response.getHeader('vary')).toBe('FT-Edition')
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('with an edition header set', () => {
|
|
26
|
+
let request
|
|
27
|
+
let response
|
|
28
|
+
let result
|
|
29
|
+
|
|
30
|
+
beforeAll(() => {
|
|
31
|
+
request = httpMocks.createRequest({ headers: { 'ft-edition': 'international' } })
|
|
32
|
+
response = httpMocks.createResponse()
|
|
33
|
+
result = subject(request, response)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('returns the set edition', () => {
|
|
37
|
+
expect(result).toEqual('international')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('sets an edition vary header', () => {
|
|
41
|
+
expect(response.getHeader('vary')).toBe('FT-Edition')
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('with a query string parameter set', () => {
|
|
46
|
+
let request
|
|
47
|
+
let response
|
|
48
|
+
let result
|
|
49
|
+
|
|
50
|
+
beforeAll(() => {
|
|
51
|
+
request = httpMocks.createRequest({ query: { edition: 'international' } })
|
|
52
|
+
response = httpMocks.createResponse()
|
|
53
|
+
result = subject(request, response)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('returns the selected edition', () => {
|
|
57
|
+
expect(result).toEqual('international')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('sets a next-edition cookie with the selected edition', () => {
|
|
61
|
+
expect(response.cookies['next-edition'].value).toBe('international')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import * as subject from '../navigation'
|
|
2
|
+
import httpMocks from 'node-mocks-http'
|
|
3
|
+
|
|
4
|
+
const fakeMenusData = {
|
|
5
|
+
navbar: {
|
|
6
|
+
label: 'Navbar UK',
|
|
7
|
+
items: [{ label: 'Foo', url: '#' }]
|
|
8
|
+
},
|
|
9
|
+
drawer: {
|
|
10
|
+
label: 'Drawer UK',
|
|
11
|
+
items: [{ label: 'Foo', url: '#' }]
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const fakeEditionsData = {
|
|
16
|
+
current: { id: 'uk', name: 'UK', url: '/' },
|
|
17
|
+
others: [{ id: 'international', name: 'International', url: '/' }]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const fakeActionsData = { id: 'subscribe', name: 'Subscribe for full access', url: '/products?' }
|
|
21
|
+
|
|
22
|
+
const fakeSubNavigationData = {
|
|
23
|
+
breadcrumb: 'some-breadcrumb',
|
|
24
|
+
subsections: 'some-subsections'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const fakeNavigationData = { ...fakeMenusData, editions: fakeEditionsData, currentPath: '' }
|
|
28
|
+
|
|
29
|
+
const FakeNavigation = {
|
|
30
|
+
getSubscribeAction: jest.fn().mockResolvedValue(fakeActionsData),
|
|
31
|
+
getMenusFor: jest.fn().mockResolvedValue(fakeMenusData),
|
|
32
|
+
getSubNavigationFor: jest.fn().mockResolvedValue(fakeSubNavigationData),
|
|
33
|
+
getEditionsFor: jest.fn().mockReturnValue(fakeEditionsData)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
jest.mock(
|
|
37
|
+
'@financial-times/dotcom-server-navigation',
|
|
38
|
+
() => {
|
|
39
|
+
return {
|
|
40
|
+
Navigation: jest.fn().mockImplementation(() => FakeNavigation)
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
{ virtual: true }
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
describe('dotcom-middleware-navigation', () => {
|
|
47
|
+
let request
|
|
48
|
+
let response
|
|
49
|
+
let next
|
|
50
|
+
let instance
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
request = httpMocks.createRequest()
|
|
54
|
+
response = httpMocks.createResponse({ locals: {} })
|
|
55
|
+
next = jest.fn()
|
|
56
|
+
instance = subject.init()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
jest.clearAllMocks()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('returns a middleware function', () => {
|
|
64
|
+
expect(instance).toBeInstanceOf(Function)
|
|
65
|
+
expect(instance.length).toBe(3)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('when handling a request', () => {
|
|
69
|
+
it('appends the navigation properties to response.locals', async () => {
|
|
70
|
+
await instance(request, response, next)
|
|
71
|
+
expect(response.locals.navigation).toEqual(expect.objectContaining(fakeNavigationData))
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('prefers the vanity URL if set', async () => {
|
|
75
|
+
const request = httpMocks.createRequest({
|
|
76
|
+
path: '/path/to/app/1234',
|
|
77
|
+
headers: {
|
|
78
|
+
'ft-vanity-url': '/vanity-url'
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
await instance(request, response, next)
|
|
83
|
+
expect(FakeNavigation.getMenusFor).toHaveBeenCalledWith('/vanity-url', 'uk')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('calls the fallthrough function', async () => {
|
|
87
|
+
await instance(request, response, next)
|
|
88
|
+
expect(next).toHaveBeenCalled()
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('when handling a request with sub-navigation enabled', () => {
|
|
93
|
+
let instance
|
|
94
|
+
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
instance = subject.init({ enableSubNavigation: true })
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('prefers the path provided by the vanity URL header when available', async () => {
|
|
100
|
+
request = httpMocks.createRequest({
|
|
101
|
+
path: '/path/to/page/123',
|
|
102
|
+
headers: {
|
|
103
|
+
'ft-vanity-url': '/vanity-url'
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
await instance(request, response, next)
|
|
108
|
+
|
|
109
|
+
expect(FakeNavigation.getSubNavigationFor).toHaveBeenCalledWith('/vanity-url')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('normalizes the current path', async () => {
|
|
113
|
+
request = httpMocks.createRequest({
|
|
114
|
+
path: '/path/to/page/123',
|
|
115
|
+
query: {
|
|
116
|
+
page: 2
|
|
117
|
+
},
|
|
118
|
+
headers: {
|
|
119
|
+
'ft-vanity-url': '/vanity-url?page=2'
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
await instance(request, response, next)
|
|
124
|
+
|
|
125
|
+
expect(FakeNavigation.getSubNavigationFor).toHaveBeenCalledWith('/vanity-url')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('appends the sub-navigation properties on response.locals for the current path', async () => {
|
|
129
|
+
await instance(request, response, next)
|
|
130
|
+
expect(response.locals.navigation).toEqual(expect.objectContaining(fakeSubNavigationData))
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('fails silently if the the sub-navigation request fails', async () => {
|
|
134
|
+
FakeNavigation.getSubNavigationFor.mockRejectedValueOnce(new Error('Fail'))
|
|
135
|
+
|
|
136
|
+
await instance(request, response, next)
|
|
137
|
+
|
|
138
|
+
expect(next).not.toHaveBeenCalledWith(expect.any(Error))
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('when handling a request with a custom getCurrentPath', () => {
|
|
143
|
+
it('executes the provided getCurrentPath function', async () => {
|
|
144
|
+
request = httpMocks.createRequest({
|
|
145
|
+
path: '/path/to/page/123',
|
|
146
|
+
headers: {
|
|
147
|
+
'ft-vanity-url': '/vanity-url?page=2'
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
const dummyPath = '/foo'
|
|
151
|
+
const instance = subject.init({ getCurrentPath: () => dummyPath })
|
|
152
|
+
await instance(request, response, next)
|
|
153
|
+
|
|
154
|
+
expect(FakeNavigation.getMenusFor).toHaveBeenCalledWith(dummyPath, 'uk')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('allows overriding of how to calculate current path logic', async () => {
|
|
158
|
+
request = httpMocks.createRequest({
|
|
159
|
+
path: '/path/to/page/123',
|
|
160
|
+
headers: {
|
|
161
|
+
'ft-blocked-url': '/ig-content-test'
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const instance = subject.init({ getCurrentPath: (request) => request.get('ft-blocked-url') })
|
|
166
|
+
await instance(request, response, next)
|
|
167
|
+
|
|
168
|
+
expect(FakeNavigation.getMenusFor).toHaveBeenCalledWith('/ig-content-test', 'uk')
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
describe('when something goes wrong', () => {
|
|
173
|
+
beforeEach(() => {
|
|
174
|
+
FakeNavigation.getMenusFor = jest.fn(() => {
|
|
175
|
+
throw Error('Whoops')
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('catches the error safely', async () => {
|
|
180
|
+
await instance(request, response, next)
|
|
181
|
+
expect(next).toHaveBeenCalledWith(expect.any(Error))
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { isEdition } from '@financial-times/dotcom-server-navigation'
|
|
2
|
+
import { Request, Response } from 'express'
|
|
3
|
+
|
|
4
|
+
const defaultEdition = 'uk'
|
|
5
|
+
|
|
6
|
+
export default (request: Request, response: Response): string => {
|
|
7
|
+
// NOTE: The FT-Edition header is set by the CDN and/or next-router...
|
|
8
|
+
// If an edition is selected with a cookie or query string it will be set to that.
|
|
9
|
+
// Otherwise the router will choose the best setting based on GeoIP.
|
|
10
|
+
// <https://github.com/Financial-Times/ft.com-cdn/blob/HEAD/src/vcl/next-editions.vcl>
|
|
11
|
+
// <https://github.com/Financial-Times/next-router/blob/HEAD/server/middleware/editions.js>
|
|
12
|
+
let currentEdition = request.get('FT-Edition') || defaultEdition
|
|
13
|
+
|
|
14
|
+
if (typeof request.query.edition === 'string' && isEdition(request.query.edition)) {
|
|
15
|
+
currentEdition = request.query.edition
|
|
16
|
+
|
|
17
|
+
response.cookie('next-edition', currentEdition, {
|
|
18
|
+
domain: 'ft.com',
|
|
19
|
+
maxAge: 1000 * 60 * 60 * 24 * 365 // 1 year
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// NOTE: n-express overrides res.set() and res.vary() in order to merge all vary headers together.
|
|
24
|
+
// <https://github.com/Financial-Times/n-express/blob/HEAD/src/middleware/vary.js>
|
|
25
|
+
response.vary('FT-Edition')
|
|
26
|
+
|
|
27
|
+
return currentEdition
|
|
28
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './navigation'
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from 'express'
|
|
2
|
+
import { TNavigationData } from '@financial-times/dotcom-types-navigation'
|
|
3
|
+
import { Navigation, TNavOptions } from '@financial-times/dotcom-server-navigation'
|
|
4
|
+
import handleEdition from './handleEdition'
|
|
5
|
+
import normalizePath from './normalizePath'
|
|
6
|
+
|
|
7
|
+
type MiddlewareOptions = TNavOptions & {
|
|
8
|
+
enableSubNavigation?: boolean
|
|
9
|
+
getCurrentPath?: Function
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const defaultOptions: MiddlewareOptions = {
|
|
13
|
+
enableSubNavigation: false,
|
|
14
|
+
getCurrentPath: (request) => normalizePath(request.get('ft-vanity-url') || request.path)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const init = (userOptions: MiddlewareOptions = {}) => {
|
|
18
|
+
const options = { ...defaultOptions, ...userOptions }
|
|
19
|
+
const navigation = new Navigation(options)
|
|
20
|
+
|
|
21
|
+
// Not all pages appear in the navigation so this request will fail often.
|
|
22
|
+
// Because it's not critical, ignore the error and move on.
|
|
23
|
+
const getSubNavigationFor = (currentPath) => navigation.getSubNavigationFor(currentPath).catch(() => {})
|
|
24
|
+
|
|
25
|
+
return async (request: Request, response: Response, next: NextFunction) => {
|
|
26
|
+
try {
|
|
27
|
+
// The vanity URL will usually be referenced in the navigation data
|
|
28
|
+
// rather than the underlying path, so prefer that when available.
|
|
29
|
+
// <https://github.com/Financial-Times/ft.com-cdn/blob/4841fbf100e1c561a2f6729b9921ec12bb6b837c/src/vcl/next-preflight.vcl#L213-L219>
|
|
30
|
+
// NOTE: Next router sets the vanity header inc. any query string so it must be normalized.
|
|
31
|
+
const currentPath = options.getCurrentPath(request)
|
|
32
|
+
const currentEdition = handleEdition(request, response)
|
|
33
|
+
|
|
34
|
+
const [menusData, subNavigationData] = await Promise.all([
|
|
35
|
+
navigation.getMenusFor(currentPath, currentEdition),
|
|
36
|
+
options.enableSubNavigation ? getSubNavigationFor(currentPath) : null
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
const editions = navigation.getEditionsFor(currentEdition)
|
|
40
|
+
const subscribeAction = navigation.getSubscribeAction()
|
|
41
|
+
|
|
42
|
+
const navigationData: TNavigationData = {
|
|
43
|
+
editions,
|
|
44
|
+
subscribeAction,
|
|
45
|
+
currentPath,
|
|
46
|
+
...menusData,
|
|
47
|
+
...subNavigationData
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
response.locals.navigation = navigationData
|
|
51
|
+
|
|
52
|
+
next()
|
|
53
|
+
} catch (error) {
|
|
54
|
+
next(error)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|