Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Autosort <head> #36

Open
wokalek opened this issue Jul 3, 2023 · 8 comments
Open

Autosort <head> #36

wokalek opened this issue Jul 3, 2023 · 8 comments
Labels
enhancement New feature or request

Comments

@wokalek
Copy link

wokalek commented Jul 3, 2023

馃啋 Your use case

The whole idea of using Capo is to cleverly sort the elements of the <head> tag.

I'm wondering why this hasn't been done before?

I wrote a plugin for the Nitro server for myself, you can see below. Mostly consists of copy-paste, because types in logger.mjs are not exported.

The plugin is executed on every hit, and this is actually not correct, as I understand it. Therefore, I want to hear what are the possibilities for optimization and other things? Then I can make a PR request if you're interested.

馃啎 The solution you'd like

nuxt/src/server/plugins/capo.ts
import type { NitroApp } from 'nitropack'
import { JSDOM } from 'jsdom'

const ElementWeights: { [key: string]: any } = {
  META: 10,
  TITLE: 9,
  PRECONNECT: 8,
  ASYNC_SCRIPT: 7,
  IMPORT_STYLES: 6,
  SYNC_SCRIPT: 5,
  SYNC_STYLES: 4,
  PRELOAD: 3,
  DEFER_SCRIPT: 2,
  PREFETCH_PRERENDER: 1,
  OTHER: 0,
}

const ElementDetectors = {
  META: isMeta,
  TITLE: isTitle,
  PRECONNECT: isPreconnect,
  ASYNC_SCRIPT: isAsyncScript,
  IMPORT_STYLES: isImportStyles,
  SYNC_SCRIPT: isSyncScript,
  SYNC_STYLES: isSyncStyles,
  PRELOAD: isPreload,
  DEFER_SCRIPT: isDeferScript,
  PREFETCH_PRERENDER: isPrefetchPrerender,
}

function isMeta (element: Element) {
  return element.matches('meta:is([charset], [http-equiv], [name=viewport])')
}

function isTitle (element: Element) {
  return element.matches('title')
}

function isPreconnect (element: Element) {
  return element.matches('link[rel=preconnect]')
}

function isAsyncScript (element: Element) {
  return element.matches('script[async]')
}

function isImportStyles (element: Element) {
  const importRe = /@import/
  if (element.matches('style')) {
    return importRe.test(element.textContent || '')
  }
  return false
}

function isSyncScript (element: Element) {
  return element.matches('script:not([defer],[async],[type*=json])')
}

function isSyncStyles (element: Element) {
  return element.matches('link[rel=stylesheet],style')
}

function isPreload (element: Element) {
  return element.matches('link[rel=preload]')
}

function isDeferScript (element: Element) {
  return element.matches('script[defer]')
}

function isPrefetchPrerender (element: Element) {
  return element.matches('link:is([rel=prefetch], [rel=dns-prefetch], [rel=prerender])')
}

function getWeight (element: Element) {
  for (const [id, detector] of Object.entries(ElementDetectors)) {
    if (detector(element)) {
      return ElementWeights[id]
    }
  }
  return ElementWeights.OTHER
}

function getHeadWeights (document: Document) {
  const headChildren = Array.from(document.head.children)
  return headChildren.map((element) => {
    return [element, getWeight(element)]
  })
}

function getSortedHead (document: Document) {
  const headWeights = getHeadWeights(document)
  const sortedHead = document.createElement('head')
  const sortedWeights = [...headWeights].sort((a, b) => {
    return b[1] - a[1]
  })

  sortedWeights.forEach(([element]) => {
    sortedHead.appendChild(element.cloneNode(true))
  })

  return sortedHead
}

export default defineNitroPlugin((nitroApp: NitroApp) => {
  nitroApp.hooks.hook('render:response', (response) => {
    if (response.headers && !response.headers['content-type']?.startsWith('text/html')) {
      return
    }

    const dom = new JSDOM(response.body)
    const sortedHead = getSortedHead(dom.window.document)

    response.body = response.body?.replace(/<head>(?:.|\n)*<\/head>/, sortedHead.outerHTML)
  })
})

馃攳 Alternatives you've considered

No response

鈩癸笍 Additional info

After sorting view in Capo plugin for chrome:

real production site without autosort

image

real production site with autosort

image

I think need to update capo functions to match plugin result.

@wokalek wokalek added the enhancement New feature or request label Jul 3, 2023
@wokalek
Copy link
Author

wokalek commented Jul 4, 2023

Syncing code with actual Capo.js repo, now get full match on production site

image

nuxt/src/server/plugins/capo.ts
import type { NitroApp } from 'nitropack'
import { JSDOM } from 'jsdom'
import { getSortedHead } from '@/utils/capo'

export default defineNitroPlugin((nitroApp: NitroApp) => {
  nitroApp.hooks.hook('render:response', (response) => {
    if (response.headers && !response.headers['content-type']?.startsWith('text/html')) {
      return
    }

    const dom = new JSDOM(response.body)
    const sortedHead = getSortedHead(dom.window.document)

    response.body = response.body?.replace(/<head>(?:.|\n)*<\/head>/, sortedHead.outerHTML.trim())
  })
})
nuxt/src/utils/capo.ts
const ElementWeights: { [key: string]: number } = {
  META: 10,
  TITLE: 9,
  PRECONNECT: 8,
  ASYNC_SCRIPT: 7,
  IMPORT_STYLES: 6,
  SYNC_SCRIPT: 5,
  SYNC_STYLES: 4,
  PRELOAD: 3,
  DEFER_SCRIPT: 2,
  PREFETCH_PRERENDER: 1,
  OTHER: 0,
}

const ElementDetectors: { [key: string]: (element: Element) => boolean } = {
  META: isMeta,
  TITLE: isTitle,
  PRECONNECT: isPreconnect,
  ASYNC_SCRIPT: isAsyncScript,
  IMPORT_STYLES: isImportStyles,
  SYNC_SCRIPT: isSyncScript,
  SYNC_STYLES: isSyncStyles,
  PRELOAD: isPreload,
  DEFER_SCRIPT: isDeferScript,
  PREFETCH_PRERENDER: isPrefetchPrerender,
}

function isMeta (element: Element) {
  return element.matches('meta:is([charset], [http-equiv], [name=viewport]), base')
}

function isTitle (element: Element) {
  return element.matches('title')
}

function isPreconnect (element: Element) {
  return element.matches('link[rel=preconnect]')
}

function isAsyncScript (element: Element) {
  return element.matches('script[src][async]')
}

function isImportStyles (element: Element) {
  const importRe = /@import/

  if (element.matches('style')) {
    return importRe.test(element.textContent || '')
  }

  return false
}

function isSyncScript (element: Element) {
  return element.matches('script:not([src][defer],[src][type=module],[src][async],[type*=json])')
}

function isSyncStyles (element: Element) {
  return element.matches('link[rel=stylesheet],style')
}

function isPreload (element: Element) {
  return element.matches('link:is([rel=preload], [rel=modulepreload])')
}

function isDeferScript (element: Element) {
  return element.matches('script[src][defer], script:not([src][async])[src][type=module]')
}

function isPrefetchPrerender (element: Element) {
  return element.matches('link:is([rel=prefetch], [rel=dns-prefetch], [rel=prerender])')
}

function getWeight (element: Element) {
  for (const [id, detector] of Object.entries(ElementDetectors)) {
    if (detector(element)) {
      return ElementWeights[id]
    }
  }

  return ElementWeights.OTHER
}

function getHeadWeights (document: Document) {
  const headChildren = Array.from(document.head.children)
  return headChildren.map((element): [Element, number] => {
    return [element, getWeight(element)]
  })
}

function getSortedHead (document: Document) {
  const headWeights = getHeadWeights(document)

  headWeights.sort((a, b) => {
    return b[1] - a[1]
  })

  const sortedHead = document.createElement('head')
  const sortedWeights = [...headWeights].sort((a, b) => b[1] - a[1])
  sortedWeights.forEach(([element]) => sortedHead.appendChild(element.cloneNode(true)))

  return sortedHead
}

export { getSortedHead }

@danielroe
Copy link
Owner

This would be very interesting, and this is exactly the kind of discussion I was hoping for. I don't know if it's possible to do without deeper integration with unhead though (cc: @harlan-zw).

@wokalek
Copy link
Author

wokalek commented Jul 10, 2023

This would be very interesting, and this is exactly the kind of discussion I was hoping for. I don't know if it's possible to do without deeper integration with unhead though (cc: @harlan-zw).

I wrote this module for my production site, but after a careful analysis of the output of Capo.js and how Nuxt forms the head, I came to the conclusion that Nuxt forms the head quite well on its own.

The difference lies only in the location of the meta tags (opengraph and others), which are actually recommended to be placed at the beginning, but Capo puts them at the end.
This is confirmed in the presentation by Harry Roberts, who inspired the developer Capo.js to write the plugin.

Harry Roberts
https://speakerdeck.com/csswizardry/get-your-head-straight?slide=88

image

@harlan-zw
Copy link

harlan-zw commented Jul 10, 2023

So briefly, Unhead will sort tags that are critical (for functionality) to be in the right position. The ordering is as follows:

-2 - <meta charset ...>
-1 - <base>
0 - <meta http-equiv="ontent-security-policy" ... >
1 - <title>

Where the default order is 10.

The capo sorting improvements seem good, but Is there any details on how stable Capo.js is? I know it performs better theoretically but these hard and fast rules seem brittle. (like the above).

Anyway, you could use the entries:resolve hook to add the below weights tagPriority for the relevant elements.

const ElementWeights = {
  META: 10,
  TITLE: 9,
  PRECONNECT: 8,
  ASYNC_SCRIPT: 7,
  IMPORT_STYLES: 6,
  SYNC_SCRIPT: 5,
  SYNC_STYLES: 4,
  PRELOAD: 3,
  DEFER_SCRIPT: 2,
  PREFETCH_PRERENDER: 1,
  OTHER: 0
};

I'll create a demo when I have a chance

@danielroe
Copy link
Owner

One of the challenges is that Nuxt bundle renderer also renders resource hints outside unhead. Maybe there is a way we can integrate the two so sorting can be consistent?

@harlan-zw
Copy link

I think it would make sense that any head tags Nuxt wants to render SSR goes through Unhead, then the user can make use of Unhead plugins to customize the output however they like without relying on HTML regex. I think this requires coupling the renderer directly with the Unhead though, currently, there's a renderMeta abstraction in the way.

Stuck at the airport so I gave the capo.js demo a go: https://stackblitz.com/edit/nuxt-starter-ybmzxf?file=plugins%2Fcapo.ts

@wokalek
Copy link
Author

wokalek commented Jul 10, 2023

This can be just wrapped in a plugin for this nuxt-capo module!

@danielroe
Copy link
Owner

With Nuxt v3.7 it will now be possible to implement this 馃憤

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

3 participants