Skip to content

refactor: uses unhead/server to optimize head#464

Open
Barbapapazes wants to merge 1 commit into
antfu-collective:mainfrom
Barbapapazes:refactor/unhead-ssr
Open

refactor: uses unhead/server to optimize head#464
Barbapapazes wants to merge 1 commit into
antfu-collective:mainfrom
Barbapapazes:refactor/unhead-ssr

Conversation

@Barbapapazes
Copy link
Copy Markdown

@Barbapapazes Barbapapazes commented Oct 30, 2025

Hello 👋,

Currently, Vite SSG generates the head using a client-side hook from Unhead (renderDOMHead).

This means the head isn’t fully optimized for performance when the browser loads the page.

I refactored the generation process to use transformHtmlTemplate, which uses capo.js under the hood to optimize the page (see capo.js rules).

For example, the output of examples/multiple-pages/dist/index.html goes from:

    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link
      rel="stylesheet"
      href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/4.0.0/github-markdown.min.css"
      integrity="sha512-Oy18vBnbSJkXTndr2n6lDMO5NN31UljR8e/ICzVPrGpSud4Gkckb8yUpqhKuUNoE+o9gAb4O/rAxxw1ojyUVzg=="
      crossorigin="anonymous"
    />
    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/gh/LukeAskew/prism-github@master/prism-github.css"
    />
    <title>Index Page</title>
    <meta charset="utf-8" />
    <script
      type="module"
      async
      crossorigin
      src="/ssg-base/assets/app-85d9JDTF.js"
    ></script>
    <link
      rel="stylesheet"
      crossorigin
      href="/ssg-base/assets/app-DX0qcnGS.css"
    />
    <meta property="og:title" content="Index Page" />
    <meta name="twitter:title" content="Index Page" />

to

    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Index Page</title>
    <script
      type="module"
      async
      crossorigin
      src="/ssg-base/assets/app-85d9JDTF.js"
    ></script>
    <link
      rel="stylesheet"
      href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/4.0.0/github-markdown.min.css"
      integrity="sha512-Oy18vBnbSJkXTndr2n6lDMO5NN31UljR8e/ICzVPrGpSud4Gkckb8yUpqhKuUNoE+o9gAb4O/rAxxw1ojyUVzg=="
      crossorigin="anonymous"
    />
    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/gh/LukeAskew/prism-github@master/prism-github.css"
    />
    <link
      rel="stylesheet"
      crossorigin
      href="/ssg-base/assets/app-DX0qcnGS.css"
    />
    <meta property="og:title" content="Index Page" />
    <meta name="twitter:title" content="Index Page" />
Patch

diff --git a/dist/shared/vite-ssg.0i5mAeat.mjs b/dist/shared/vite-ssg.0i5mAeat.mjs
index 6c74327c9cf19cdce335627d2f5021f89198b74a..0827a546143d9d401cd9334278fbd3e51511c3d2 100644
--- a/dist/shared/vite-ssg.0i5mAeat.mjs
+++ b/dist/shared/vite-ssg.0i5mAeat.mjs
@@ -3,7 +3,7 @@ import fs from 'node:fs/promises';
 import { resolve, isAbsolute, parse, join, dirname } from 'node:path';
 import process from 'node:process';
 import { pathToFileURL } from 'node:url';
-import { renderDOMHead } from '@unhead/dom';
+import { transformHtmlTemplate  } from '@unhead/vue/server';
 import { gray, yellow, blue, dim, cyan, red, green } from 'ansis';
 import { JSDOM } from 'jsdom';
 import { resolveConfig, build as build$1, mergeConfig } from 'vite';
@@ -1337,13 +1337,14 @@ async function build(ssgOptions = {}, viteConfig = {}) {
         });
         const jsdom = new JSDOM(renderedHTML);
         renderPreloadLinks(jsdom.window.document, ctx.modules || /* @__PURE__ */ new Set(), ssrManifest);
-        if (head)
-          await renderDOMHead(head, { document: jsdom.window.document });
         const html = jsdom.serialize();
         let transformed = await onPageRendered?.(route, html, appCtx) || html;
         if (beasties)
           transformed = await beasties.process(transformed);
-        const formatted = await formatHtml(transformed, formatting);
+        let optimized = html
+        if (head)
+          optimized = await transformHtmlTemplate(head, transformed)
+        const formatted = await formatHtml(optimized, formatting);
         const relativeRouteFile = `${(route.endsWith("/") ? `${route}index` : route).replace(/^\//g, "")}.html`;
         const filename = await prepareHtmlFileName(
           dirStyle === "nested" ? join(route.replace(/^\//g, ""), "index.html") : relativeRouteFile,

@Barbapapazes Barbapapazes marked this pull request as ready for review October 30, 2025 14:13
@Barbapapazes Barbapapazes marked this pull request as draft October 30, 2025 14:22
@userquin
Copy link
Copy Markdown
Member

can you check adding @unhead/addons/vite plugin?

@Barbapapazes Barbapapazes marked this pull request as ready for review October 30, 2025 14:42
@Barbapapazes
Copy link
Copy Markdown
Author

can you check adding @unhead/addons/vite plugin?

in addition to the current change?

@userquin
Copy link
Copy Markdown
Member

I think we don't need to switch to unhead server, @unhead/addons/vite plugin will treeshake the server stuff from the client build, I'm not sure about the app module order in the output.

@Barbapapazes

This comment was marked as off-topic.

@Barbapapazes
Copy link
Copy Markdown
Author

Barbapapazes commented Oct 30, 2025

The usage of @unhead/server is only when building and generating the application so it does not leak in the client. I can add the @unhead/addons but it's unrelated to this PR as it only optimize the bundle, not the html head.

@Barbapapazes
Copy link
Copy Markdown
Author

Barbapapazes commented Oct 31, 2025

Side note to remove JSDOM

diff --git a/src/node/build.ts b/src/node/build.ts
index dd05ce6..4c994ab 100644
--- a/src/node/build.ts
+++ b/src/node/build.ts
@@ -10,7 +10,7 @@ import fs from 'node:fs/promises'
 import { dirname, isAbsolute, join, parse, resolve } from 'node:path'
 import process from 'node:process'
 import { pathToFileURL } from 'node:url'
-import { transformHtmlTemplate } from '@unhead/vue/server'
+import { createHead, transformHtmlTemplate, VueHeadClient } from '@unhead/vue/server'
 import { blue, cyan, dim, gray, green, red } from 'ansis'
 import { JSDOM } from 'jsdom'
 import PQueue from 'p-queue'
@@ -177,26 +177,20 @@ export async function build(ssgOptions: Partial<ViteSSGOptions> = {}, viteConfig
           initialState: transformState(initialState),
         })
 
-        // create jsdom from renderedHTML
-        const jsdom = new JSDOM(renderedHTML)
-
         // render current page's preloadLinks
-        renderPreloadLinks(jsdom.window.document, ctx.modules || new Set<string>(), ssrManifest)
+        const links = renderPreloadLinks(ctx.modules || new Set<string>(), ssrManifest)
+        const vueHead = head || createHead() as VueHeadClient
+        vueHead.push({
+          link: links,
+        })
+        const html = await transformHtmlTemplate(vueHead, renderedHTML)
 
-        const html = jsdom.serialize()
         let transformed = (await onPageRendered?.(route, html, appCtx)) || html
         if (beasties)
           transformed = await beasties.process(transformed)
 
-        let optimized = html
-        // Under the hood, Unhead uses capo.js to optimize the head tags
-        // @see https://unhead.unjs.io/docs/typescript/head/guides/get-started/installation#_3-setup-server-side-rendering-optional
-        // @see https://unhead.unjs.io/docs/typescript/head/guides/core-concepts/positions#sort-order
-        // @see https://rviscomi.github.io/capo.js/user/rules/
-        if (head)
-          optimized = await transformHtmlTemplate(head as Unhead, transformed)
-
-        const formatted = await formatHtml(optimized, formatting)
+        const formatted = await formatHtml(transformed, formatting)
 
         const relativeRouteFile = `${(route.endsWith('/')
           ? `${route}index`
diff --git a/src/node/preload-links.ts b/src/node/preload-links.ts
index af3293a..7896093 100644
--- a/src/node/preload-links.ts
+++ b/src/node/preload-links.ts
@@ -1,60 +1,28 @@
+import { ResolvableLink } from '@unhead/vue';
 import type { Manifest } from './build'
 
-export function renderPreloadLinks(document: Document, modules: Set<string>, ssrManifest: Manifest) {
-  const seen = new Set()
-
-  const preloadLinks: string[] = []
+export function renderPreloadLinks(modules: Set<string>, ssrManifest: Manifest): ResolvableLink[] {
+  const links: ResolvableLink[] = []
 
   // preload modules
   Array.from(modules).forEach((id) => {
     const files = ssrManifest[id] || []
     files.forEach((file) => {
-      if (!preloadLinks.includes(file))
-        preloadLinks.push(file)
-    })
-  })
-
-  if (preloadLinks) {
-    preloadLinks.forEach((file) => {
-      if (!seen.has(file)) {
-        seen.add(file)
-        renderPreloadLink(document, file)
+      if (file.endsWith('.js')) {
+        links.push({
+          rel: 'modulepreload',
+          crossorigin: '',
+          href: file,
+        })
+      }
+      else if (file.endsWith('.css')) {
+        links.push({
+          rel: 'stylesheet',
+          href: file,
+        })
       }
     })
-  }
-}
-
-function renderPreloadLink(document: Document, file: string) {
-  if (file.endsWith('.js')) {
-    appendLink(document, {
-      rel: 'modulepreload',
-      crossOrigin: '',
-      href: file,
-    })
-  }
-  else if (file.endsWith('.css')) {
-    appendLink(document, {
-      rel: 'stylesheet',
-      href: file,
-    })
-  }
-}
-
-function createLink(document: Document) {
-  return document.createElement('link')
-}
-
-function setAttrs(el: Element, attrs: Record<string, any>) {
-  const keys = Object.keys(attrs)
-  for (const key of keys)
-    el.setAttribute(key, attrs[key])
-}
+  })
 
-function appendLink(document: Document, attrs: Record<string, any>) {
-  const exits = document.head.querySelector(`link[href='${attrs.file}']`)
-  if (exits)
-    return
-  const link = createLink(document)
-  setAttrs(link, attrs)
-  document.head.appendChild(link)
+  return links
 }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants