-
Notifications
You must be signed in to change notification settings - Fork 693
/
growPages.js
276 lines (238 loc) · 7.8 KB
/
growPages.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
/**
* Copyright 2019 The AMP HTML Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict';
const express = require('express');
const URL = require('url').URL;
const LRU = require('lru-cache');
const config = require('@lib/config');
const {Templates, createRequestContext} = require('@lib/templates/index.js');
const AmpOptimizer = require('@ampproject/toolbox-optimizer');
const CssTransformer = require('@lib/utils/cssTransformer');
const pageCache = require('@lib/utils/pageCache');
const imageOptimizer = require('@lib/utils/imageOptimizer');
const HeadDedupTransformer = require('@lib/utils/HeadDedupTransformer');
const signale = require('signale');
const {promisify} = require('util');
/* Potential path stubs that are used to find a matching file */
const AVAILABLE_STUBS = ['.html', '/index.html', '', '/'];
/* Matches all documentation routes */
const DOCUMENTATION_ROUTE_PATTERN = /\/documentation\/*/;
/* Matches all courses routes */
const COURSES_ROUTE_PATTERN = /\/courses\/*/;
/* Matches <a> tags with the href-attribute value as its first matching group */
const A_HREF_PATTERN = /<a\s+(?:[^>]*?\s+)?href=(["'])(.*?)\1/gm;
/**
* Transforms a request URL to match the defined scheme: has trailing slash,
* doesn't have a HTML file extension
* @param {String} originalUrl
* @return {URL} The eventually rewritten URL
*/
function ensureUrlScheme(originalUrl) {
const url = new URL(originalUrl, config.hosts.platform.base);
// Get rid of former .amp.html file extension for legacy support
if (url.pathname.endsWith('.amp.html')) {
url.pathname = url.pathname.slice(0, -9);
}
// Get rid of .html file extension
if (url.pathname.endsWith('.html')) {
url.pathname = url.pathname.slice(0, -5);
}
// Get rid of index in the URL
if (url.pathname.endsWith('index')) {
url.pathname = url.pathname.slice(0, -5);
}
// Ensure there is a trailing slash
if (!url.pathname.endsWith('/')) {
url.pathname = `${url.pathname}/`;
}
return url;
}
// Used to speed up resolving of path stubs to valid paths
const pathCache = new LRU({
max: 500,
});
/**
* Fetches a template matching the requested path
* @param {String} templatePath The path where the template can be found
* @return {nunjucks.Template|null}
*/
async function loadTemplate(templatePath) {
// The path has been ensured to always have a trailing slash which isn't
// needed to find a matching page file
templatePath = templatePath.slice(0, -1);
const resolvedPath = pathCache.get(templatePath);
if (resolvedPath === false) {
// If the path has already been tried to resolve but never found
// do not try to resolve it again
return null;
} else if (resolvedPath) {
// If the path has been resolved before get the template
return await Templates.get(resolvedPath);
} else {
// Otherwise search for the template
return await searchTemplate(templatePath);
}
}
/**
* Tries to complete a template path with one of AVAILABLE_STUBS to find
* an actual template
* @param {String} path
* @return {nunjucks.Template|null}
*/
async function searchTemplate(templatePath) {
// As the request path is not the actual path to the template it is somehow
// guessed by testing all of AVAILABLE_STUBS ...
let template = null;
for (const stub of AVAILABLE_STUBS) {
// Othwerwise try the first stub or the already resolved path if there is one
const searchPath = `${templatePath}${stub}`;
try {
template = await Templates.get(searchPath);
} catch (e) {
// Getting a template will throw an error if no template has been found
// which is fine as we're testing locations
continue;
}
if (template) {
// ... therefore a resolved path gets cached
pathCache.set(templatePath, searchPath);
break;
}
}
// If no template could be found, mark this as unresolvable
if (!template) {
pathCache.set(templatePath, false);
}
return template;
}
/**
* Takes the rendered template and rewrites all hrefs in anchor tags
* to have the currently selected format
* @param {String} html
* @return {String}
*/
function rewriteLinks(canonical, html, format, level) {
if (!DOCUMENTATION_ROUTE_PATTERN.test(canonical)) {
return html;
}
html = html.replace(A_HREF_PATTERN, (match, p1, p2) => {
if (!DOCUMENTATION_ROUTE_PATTERN.test(p2)) {
return match;
}
const url = new URL(p2, config.hosts.platform.base);
if (DOCUMENTATION_ROUTE_PATTERN.test(p2)) {
if (!url.searchParams.has('format')) {
url.searchParams.set('format', format);
}
}
if (COURSES_ROUTE_PATTERN.test(p2)) {
if (!url.searchParams.has('level')) {
url.searchParams.set('level', level);
}
}
return match.replace(p2, url.toString());
});
return html;
}
// eslint-disable-next-line new-cap
const growPages = express.Router();
const optimizer = AmpOptimizer.create({
experimentPreloadHeroImage: true,
preloadHeroImage: true,
imageOptimizer,
transformations: [
HeadDedupTransformer,
...AmpOptimizer.TRANSFORMATIONS_AMP_FIRST,
CssTransformer,
],
});
// Only match urls with slash at the end or html extension or no extension
growPages.get(/^(.*\/)?([^\/\.]+|.+\.html|.*\/|$)$/, async (req, res, next) => {
const url = ensureUrlScheme(req.originalUrl);
if (url.pathname !== req.path) {
res.redirect(301, url.toString());
return;
}
// Check if the page has been cached
const cachedPage = await pageCache.get(req.originalUrl);
if (cachedPage) {
res.send(cachedPage);
return;
}
const templateContext = createRequestContext(req);
const template = await loadTemplate(url.pathname);
if (!template) {
next();
return;
}
template.renderAsync = promisify(template.render);
let renderedTemplate = null;
try {
renderedTemplate = await template.renderAsync(templateContext);
} catch (e) {
// If there was a rendering error show the unrendered template with line
// count to the user to figure out what's wrong
if (config.isDevMode()) {
res.set('content-type', 'text/plain');
res.send(
`SSR error: ${e}\n\n` +
template.tmplStr
.split('\n')
.map((line, index) => `${index + 1} ${line}`)
.join('\n')
);
signale.error(e);
return;
}
next(e);
return;
}
// The documentation pages rely on passing along their currently
// selected format via GET paramters. The static URLs need to be rewritten
// for this use case
renderedTemplate = rewriteLinks(
url.pathname,
renderedTemplate,
templateContext.format,
templateContext.level
);
// Pipe the rendered template through the AMP optimizer
try {
const optimize = req.query.optimize !== 'false';
if (optimize) {
const experimentEsm = !!req.query.esm || false;
const preloadHeroImage = !!req.query.hero || false;
const params = {
experimentEsm,
preloadHeroImage,
};
renderedTemplate = await optimizer.transformHtml(
renderedTemplate,
params
);
}
} catch (e) {
signale.error('[OPTIMIZER]', e);
}
res.send(renderedTemplate);
// Cache the optimized and rendered page
pageCache.set(req.originalUrl, renderedTemplate);
});
module.exports = {
loadTemplate,
ensureUrlScheme,
growPages,
};