import { parse, HTMLElement } from 'node-html-parser' import { OPTIMIZED_FONT_PROVIDERS } from './constants' // const MIDDLEWARE_TIME_BUDGET = parseInt(process.env.__POST_PROCESS_MIDDLEWARE_TIME_BUDGET || '', 10) || 10 const MAXIMUM_IMAGE_PRELOADS = 2 const IMAGE_PRELOAD_SIZE_THRESHOLD = 2500 type postProcessOptions = { optimizeFonts: boolean optimizeImages: boolean } type renderOptions = { getFontDefinition?: (url: string) => string } type PostProcessData = { preloads: { images: Array } } interface PostProcessMiddleware { inspect: ( originalDom: HTMLElement, data: PostProcessData, options: renderOptions ) => void mutate: ( markup: string, data: PostProcessData, options: renderOptions ) => Promise } type middlewareSignature = { name: string middleware: PostProcessMiddleware condition: ((options: postProcessOptions) => boolean) | null } const middlewareRegistry: Array = [] 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 { // Don't parse unless there's at least one processor middleware if (!middlewareRegistry[0]) { return html } const postProcessData: PostProcessData = { preloads: { images: [], }, } const root: HTMLElement = parse(html) let document = html // Calls the middleware, with some instrumentation and logging async function callMiddleWare(middleware: PostProcessMiddleware) { // let timer = Date.now() middleware.inspect(root, postProcessData, data) document = await middleware.mutate(document, postProcessData, data) // 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 // } return } for (let i = 0; i < middlewareRegistry.length; i++) { let middleware = middlewareRegistry[i] if (!middleware.condition || middleware.condition(options)) { await callMiddleWare(middlewareRegistry[i].middleware) } } return document } class FontOptimizerMiddleware implements PostProcessMiddleware { fontDefinitions: (string | undefined)[][] = [] inspect( originalDom: HTMLElement, _data: PostProcessData, options: renderOptions ) { if (!options.getFontDefinition) { return } // collecting all the requested font definitions originalDom .querySelectorAll('link') .filter( (tag: HTMLElement) => tag.getAttribute('rel') === 'stylesheet' && tag.hasAttribute('data-href') && OPTIMIZED_FONT_PROVIDERS.some((url) => { const dataHref = tag.getAttribute('data-href') return dataHref ? dataHref.startsWith(url) : false }) ) .forEach((element: HTMLElement) => { const url = element.getAttribute('data-href') const nonce = element.getAttribute('nonce') if (url) { this.fontDefinitions.push([url, nonce]) } }) } mutate = async ( markup: string, _data: PostProcessData, options: renderOptions ) => { let result = markup if (!options.getFontDefinition) { return markup } for (const key in this.fontDefinitions) { const [url, nonce] = this.fontDefinitions[key] const fallBackLinkTag = `` if ( result.indexOf(`` ) } } return result } } class ImageOptimizerMiddleware implements PostProcessMiddleware { inspect(originalDom: HTMLElement, _data: PostProcessData) { const imgElements = originalDom.querySelectorAll('img') let eligibleImages: Array = [] for (let i = 0; i < imgElements.length; i++) { if (isImgEligible(imgElements[i])) { eligibleImages.push(imgElements[i]) } if (eligibleImages.length >= MAXIMUM_IMAGE_PRELOADS) { break } } _data.preloads.images = [] for (const imgEl of eligibleImages) { const src = imgEl.getAttribute('src') if (src) { _data.preloads.images.push(src) } } } mutate = async (markup: string, _data: PostProcessData) => { let result = markup let imagePreloadTags = _data.preloads.images .filter((imgHref) => !preloadTagAlreadyExists(markup, imgHref)) .reduce( (acc, imgHref) => acc + ``, '' ) return result.replace( /]*href[^>]*${href}`) 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 { const heightAttr = imgElement.getAttribute('height') const widthAttr = imgElement.getAttribute('width') if (!heightAttr || !widthAttr) { return true } if ( parseInt(heightAttr) * parseInt(widthAttr) <= 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 } // Currently only filters out svg images--could be made more specific in the future. function sourceIsSupportedType(imgSrc: string): boolean { return !imgSrc.includes('.svg') } // Initialization 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 ) registerPostProcessor( 'Preload Images', new ImageOptimizerMiddleware(), // @ts-ignore (options) => options.optimizeImages || process.env.__NEXT_OPTIMIZE_IMAGES ) export default processHTML