2021-05-14 04:40:20 +02:00
|
|
|
import escapeRegexp from 'next/dist/compiled/escape-string-regexp'
|
2020-07-28 12:19:28 +02:00
|
|
|
import { parse, HTMLElement } from 'node-html-parser'
|
|
|
|
import { OPTIMIZED_FONT_PROVIDERS } from './constants'
|
|
|
|
|
2021-01-13 16:54:31 +01:00
|
|
|
// const MIDDLEWARE_TIME_BUDGET = parseInt(process.env.__POST_PROCESS_MIDDLEWARE_TIME_BUDGET || '', 10) || 10
|
2020-08-05 19:49:44 +02:00
|
|
|
const MAXIMUM_IMAGE_PRELOADS = 2
|
|
|
|
const IMAGE_PRELOAD_SIZE_THRESHOLD = 2500
|
2020-07-28 12:19:28 +02:00
|
|
|
|
|
|
|
type postProcessOptions = {
|
2020-12-21 20:26:00 +01:00
|
|
|
optimizeFonts: boolean
|
2020-08-05 19:49:44 +02:00
|
|
|
optimizeImages: boolean
|
2020-07-28 12:19:28 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
type renderOptions = {
|
|
|
|
getFontDefinition?: (url: string) => string
|
|
|
|
}
|
|
|
|
interface PostProcessMiddleware {
|
2021-04-26 20:30:21 +02:00
|
|
|
inspect: (originalDom: HTMLElement, options: renderOptions) => any
|
|
|
|
mutate: (markup: string, data: any, options: renderOptions) => Promise<string>
|
2020-07-28 12:19:28 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
type middlewareSignature = {
|
|
|
|
name: string
|
|
|
|
middleware: PostProcessMiddleware
|
|
|
|
condition: ((options: postProcessOptions) => boolean) | null
|
|
|
|
}
|
|
|
|
|
|
|
|
const middlewareRegistry: Array<middlewareSignature> = []
|
|
|
|
|
|
|
|
function registerPostProcessor(
|
|
|
|
name: string,
|
|
|
|
middleware: PostProcessMiddleware,
|
|
|
|
condition?: (options: postProcessOptions) => boolean
|
|
|
|
) {
|
|
|
|
middlewareRegistry.push({ name, middleware, condition: condition || null })
|
|
|
|
}
|
|
|
|
|
|
|
|
async function processHTML(
|
|
|
|
html: string,
|
|
|
|
data: renderOptions,
|
|
|
|
options: postProcessOptions
|
|
|
|
): Promise<string> {
|
|
|
|
// Don't parse unless there's at least one processor middleware
|
|
|
|
if (!middlewareRegistry[0]) {
|
|
|
|
return html
|
|
|
|
}
|
|
|
|
const root: HTMLElement = parse(html)
|
|
|
|
let document = html
|
|
|
|
// Calls the middleware, with some instrumentation and logging
|
2021-01-13 16:54:31 +01:00
|
|
|
async function callMiddleWare(middleware: PostProcessMiddleware) {
|
|
|
|
// let timer = Date.now()
|
2021-04-26 20:30:21 +02:00
|
|
|
const inspectData = middleware.inspect(root, data)
|
|
|
|
document = await middleware.mutate(document, inspectData, data)
|
2021-01-13 16:54:31 +01:00
|
|
|
// timer = Date.now() - timer
|
|
|
|
// if (timer > MIDDLEWARE_TIME_BUDGET) {
|
|
|
|
// TODO: Identify a correct upper limit for the postprocess step
|
|
|
|
// and add a warning to disable the optimization
|
|
|
|
// }
|
2020-07-28 12:19:28 +02:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
for (let i = 0; i < middlewareRegistry.length; i++) {
|
|
|
|
let middleware = middlewareRegistry[i]
|
|
|
|
if (!middleware.condition || middleware.condition(options)) {
|
2021-01-13 16:54:31 +01:00
|
|
|
await callMiddleWare(middlewareRegistry[i].middleware)
|
2020-07-28 12:19:28 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return document
|
|
|
|
}
|
|
|
|
|
|
|
|
class FontOptimizerMiddleware implements PostProcessMiddleware {
|
2021-04-26 20:30:21 +02:00
|
|
|
inspect(originalDom: HTMLElement, options: renderOptions) {
|
2020-07-28 12:19:28 +02:00
|
|
|
if (!options.getFontDefinition) {
|
|
|
|
return
|
|
|
|
}
|
2021-04-26 20:30:21 +02:00
|
|
|
const fontDefinitions: (string | undefined)[][] = []
|
2020-07-28 12:19:28 +02:00
|
|
|
// collecting all the requested font definitions
|
|
|
|
originalDom
|
|
|
|
.querySelectorAll('link')
|
|
|
|
.filter(
|
|
|
|
(tag: HTMLElement) =>
|
|
|
|
tag.getAttribute('rel') === 'stylesheet' &&
|
|
|
|
tag.hasAttribute('data-href') &&
|
2020-08-10 11:14:53 +02:00
|
|
|
OPTIMIZED_FONT_PROVIDERS.some((url) => {
|
|
|
|
const dataHref = tag.getAttribute('data-href')
|
|
|
|
return dataHref ? dataHref.startsWith(url) : false
|
|
|
|
})
|
2020-07-28 12:19:28 +02:00
|
|
|
)
|
|
|
|
.forEach((element: HTMLElement) => {
|
|
|
|
const url = element.getAttribute('data-href')
|
2021-01-26 19:32:39 +01:00
|
|
|
const nonce = element.getAttribute('nonce')
|
|
|
|
|
2020-08-10 11:14:53 +02:00
|
|
|
if (url) {
|
2021-04-26 20:30:21 +02:00
|
|
|
fontDefinitions.push([url, nonce])
|
2020-08-10 11:14:53 +02:00
|
|
|
}
|
2020-07-28 12:19:28 +02:00
|
|
|
})
|
2021-04-26 20:30:21 +02:00
|
|
|
|
|
|
|
return fontDefinitions
|
2020-07-28 12:19:28 +02:00
|
|
|
}
|
|
|
|
mutate = async (
|
|
|
|
markup: string,
|
2021-04-26 20:30:21 +02:00
|
|
|
fontDefinitions: string[][],
|
2020-07-28 12:19:28 +02:00
|
|
|
options: renderOptions
|
|
|
|
) => {
|
|
|
|
let result = markup
|
|
|
|
if (!options.getFontDefinition) {
|
|
|
|
return markup
|
|
|
|
}
|
2021-04-26 20:30:21 +02:00
|
|
|
|
|
|
|
fontDefinitions.forEach((fontDef) => {
|
|
|
|
const [url, nonce] = fontDef
|
2020-12-31 00:41:33 +01:00
|
|
|
const fallBackLinkTag = `<link rel="stylesheet" href="${url}"/>`
|
|
|
|
if (
|
|
|
|
result.indexOf(`<style data-href="${url}">`) > -1 ||
|
|
|
|
result.indexOf(fallBackLinkTag) > -1
|
|
|
|
) {
|
2020-07-28 12:19:28 +02:00
|
|
|
// The font is already optimized and probably the response is cached
|
2021-04-26 20:30:21 +02:00
|
|
|
return
|
2020-07-28 12:19:28 +02:00
|
|
|
}
|
2021-04-26 20:30:21 +02:00
|
|
|
const fontContent = options.getFontDefinition
|
|
|
|
? options.getFontDefinition(url as string)
|
|
|
|
: null
|
2020-12-04 10:52:54 +01:00
|
|
|
if (!fontContent) {
|
|
|
|
/**
|
|
|
|
* In case of unreachable font definitions, fallback to default link tag.
|
|
|
|
*/
|
2020-12-31 00:41:33 +01:00
|
|
|
result = result.replace('</head>', `${fallBackLinkTag}</head>`)
|
2020-12-04 10:52:54 +01:00
|
|
|
} else {
|
2021-01-26 19:32:39 +01:00
|
|
|
const nonceStr = nonce ? ` nonce="${nonce}"` : ''
|
2020-12-04 10:52:54 +01:00
|
|
|
result = result.replace(
|
|
|
|
'</head>',
|
2021-01-26 19:32:39 +01:00
|
|
|
`<style data-href="${url}"${nonceStr}>${fontContent}</style></head>`
|
2020-12-04 10:52:54 +01:00
|
|
|
)
|
|
|
|
}
|
2021-04-26 20:30:21 +02:00
|
|
|
})
|
|
|
|
|
2020-07-28 12:19:28 +02:00
|
|
|
return result
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-08-05 19:49:44 +02:00
|
|
|
class ImageOptimizerMiddleware implements PostProcessMiddleware {
|
2021-04-26 20:30:21 +02:00
|
|
|
inspect(originalDom: HTMLElement) {
|
|
|
|
const imgPreloads = []
|
2020-08-05 19:49:44 +02:00
|
|
|
const imgElements = originalDom.querySelectorAll('img')
|
|
|
|
let eligibleImages: Array<HTMLElement> = []
|
|
|
|
for (let i = 0; i < imgElements.length; i++) {
|
|
|
|
if (isImgEligible(imgElements[i])) {
|
|
|
|
eligibleImages.push(imgElements[i])
|
|
|
|
}
|
|
|
|
if (eligibleImages.length >= MAXIMUM_IMAGE_PRELOADS) {
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
2020-08-10 11:14:53 +02:00
|
|
|
|
|
|
|
for (const imgEl of eligibleImages) {
|
|
|
|
const src = imgEl.getAttribute('src')
|
|
|
|
if (src) {
|
2021-04-26 20:30:21 +02:00
|
|
|
imgPreloads.push(src)
|
2020-08-10 11:14:53 +02:00
|
|
|
}
|
|
|
|
}
|
2021-04-26 20:30:21 +02:00
|
|
|
|
|
|
|
return imgPreloads
|
2020-08-05 19:49:44 +02:00
|
|
|
}
|
2021-04-26 20:30:21 +02:00
|
|
|
mutate = async (markup: string, imgPreloads: string[]) => {
|
2020-08-05 19:49:44 +02:00
|
|
|
let result = markup
|
2021-04-26 20:30:21 +02:00
|
|
|
let imagePreloadTags = imgPreloads
|
2020-08-05 19:49:44 +02:00
|
|
|
.filter((imgHref) => !preloadTagAlreadyExists(markup, imgHref))
|
|
|
|
.reduce(
|
2020-08-18 23:14:42 +02:00
|
|
|
(acc, imgHref) =>
|
|
|
|
acc + `<link rel="preload" href="${imgHref}" as="image"/>`,
|
2020-08-05 19:49:44 +02:00
|
|
|
''
|
|
|
|
)
|
|
|
|
return result.replace(
|
|
|
|
/<link rel="preload"/,
|
|
|
|
`${imagePreloadTags}<link rel="preload"`
|
|
|
|
)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function isImgEligible(imgElement: HTMLElement): boolean {
|
2020-09-02 04:42:20 +02:00
|
|
|
let imgSrc = imgElement.getAttribute('src')
|
2020-08-05 19:49:44 +02:00
|
|
|
return (
|
2020-09-02 04:42:20 +02:00
|
|
|
!!imgSrc &&
|
|
|
|
sourceIsSupportedType(imgSrc) &&
|
2020-08-05 19:49:44 +02:00
|
|
|
imageIsNotTooSmall(imgElement) &&
|
|
|
|
imageIsNotHidden(imgElement)
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
function preloadTagAlreadyExists(html: string, href: string) {
|
2021-05-14 04:40:20 +02:00
|
|
|
const escapedHref = escapeRegexp(href)
|
|
|
|
const regex = new RegExp(`<link[^>]*href[^>]*${escapedHref}`)
|
2020-08-05 19:49:44 +02:00
|
|
|
return html.match(regex)
|
|
|
|
}
|
|
|
|
|
|
|
|
function imageIsNotTooSmall(imgElement: HTMLElement): boolean {
|
|
|
|
// Skip images without both height and width--we don't know enough to say if
|
|
|
|
// they are too small
|
|
|
|
if (
|
|
|
|
!(imgElement.hasAttribute('height') && imgElement.hasAttribute('width'))
|
|
|
|
) {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
try {
|
2020-08-10 11:14:53 +02:00
|
|
|
const heightAttr = imgElement.getAttribute('height')
|
|
|
|
const widthAttr = imgElement.getAttribute('width')
|
|
|
|
if (!heightAttr || !widthAttr) {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
2020-08-05 19:49:44 +02:00
|
|
|
if (
|
2020-08-10 11:14:53 +02:00
|
|
|
parseInt(heightAttr) * parseInt(widthAttr) <=
|
2020-08-05 19:49:44 +02:00
|
|
|
IMAGE_PRELOAD_SIZE_THRESHOLD
|
|
|
|
) {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
} catch (err) {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
// Traverse up the dom from each image to see if it or any of it's
|
|
|
|
// ancestors have the hidden attribute.
|
|
|
|
function imageIsNotHidden(imgElement: HTMLElement): boolean {
|
|
|
|
let activeElement = imgElement
|
|
|
|
while (activeElement.parentNode) {
|
|
|
|
if (activeElement.hasAttribute('hidden')) {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
activeElement = activeElement.parentNode as HTMLElement
|
|
|
|
}
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
2020-09-02 04:42:20 +02:00
|
|
|
// Currently only filters out svg images--could be made more specific in the future.
|
|
|
|
function sourceIsSupportedType(imgSrc: string): boolean {
|
|
|
|
return !imgSrc.includes('.svg')
|
|
|
|
}
|
|
|
|
|
2020-07-28 12:19:28 +02:00
|
|
|
// Initialization
|
2020-12-21 20:26:00 +01:00
|
|
|
registerPostProcessor(
|
|
|
|
'Inline-Fonts',
|
|
|
|
new FontOptimizerMiddleware(),
|
|
|
|
// Using process.env because passing Experimental flag through loader is not possible.
|
|
|
|
// @ts-ignore
|
|
|
|
(options) => options.optimizeFonts || process.env.__NEXT_OPTIMIZE_FONTS
|
|
|
|
)
|
2020-07-28 12:19:28 +02:00
|
|
|
|
2020-08-05 19:49:44 +02:00
|
|
|
registerPostProcessor(
|
|
|
|
'Preload Images',
|
|
|
|
new ImageOptimizerMiddleware(),
|
|
|
|
// @ts-ignore
|
|
|
|
(options) => options.optimizeImages || process.env.__NEXT_OPTIMIZE_IMAGES
|
|
|
|
)
|
|
|
|
|
2020-07-28 12:19:28 +02:00
|
|
|
export default processHTML
|