feat(nuxt): upgrade to unhead v2 (#31169)

This commit is contained in:
Harlan Wilton 2025-03-05 07:42:32 +11:00 committed by GitHub
parent 52146b4641
commit 4edd782011
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 745 additions and 268 deletions

View File

@ -245,6 +245,69 @@ export default defineNuxtConfig({
})
```
#### Unhead v2
🚦 **Impact Level**: Minimal
##### What Changed
[Unhead](https://unhead.unjs.io/), used to generate `<head>` tags, has been updated to version 2. While mostly compatible it includes several breaking changes
for lower-level APIs.
* Removed props: `vmid`, `hid`, `children`, `body`.
* Promise input no longer supported.
* Tags are now sorted using Capo.js by default.
##### Migration Steps
The above changes should have minimal impact on your app.
If you have issues you should verify:
* You're not using any of the removed props.
```diff
useHead({
meta: [{
name: 'description',
// meta tags don't need a vmid, or a key
- vmid: 'description'
- hid: 'description'
}]
})
```
* If you're using [Template Params](https://unhead.unjs.io/docs/plugins/template-params) or [Alias Tag Sorting](https://unhead.unjs.io/docs/plugins/alias-sorting), you will need to explicitly opt in to these features now.
```ts
import { TemplateParamsPlugin, AliasSortingPlugin } from '@unhead/vue/plugins'
export default defineNuxtPlugin({
setup() {
const unhead = injectHead()
unhead.use(TemplateParamsPlugin)
unhead.use(AliasSortingPlugin)
}
})
```
While not required it's recommend to update any imports from `@unhead/vue` to `#imports` or `nuxt/app`.
```diff
-import { useHead } from '@unhead/vue'
+import { useHead } from '#imports'
```
If you still have issues you may revert to the v1 behavior by enabling the `head.legacy` config.
```ts
export default defineNuxtConfig({
unhead: {
legacy: true,
}
})
```
#### New DOM Location for SPA Loading Screen
🚦 **Impact Level**: Minimal

View File

@ -4,35 +4,61 @@ description: Improve your Nuxt app's SEO with powerful head config, composables
navigation.icon: i-ph-file-search
---
## Defaults
Nuxt head tag management is powered by [Unhead](https://unhead.unjs.io). It provides sensible defaults, several powerful composables
and numerous configuration options to manage your app's head and SEO meta tags.
Out-of-the-box, Nuxt provides sensible defaults, which you can override if needed.
## Nuxt Config
```ts twoslash [nuxt.config.ts]
export default defineNuxtConfig({
app: {
head: {
charset: 'utf-8',
viewport: 'width=device-width, initial-scale=1',
}
}
})
```
Providing an [`app.head`](/docs/api/nuxt-config#head) property in your [`nuxt.config.ts`](/docs/guide/directory-structure/nuxt-config) allows you to customize the head for your entire app.
Providing an [`app.head`](/docs/api/nuxt-config#head) property in your [`nuxt.config.ts`](/docs/guide/directory-structure/nuxt-config) allows you to statically customize the head for your entire app.
::important
This method does not allow you to provide reactive data. We recommend to use `useHead()` in `app.vue`.
::
Shortcuts are available to make configuration easier: `charset` and `viewport`. You can also provide any of the keys listed below in [Types](#types).
It's good practice to set tags here that won't change such as your site title default, language and favicon.
```ts twoslash [nuxt.config.ts]
export default defineNuxtConfig({
app: {
head: {
title: 'Nuxt', // default fallback title
htmlAttrs: {
lang: 'en',
},
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
]
}
}
})
```
You can also provide any of the keys listed below in [Types](#types).
### Defaults Tags
Some tags are provided by Nuxt by default to ensure your website works well out of the box.
- `viewport`: `width=device-width, initial-scale=1`
- `charset`: `utf-8`
While most sites won't need to override these defaults, you can update them using the keyed shortcuts.
```ts twoslash [nuxt.config.ts]
export default defineNuxtConfig({
app: {
head: {
// update Nuxt defaults
charset: 'utf-16',
viewport: 'width=device-width, initial-scale=1, maximum-scale=1',
}
}
})
```
## `useHead`
The [`useHead`](/docs/api/composables/use-head) composable function allows you to manage your head tags programmatically and reactively,
powered by [Unhead](https://unhead.unjs.io).
As with all composables, it can only be used with a components `setup` and lifecycle hooks.
The [`useHead`](/docs/api/composables/use-head) composable function supports reactive input, allowing you to manage your head tags programmatically.
```vue twoslash [app.vue]
<script setup lang="ts">
@ -53,7 +79,7 @@ We recommend to take a look at the [`useHead`](/docs/api/composables/use-head) a
## `useSeoMeta`
The [`useSeoMeta`](/docs/api/composables/use-seo-meta) composable lets you define your site's SEO meta tags as a flat object with full TypeScript support.
The [`useSeoMeta`](/docs/api/composables/use-seo-meta) composable lets you define your site's SEO meta tags as an object with full type safety.
This helps you avoid typos and common mistakes, such as using `name` instead of `property`.

View File

@ -28,8 +28,8 @@ useHeadSafe({
// <meta content="0;javascript:alert(1)">
```
::read-more{to="https://unhead.unjs.io/usage/composables/use-head-safe" target="_blank"}
Read more on `unhead` documentation.
::read-more{to="https://unhead.unjs.io/docs/api/use-head-safe" target="_blank"}
Read more on the `Unhead` documentation.
::
## Type
@ -41,13 +41,14 @@ useHeadSafe(input: MaybeComputedRef<HeadSafe>): void
The list of allowed values is:
```ts
export default {
htmlAttrs: ['id', 'class', 'lang', 'dir'],
bodyAttrs: ['id', 'class'],
meta: ['id', 'name', 'property', 'charset', 'content'],
noscript: ['id', 'textContent'],
script: ['id', 'type', 'textContent'],
link: ['id', 'color', 'crossorigin', 'fetchpriority', 'href', 'hreflang', 'imagesrcset', 'imagesizes', 'integrity', 'media', 'referrerpolicy', 'rel', 'sizes', 'type'],
const WhitelistAttributes = {
htmlAttrs: ['class', 'style', 'lang', 'dir'],
bodyAttrs: ['class', 'style'],
meta: ['name', 'property', 'charset', 'content', 'media'],
noscript: ['textContent'],
style: ['media', 'textContent', 'nonce', 'title', 'blocking'],
script: ['type', 'textContent', 'nonce', 'blocking'],
link: ['color', 'crossorigin', 'fetchpriority', 'href', 'hreflang', 'imagesrcset', 'imagesizes', 'integrity', 'media', 'referrerpolicy', 'rel', 'sizes', 'type'],
}
```

View File

@ -17,7 +17,7 @@ This composable is available in Nuxt v3.12+.
## Description
A composable which observes the page title changes and updates the announcer message accordingly. Used by [`<NuxtRouteAnnouncer>`](/docs/api/components/nuxt-route-announcer) and controllable.
It hooks into Unhead's [`dom:rendered`](https://unhead.unjs.io/api/core/hooks#dom-hooks) to read the page's title and set it as the announcer message.
It hooks into Unhead's [`dom:rendered`](https://unhead.unjs.io/docs/guides/hooks) to read the page's title and set it as the announcer message.
## Parameters

View File

@ -49,3 +49,32 @@ useSeoMeta({
There are over 100 parameters. See the [full list of parameters in the source code](https://github.com/harlan-zw/zhead/blob/main/packages/zhead/src/metaFlat.ts#L1035).
:read-more{to="/docs/getting-started/seo-meta"}
## Performance
In most instances, SEO meta tags don't need to be reactive as search engine robots primarily scan the initial page load.
For better performance, you can wrap your `useSeoMeta` calls in a server-only condition when the meta tags don't need to be reactive:
```vue [app.vue]
<script setup lang="ts">
if (import.meta.server) {
// These meta tags will only be added during server-side rendering
useSeoMeta({
robots: 'index, follow',
description: 'Static description that does not need reactivity',
ogImage: 'https://example.com/image.png',
// other static meta tags...
})
}
const dynamicTitle = ref('My title')
// Only use reactive meta tags outside the condition when necessary
useSeoMeta({
title: () => dynamicTitle.value,
ogTitle: () => dynamicTitle.value,
})
</script>
```
This previously used the [`useServerSeoMeta`](/docs/api/composables/use-server-seo-meta) composable, but it has been deprecated in favor of this approach.

View File

@ -111,7 +111,8 @@ export default defineNuxtModule({
```
```ts [plugin.ts]
import { createHead as createClientHead, createServerHead } from '@unhead/vue'
import { createHead as createServerHead } from '@unhead/vue/server'
import { createHead as createClientHead } from '@unhead/vue/client'
import { defineNuxtPlugin } from '#imports'
// @ts-ignore
import metaConfig from '#build/meta.config.mjs'

View File

@ -11,7 +11,7 @@ Nuxt 3 provides several different ways to manage your meta tags:
You can customize `title`, `titleTemplate`, `base`, `script`, `noscript`, `style`, `meta`, `link`, `htmlAttrs` and `bodyAttrs`.
::tip
Nuxt currently uses [`vueuse/head`](https://github.com/vueuse/head) to manage your meta tags, but implementation details may change.
Nuxt currently uses [`Unhead`](https://github.com/unjs/unhead) to manage your meta tags, but implementation details may change.
::
:read-more{to="/docs/getting-started/seo-meta"}

View File

@ -46,11 +46,7 @@
"@nuxt/vite-builder": "workspace:*",
"@nuxt/webpack-builder": "workspace:*",
"@types/node": "22.13.9",
"@unhead/dom": "1.11.20",
"@unhead/schema": "1.11.20",
"@unhead/shared": "1.11.20",
"@unhead/ssr": "1.11.20",
"@unhead/vue": "1.11.20",
"@unhead/vue": "2.0.0-rc.1",
"@vue/compiler-core": "3.5.13",
"@vue/compiler-dom": "3.5.13",
"@vue/shared": "3.5.13",
@ -66,7 +62,6 @@
"typescript": "5.8.2",
"ufo": "1.5.4",
"unbuild": "3.5.0",
"unhead": "1.11.20",
"unimport": "4.1.2",
"vite": "6.2.0",
"vue": "3.5.13"
@ -87,8 +82,7 @@
"@types/babel__helper-plugin-utils": "7.10.3",
"@types/node": "22.13.9",
"@types/semver": "7.5.8",
"@unhead/schema": "1.11.20",
"@unhead/vue": "1.11.20",
"@unhead/vue": "2.0.0-rc.1",
"@vitest/coverage-v8": "3.0.7",
"@vue/test-utils": "2.4.6",
"acorn": "8.14.0",

View File

@ -71,10 +71,7 @@
"@nuxt/schema": "workspace:*",
"@nuxt/telemetry": "^2.6.5",
"@nuxt/vite-builder": "workspace:*",
"@unhead/dom": "^1.11.20",
"@unhead/shared": "^1.11.20",
"@unhead/ssr": "^1.11.20",
"@unhead/vue": "^1.11.20",
"@unhead/vue": "^2.0.0-rc.1",
"@vue/shared": "^3.5.13",
"c12": "^3.0.2",
"chokidar": "^4.0.3",
@ -120,7 +117,6 @@
"uncrypto": "^0.1.3",
"unctx": "^2.4.1",
"unenv": "^1.10.0",
"unhead": "^1.11.20",
"unimport": "^4.1.2",
"unplugin": "^2.2.0",
"unplugin-vue-router": "^0.12.0",

View File

@ -1,7 +1,7 @@
import type { defineAsyncComponent } from 'vue'
import { createVNode, defineComponent, onErrorCaptured } from 'vue'
import { injectHead } from '@unhead/vue'
import { injectHead } from '../composables/head'
import { createError } from '../composables/error'
// @ts-expect-error virtual file

View File

@ -3,7 +3,7 @@ import { Fragment, Teleport, computed, createStaticVNode, createVNode, defineCom
import { debounce } from 'perfect-debounce'
import { hash } from 'ohash'
import { appendResponseHeader } from 'h3'
import { type ActiveHeadEntry, type Head, injectHead } from '@unhead/vue'
import type { ActiveHeadEntry, SerializableHead } from '@unhead/vue'
import { randomUUID } from 'uncrypto'
import { joinURL, withQuery } from 'ufo'
import type { FetchResponse } from 'ofetch'
@ -11,6 +11,7 @@ import type { FetchResponse } from 'ofetch'
import type { NuxtIslandResponse } from '../types'
import { useNuxtApp, useRuntimeConfig } from '../nuxt'
import { prerenderRoutes, useRequestEvent } from '../composables/ssr'
import { injectHead } from '../composables/head'
import { getFragmentHTML } from './utils'
// @ts-expect-error virtual file
@ -90,7 +91,7 @@ export default defineComponent({
const instance = getCurrentInstance()!
const event = useRequestEvent()
let activeHead: ActiveHeadEntry<Head>
let activeHead: ActiveHeadEntry<SerializableHead>
// TODO: remove use of `$fetch.raw` when nitro 503 issues on windows dev server are resolved
const eventFetch = import.meta.server ? event!.fetch : import.meta.dev ? $fetch.raw : globalThis.fetch

View File

@ -1,9 +1,9 @@
import { getCurrentInstance, reactive, toRefs } from 'vue'
import type { DefineComponent, defineComponent } from 'vue'
import { useHead } from '@unhead/vue'
import { hash } from 'ohash'
import type { NuxtApp } from '../nuxt'
import { getNuxtAppCtx, useNuxtApp } from '../nuxt'
import { useHead } from './head'
import { useAsyncData } from './asyncData'
import { useRoute } from './router'
import { createError } from './error'

View File

@ -0,0 +1,9 @@
export {
injectHead,
useHead,
useServerHead,
useSeoMeta,
useServerSeoMeta,
useHeadSafe,
useServerHeadSafe,
} from '#unhead/composables'

View File

@ -1,17 +1,3 @@
import type { UseHeadInput } from '@unhead/vue'
import type { HeadAugmentations } from 'nuxt/schema'
/** @deprecated Use `UseHeadInput` from `@unhead/vue` instead. This may be removed in a future minor version. */
export type MetaObject = UseHeadInput<HeadAugmentations>
export {
/** @deprecated Import `useHead` from `#imports` instead. This may be removed in a future minor version. */
useHead,
/** @deprecated Import `useSeoMeta` from `#imports` instead. This may be removed in a future minor version. */
useSeoMeta,
/** @deprecated Import `useServerSeoMeta` from `#imports` instead. This may be removed in a future minor version. */
useServerSeoMeta,
} from '@unhead/vue'
export { defineNuxtComponent } from './component'
export { useAsyncData, useLazyAsyncData, useNuxtData, refreshNuxtData, clearNuxtData } from './asyncData'
export type { AsyncDataOptions, AsyncData, AsyncDataRequestStatus } from './asyncData'
@ -40,3 +26,4 @@ export { useId } from './id'
export { useRouteAnnouncer } from './route-announcer'
export type { Politeness } from './route-announcer'
export { useRuntimeHook } from './runtime-hook'
export { injectHead, useHead, useHeadSafe, useSeoMeta, useServerHead, useServerHeadSafe, useServerSeoMeta } from './head'

View File

@ -1,9 +1,9 @@
import { hasProtocol, joinURL, withoutTrailingSlash } from 'ufo'
import { parse } from 'devalue'
import { useHead } from '@unhead/vue'
import { getCurrentInstance, onServerPrefetch, reactive } from 'vue'
import { useNuxtApp, useRuntimeConfig } from '../nuxt'
import type { NuxtPayload } from '../nuxt'
import { useHead } from './head'
import { useRoute } from './router'
import { getAppManifest, getRouteRules } from './manifest'

View File

@ -1,7 +1,7 @@
import type { Ref } from 'vue'
import { getCurrentScope, onScopeDispose, ref } from 'vue'
import { injectHead } from '@unhead/vue'
import { useNuxtApp } from '../nuxt'
import { injectHead } from './head'
export type Politeness = 'assertive' | 'polite' | 'off'

View File

@ -1,4 +1,4 @@
import type { UseScriptInput } from '@unhead/vue'
import type { UseScriptInput } from '@unhead/vue/scripts'
import { createError } from './error'
function renderStubMessage (name: string) {

View File

@ -1,12 +1,12 @@
import type { H3Event } from 'h3'
import { setResponseStatus as _setResponseStatus, appendHeader, getRequestHeader, getRequestHeaders, getResponseHeader, removeResponseHeader, setResponseHeader } from 'h3'
import { computed, getCurrentInstance, ref } from 'vue'
import { useServerHead } from '@unhead/vue'
import type { H3Event$Fetch } from 'nitro/types'
import type { NuxtApp } from '../nuxt'
import { useNuxtApp } from '../nuxt'
import { toArray } from '../utils'
import { useServerHead } from './head'
/** @since 3.0.0 */
export function useRequestEvent (nuxtApp: NuxtApp = useNuxtApp()) {
@ -132,7 +132,7 @@ export function onPrehydrate (callback: string | ((el: HTMLElement) => void), ke
useServerHead({
script: [{
key: vm && key ? key : code,
key: vm && key ? key : undefined,
tagPosition: 'bodyClose',
tagPriority: 'critical',
innerHTML: code,

View File

@ -1,7 +1,7 @@
export { applyPlugin, applyPlugins, callWithNuxt, createNuxtApp, defineAppConfig, defineNuxtPlugin, definePayloadPlugin, isNuxtPlugin, registerPluginHooks, tryUseNuxtApp, useNuxtApp, useRuntimeConfig } from './nuxt'
export type { CreateOptions, NuxtApp, NuxtPayload, NuxtPluginIndicator, NuxtSSRContext, ObjectPlugin, Plugin, PluginEnvContext, PluginMeta, ResolvedPluginMeta, RuntimeNuxtHooks } from './nuxt'
export { defineNuxtComponent, useAsyncData, useLazyAsyncData, useNuxtData, refreshNuxtData, clearNuxtData, useHydration, callOnce, useState, clearNuxtState, clearError, createError, isNuxtError, showError, useError, useFetch, useLazyFetch, useCookie, refreshCookie, onPrehydrate, prerenderRoutes, useRequestHeaders, useRequestEvent, useRequestFetch, setResponseStatus, useResponseHeader, onNuxtReady, abortNavigation, addRouteMiddleware, defineNuxtRouteMiddleware, onBeforeRouteLeave, onBeforeRouteUpdate, setPageLayout, navigateTo, useRoute, useRouter, preloadComponents, prefetchComponents, preloadRouteComponents, isPrerendered, loadPayload, preloadPayload, definePayloadReducer, definePayloadReviver, getAppManifest, getRouteRules, reloadNuxtApp, useRequestURL, usePreviewMode, useId, useRouteAnnouncer, useHead, useSeoMeta, useServerSeoMeta, useRuntimeHook } from './composables/index'
export { defineNuxtComponent, useAsyncData, useLazyAsyncData, useNuxtData, refreshNuxtData, clearNuxtData, useHydration, callOnce, useState, clearNuxtState, clearError, createError, isNuxtError, showError, useError, useFetch, useLazyFetch, useCookie, refreshCookie, onPrehydrate, prerenderRoutes, useRequestHeaders, useRequestEvent, useRequestFetch, setResponseStatus, useResponseHeader, onNuxtReady, abortNavigation, addRouteMiddleware, defineNuxtRouteMiddleware, onBeforeRouteLeave, onBeforeRouteUpdate, setPageLayout, navigateTo, useRoute, useRouter, preloadComponents, prefetchComponents, preloadRouteComponents, isPrerendered, loadPayload, preloadPayload, definePayloadReducer, definePayloadReviver, getAppManifest, getRouteRules, reloadNuxtApp, useRequestURL, usePreviewMode, useId, useRouteAnnouncer, useHead, useHeadSafe, useServerSeoMeta, useServerHeadSafe, useServerHead, useSeoMeta, injectHead, useRuntimeHook } from './composables/index'
export type { AddRouteMiddlewareOptions, AsyncData, AsyncDataOptions, AsyncDataRequestStatus, CookieOptions, CookieRef, FetchResult, NuxtAppManifest, NuxtAppManifestMeta, NuxtError, Politeness, ReloadNuxtAppOptions, RouteMiddleware, UseFetchOptions } from './composables/index'
export { defineNuxtLink } from './components/index'

View File

@ -9,7 +9,7 @@ import type { EventHandlerRequest, H3Event } from 'h3'
import type { AppConfig, AppConfigInput, RuntimeConfig } from 'nuxt/schema'
import type { RenderResponse } from 'nitro/types'
import type { LogObject } from 'consola'
import type { MergeHead, VueHeadClient } from '@unhead/vue'
import type { VueHeadClient } from '@unhead/vue/types'
import type { NuxtAppLiterals } from 'nuxt/app'
@ -66,7 +66,7 @@ export interface NuxtSSRContext extends SSRContext {
error?: boolean
nuxt: _NuxtApp
payload: Partial<NuxtPayload>
head: VueHeadClient<MergeHead>
head: VueHeadClient
/** This is used solely to render runtime config with SPA renderer. */
config?: Pick<RuntimeConfig, 'public' | 'app'>
teleports?: Record<string, string>

View File

@ -1,6 +1,6 @@
import { ref } from 'vue'
import { useHead } from '@unhead/vue'
import { defineNuxtPlugin } from '../nuxt'
import { useHead } from '../composables/head'
const SUPPORTED_PROTOCOLS = ['http:', 'https:']

View File

@ -1,4 +1,4 @@
import type { UseHeadInput } from '@unhead/vue'
import type { UseHeadInput } from '@unhead/vue/types'
import type { NuxtApp, useNuxtApp } from '../nuxt.js'
declare global {

View File

@ -10,10 +10,9 @@ import type { H3Event } from 'h3'
import { appendResponseHeader, createError, getQuery, getResponseStatus, getResponseStatusText, readBody, writeEarlyHints } from 'h3'
import destr from 'destr'
import { getQuery as getURLQuery, joinURL, withoutTrailingSlash } from 'ufo'
import { propsToString, renderSSRHead } from '@unhead/ssr'
import type { Head, HeadEntryOptions } from '@unhead/schema'
import type { Link, Script, Style } from '@unhead/vue'
import { createServerHead, resolveUnrefHeadInput } from '@unhead/vue'
import { createHead, propsToString, renderSSRHead } from '@unhead/vue/server'
import { resolveUnrefHeadInput } from '@unhead/vue/utils'
import type { HeadEntryOptions, Link, Script, SerializableHead, Style } from '@unhead/vue/types'
import { defineRenderHandler, getRouteRules, useNitroApp, useRuntimeConfig } from 'nitro/runtime'
import type { NuxtPayload, NuxtSSRContext } from 'nuxt/app'
@ -23,7 +22,7 @@ import { islandCache, islandPropCache, payloadCache, sharedPrerenderCache } from
import { renderPayloadJsonScript, renderPayloadResponse, renderPayloadScript, splitPayload } from '../utils/payload'
// @ts-expect-error virtual file
import unheadPlugins from '#internal/unhead-plugins.mjs'
import unheadOptions from '#internal/unhead-options.mjs'
// @ts-expect-error virtual file
import { renderSSRHeadOptions } from '#internal/unhead.config.mjs'
@ -76,7 +75,7 @@ export interface NuxtIslandContext {
export interface NuxtIslandResponse {
id?: string
html: string
head: Head
head: SerializableHead
props?: Record<string, Record<string, any>>
components?: Record<string, NuxtIslandClientResponse>
slots?: Record<string, NuxtIslandSlotResponse>
@ -172,9 +171,7 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
// Get route options (currently to apply `ssr: false`)
const routeOptions = getRouteRules(event)
const head = createServerHead({
plugins: unheadPlugins,
})
const head = createHead(unheadOptions)
// needed for hash hydration plugin to work
const headEntryOptions: HeadEntryOptions = { mode: 'server' }
@ -315,14 +312,14 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
// 3. Response for component islands
if (isRenderingIsland && islandContext) {
const islandHead: Head = {}
for (const entry of head.headEntries()) {
for (const [key, value] of Object.entries(resolveUnrefHeadInput(entry.input) as Head)) {
const currentValue = islandHead[key as keyof Head]
const islandHead: SerializableHead = {}
for (const entry of head.entries.values()) {
for (const [key, value] of Object.entries(resolveUnrefHeadInput(entry.input as any) as SerializableHead)) {
const currentValue = islandHead[key as keyof SerializableHead]
if (Array.isArray(currentValue)) {
currentValue.push(...value)
}
islandHead[key as keyof Head] = value
islandHead[key as keyof SerializableHead] = value
}
}

View File

@ -4,7 +4,7 @@ import {
import type { Manifest as ClientManifest } from 'vue-bundle-renderer'
import type { Manifest } from 'vite'
import { renderToString as _renderToString } from 'vue/server-renderer'
import { propsToString } from '@unhead/ssr'
import { propsToString } from '@unhead/vue/server'
import { useRuntimeConfig } from 'nitro/runtime'
import type { NuxtSSRContext } from 'nuxt/app'

View File

@ -1,8 +1,9 @@
import { resolve } from 'pathe'
import { addComponent, addImportsSources, addPlugin, addTemplate, defineNuxtModule, directoryToURL } from '@nuxt/kit'
import { addBuildPlugin, addComponent, addPlugin, addTemplate, defineNuxtModule, directoryToURL } from '@nuxt/kit'
import type { NuxtOptions } from '@nuxt/schema'
import { resolveModulePath } from 'exsolve'
import { distDir } from '../dirs'
import { UnheadImportsPlugin } from './plugins/unhead-imports'
const components = ['NoScript', 'Link', 'Base', 'Title', 'Meta', 'Style', 'Head', 'Html', 'Body']
@ -17,6 +18,7 @@ export default defineNuxtModule<NuxtOptions['unhead']>({
// Transpile @unhead/vue
nuxt.options.build.transpile.push('@unhead/vue')
const isNuxtV4 = nuxt.options._majorVersion === 4 || nuxt.options.future?.compatibilityVersion === 4
// Register components
const componentsPath = resolve(runtimeDir, 'components')
for (const componentName of components) {
@ -38,32 +40,34 @@ export default defineNuxtModule<NuxtOptions['unhead']>({
]
}
addImportsSources({
from: '@unhead/vue',
// hard-coded for now we so don't support auto-imports on the deprecated composables
imports: [
'injectHead',
'useHead',
'useSeoMeta',
'useHeadSafe',
'useServerHead',
'useServerSeoMeta',
'useServerHeadSafe',
],
})
nuxt.options.alias['#unhead/composables'] = resolve(runtimeDir, 'composables', isNuxtV4 ? 'v4' : 'v3')
addBuildPlugin(UnheadImportsPlugin({
sourcemap: !!nuxt.options.sourcemap.server,
rootDir: nuxt.options.rootDir,
}))
// Opt-out feature allowing dependencies using @vueuse/head to work
const importPaths = nuxt.options.modulesDir.map(d => directoryToURL(d))
const unheadVue = resolveModulePath('@unhead/vue', { try: true, from: importPaths }) || '@unhead/vue'
const unheadPlugins = resolveModulePath('@unhead/vue/plugins', { try: true, from: importPaths }) || '@unhead/vue/plugins'
addTemplate({
filename: 'unhead-plugins.mjs',
filename: 'unhead-options.mjs',
getContents () {
if (!nuxt.options.experimental.headNext) {
return 'export default []'
// disableDefaults is enabled to avoid server component issues
if (isNuxtV4 && !options.legacy) {
return `
export default {
disableDefaults: true,
}`
}
return `import { CapoPlugin } from ${JSON.stringify(unheadVue)};
export default import.meta.server ? [CapoPlugin({ track: true })] : [];`
// v1 unhead legacy options
const disableCapoSorting = !nuxt.options.experimental.headNext
return `import { DeprecationsPlugin, PromisesPlugin, TemplateParamsPlugin, AliasSortingPlugin } from ${JSON.stringify(unheadPlugins)};
export default {
disableDefaults: true,
disableCapoSorting: ${Boolean(disableCapoSorting)},
plugins: [DeprecationsPlugin, PromisesPlugin, TemplateParamsPlugin, AliasSortingPlugin],
}`
},
})
@ -78,7 +82,7 @@ export default import.meta.server ? [CapoPlugin({ track: true })] : [];`
// template is only exposed in nuxt context, expose in nitro context as well
nuxt.hooks.hook('nitro:config', (config) => {
config.virtual!['#internal/unhead-plugins.mjs'] = () => nuxt.vfs['#build/unhead-plugins.mjs']
config.virtual!['#internal/unhead-options.mjs'] = () => nuxt.vfs['#build/unhead-options.mjs']
config.virtual!['#internal/unhead.config.mjs'] = () => nuxt.vfs['#build/unhead.config.mjs']
})

View File

@ -0,0 +1,84 @@
import { createUnplugin } from 'unplugin'
import MagicString from 'magic-string'
import type { Identifier, ImportSpecifier } from 'estree'
import { normalize, relative } from 'pathe'
import { unheadVueComposablesImports } from '@unhead/vue'
import { genImport } from 'knitwork'
import { parseAndWalk, withLocations } from '../../core/utils/parse'
import { isJS, isVue } from '../../core/utils'
import { distDir } from '../../dirs'
import { logger } from '../../utils'
interface UnheadImportsPluginOptions {
sourcemap: boolean
rootDir: string
}
const UNHEAD_LIB_RE = /node_modules[/\\](?:@unhead[/\\][^/\\]+|unhead)[/\\]/
function toImports (specifiers: ImportSpecifier[]) {
return specifiers.map((specifier) => {
const imported = specifier.imported as Identifier | null
const isNamedImport = imported && imported.name !== specifier.local.name
return isNamedImport ? `${imported.name} as ${specifier.local.name}` : specifier.local.name
})
}
const UnheadVue = '@unhead/vue'
/**
* To use composable in an async context we need to pass Nuxt context to the Unhead composables.
*
* We swap imports from @unhead/vue to #app/composables/head and warn users for type safety.
*/
export const UnheadImportsPlugin = (options: UnheadImportsPluginOptions) => createUnplugin(() => {
return {
name: 'nuxt:head:unhead-imports',
enforce: 'post',
transformInclude (id) {
id = normalize(id)
return (
(isJS(id) || isVue(id, { type: ['script'] })) &&
!id.startsWith('virtual:') &&
!id.startsWith(normalize(distDir)) &&
!UNHEAD_LIB_RE.test(id)
)
},
transform (code, id) {
if (!code.includes(UnheadVue)) {
return
}
const s = new MagicString(code)
const importsToAdd: ImportSpecifier[] = []
parseAndWalk(code, id, function (node) {
if (node.type === 'ImportDeclaration' && [UnheadVue, '#app/composables/head'].includes(String(node.source.value))) {
importsToAdd.push(...node.specifiers as ImportSpecifier[])
const { start, end } = withLocations(node)
s.remove(start, end)
}
})
const importsFromUnhead = importsToAdd.filter(specifier => unheadVueComposablesImports[UnheadVue].includes((specifier.imported as Identifier)?.name))
const importsFromHead = importsToAdd.filter(specifier => !unheadVueComposablesImports[UnheadVue].includes((specifier.imported as Identifier)?.name))
if (importsFromUnhead.length) {
// warn if user has imported from @unhead/vue themselves
if (!normalize(id).includes('node_modules')) {
logger.warn(`You are importing from \`${UnheadVue}\` in \`./${relative(normalize(options.rootDir), normalize(id))}\`. Please import from \`#imports\` instead for full type safety.`)
}
s.prepend(`${genImport('#app/composables/head', toImports(importsFromUnhead))}\n`)
}
if (importsFromHead.length) {
s.prepend(`${genImport(UnheadVue, toImports(importsFromHead))}\n`)
}
if (s.hasChanged()) {
return {
code: s.toString(),
map: options.sourcemap
? s.generateMap({ hires: true })
: undefined,
}
}
},
}
})

View File

@ -1,6 +1,5 @@
import { defineComponent } from 'vue'
import type { PropType, SetupContext } from 'vue'
import { useHead } from '@unhead/vue'
import type {
CrossOrigin,
FetchPriority,
@ -10,6 +9,7 @@ import type {
ReferrerPolicy,
Target,
} from './types'
import { useHead } from '#app/composables/head'
const removeUndefinedProps = (props: Props) => {
const filteredProps = Object.create(null)

View File

@ -0,0 +1,72 @@
import type { ActiveHeadEntry, UseHeadInput, UseHeadOptions, UseHeadSafeInput, UseSeoMetaInput, VueHeadClient } from '@unhead/vue'
import { hasInjectionContext, inject } from 'vue'
import {
useHead as headCore,
useHeadSafe as headSafe,
headSymbol,
useSeoMeta as seoMeta, useServerHead as serverHead, useServerHeadSafe as serverHeadSafe,
useServerSeoMeta as serverSeoMeta,
} from '@unhead/vue'
import { tryUseNuxtApp } from '#app/nuxt'
import type { NuxtApp } from '#app/nuxt'
/**
* Injects the head client from the Nuxt context or Vue inject.
*
* In Nuxt v3 this function will not throw an error if the context is missing.
*/
export function injectHead (nuxtApp?: NuxtApp): VueHeadClient {
// Nuxt 4 will throw an error if the context is missing
const nuxt = nuxtApp || tryUseNuxtApp()
return nuxt?.ssrContext?.head || nuxt?.runWithContext(() => {
if (hasInjectionContext()) {
return inject<VueHeadClient>(headSymbol)!
}
}) as VueHeadClient
}
interface NuxtUseHeadOptions extends UseHeadOptions {
nuxt?: NuxtApp
}
export function useHead (input: UseHeadInput, options: NuxtUseHeadOptions = {}): ActiveHeadEntry<UseHeadInput> | void {
const head = injectHead(options.nuxt)
if (head) {
return headCore(input, { head, ...options }) as ActiveHeadEntry<UseHeadInput>
}
}
export function useHeadSafe (input: UseHeadSafeInput, options: NuxtUseHeadOptions = {}): ActiveHeadEntry<UseHeadSafeInput> | void {
const head = injectHead(options.nuxt)
if (head) {
return headSafe(input, { head, ...options }) as ActiveHeadEntry<UseHeadSafeInput>
}
}
export function useSeoMeta (input: UseSeoMetaInput, options: NuxtUseHeadOptions = {}): ActiveHeadEntry<UseSeoMetaInput> | void {
const head = injectHead(options.nuxt)
if (head) {
return seoMeta(input, { head, ...options }) as ActiveHeadEntry<UseHeadInput>
}
}
export function useServerHead (input: UseHeadInput, options: NuxtUseHeadOptions = {}): ActiveHeadEntry<UseHeadInput> | void {
const head = injectHead(options.nuxt)
if (head) {
return serverHead(input, { head, ...options }) as ActiveHeadEntry<UseHeadInput>
}
}
export function useServerHeadSafe (input: UseHeadSafeInput, options: NuxtUseHeadOptions = {}): ActiveHeadEntry<UseHeadSafeInput> | void {
const head = injectHead(options.nuxt)
if (head) {
return serverHeadSafe(input, { head, ...options }) as ActiveHeadEntry<UseHeadSafeInput>
}
}
export function useServerSeoMeta (input: UseSeoMetaInput, options: NuxtUseHeadOptions = {}): ActiveHeadEntry<UseSeoMetaInput> | void {
const head = injectHead(options.nuxt)
if (head) {
return serverSeoMeta(input, { head, ...options }) as ActiveHeadEntry<UseSeoMetaInput>
}
}

View File

@ -0,0 +1,72 @@
import type { ActiveHeadEntry, UseHeadInput, UseHeadOptions, UseHeadSafeInput, UseSeoMetaInput, VueHeadClient } from '@unhead/vue/types'
import { hasInjectionContext, inject } from 'vue'
import {
useHead as headCore,
useHeadSafe as headSafe,
headSymbol,
useSeoMeta as seoMeta, useServerHead as serverHead, useServerHeadSafe as serverHeadSafe,
useServerSeoMeta as serverSeoMeta,
} from '@unhead/vue'
import { useNuxtApp } from '#app/nuxt'
import type { NuxtApp } from '#app/nuxt'
/**
* Injects the head client from the Nuxt context or Vue inject.
*/
export function injectHead (nuxtApp?: NuxtApp): VueHeadClient {
// Nuxt 4 will throw an error if the context is missing
const nuxt = nuxtApp || useNuxtApp()
return nuxt.ssrContext?.head || nuxt.runWithContext(() => {
if (hasInjectionContext()) {
const head = inject<VueHeadClient>(headSymbol)
// should not be possible
if (!head) {
throw new Error('[nuxt] [unhead] Missing Unhead instance.')
}
return head
}
}) as VueHeadClient
}
interface NuxtUseHeadOptions extends UseHeadOptions {
nuxt?: NuxtApp
}
export function useHead (input: UseHeadInput, options: NuxtUseHeadOptions = {}): ActiveHeadEntry<UseHeadInput> {
const head = injectHead(options.nuxt)
return headCore(input, { head, ...options }) as ActiveHeadEntry<UseHeadInput>
}
export function useHeadSafe (input: UseHeadSafeInput, options: NuxtUseHeadOptions = {}): ActiveHeadEntry<UseHeadSafeInput> {
const head = injectHead(options.nuxt)
return headSafe(input, { head, ...options }) as ActiveHeadEntry<UseHeadSafeInput>
}
export function useSeoMeta (input: UseSeoMetaInput, options: NuxtUseHeadOptions = {}): ActiveHeadEntry<UseSeoMetaInput> {
const head = injectHead(options.nuxt)
return seoMeta(input, { head, ...options }) as ActiveHeadEntry<UseSeoMetaInput>
}
/**
* @deprecated Use `useHead` instead and wrap with `if (import.meta.server)`
*/
export function useServerHead (input: UseHeadInput, options: NuxtUseHeadOptions = {}): ActiveHeadEntry<UseHeadInput> {
const head = injectHead(options.nuxt)
return serverHead(input, { head, ...options }) as ActiveHeadEntry<UseHeadInput>
}
/**
* @deprecated Use `useHeadSafe` instead and wrap with `if (import.meta.server)`
*/
export function useServerHeadSafe (input: UseHeadSafeInput, options: NuxtUseHeadOptions = {}): ActiveHeadEntry<UseHeadSafeInput> {
const head = injectHead(options.nuxt)
return serverHeadSafe(input, { head, ...options }) as ActiveHeadEntry<UseHeadSafeInput>
}
/**
* @deprecated Use `useSeoMeta` instead and wrap with `if (import.meta.server)`
*/
export function useServerSeoMeta (input: UseSeoMetaInput, options: NuxtUseHeadOptions = {}): ActiveHeadEntry<UseSeoMetaInput> {
const head = injectHead(options.nuxt)
return serverSeoMeta(input, { head, ...options }) as ActiveHeadEntry<UseSeoMetaInput>
}

View File

@ -1,9 +1,8 @@
import { createHead as createClientHead, setHeadInjectionHandler } from '@unhead/vue'
import { renderDOMHead } from '@unhead/dom'
import { defineNuxtPlugin, useNuxtApp } from '#app/nuxt'
import { createHead as createClientHead, renderDOMHead } from '@unhead/vue/client'
import { defineNuxtPlugin } from '#app/nuxt'
// @ts-expect-error virtual file
import unheadPlugins from '#build/unhead-plugins.mjs'
import unheadOptions from '#build/unhead-options.mjs'
export default defineNuxtPlugin({
name: 'nuxt:head',
@ -11,14 +10,7 @@ export default defineNuxtPlugin({
setup (nuxtApp) {
const head = import.meta.server
? nuxtApp.ssrContext!.head
: createClientHead({
plugins: unheadPlugins,
})
// allow useHead to be used outside a Vue context but within a Nuxt context
setHeadInjectionHandler(
// need a fresh instance of the nuxt app to avoid parallel requests interfering with each other
() => useNuxtApp().vueApp._context.provides.usehead,
)
: createClientHead(unheadOptions)
// nuxt.config appHead is set server-side within the renderer
nuxtApp.vueApp.use(head)

View File

@ -105,6 +105,10 @@ const granularAppPresets: InlinePreset[] = [
imports: ['useRuntimeHook'],
from: '#app/composables/runtime-hook',
},
{
imports: ['useHead', 'useHeadSafe', 'useServerHeadSafe', 'useServerHead', 'useSeoMeta', 'useServerSeoMeta', 'injectHead'],
from: '#app/composables/head',
},
]
export const scriptsStubsPreset = {

View File

@ -0,0 +1,208 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { compileScript, parse } from '@vue/compiler-sfc'
import { UnheadImportsPlugin } from '../src/head/plugins/unhead-imports'
describe('UnheadImportsPlugin', () => {
// Helper function to transform code
function transform (code: string, id = 'app.vue') {
const plugin = UnheadImportsPlugin({ rootDir: import.meta.dirname, sourcemap: false }).raw({}, {} as any) as any
return plugin.transformInclude(id) ? Promise.resolve(plugin.transform(code, id)).then((r: any) => r?.code.replace(/^ {6}/gm, '').trim()) : null
}
describe('transformInclude', () => {
// @ts-expect-error untyped
const transformInclude = UnheadImportsPlugin({ rootDir: process.cwd(), sourcemap: false }).raw({}, {} as any).transformInclude
it('should include JS files', () => {
expect(transformInclude('/project/components/MyComponent.js')).toBe(true)
})
it('should include TypeScript files', () => {
expect(transformInclude('/project/components/MyComponent.ts')).toBe(true)
})
it('should include Vue files', () => {
expect(transformInclude('/project/components/MyComponent.vue')).toBe(true)
})
it('should exclude virtual files', () => {
expect(transformInclude('virtual:my-plugin')).toBe(false)
})
it('should exclude files from unhead libraries', () => {
expect(transformInclude('/project/node_modules/@unhead/vue/index.js')).toBe(false)
expect(transformInclude('/project/node_modules/unhead/index.js')).toBe(false)
})
})
describe('transform', () => {
it('should not transform code that does not include @unhead/vue', async () => {
const code = `import { renderSSRHead } from '@unhead/ssr'`
const result = await transform(code, '/project/components/MyComponent.vue')
expect(result).toBeUndefined()
})
it('should transform imports from @unhead/vue in .vue files', async () => {
const sfc = `
<script lang="ts" setup>
import { useHead } from '@unhead/vue'
useHead({ title: 'My Page' })
</script>
`
const res = compileScript(parse(sfc).descriptor, { id: 'component.vue' })
const result = await transform(res.content, '/project/components/MyComponent.vue')
expect(result).toBeDefined()
expect(result).toContain('import { useHead } from "#app/composables/head"')
expect(result).not.toContain('import { useHead } from "@unhead/vue"')
})
it('should transform imports from @unhead/vue in JS files', async () => {
const code = `import { useHead } from '@unhead/vue'`
const result = await transform(code, '/project/composables/head.ts')
expect(result).toBeDefined()
expect(result).toContain('import { useHead } from "#app/composables/head"')
expect(result).not.toContain('import { useHead } from "@unhead/vue"')
})
it('should handle mixed imports correctly', async () => {
const code = `
import { useHead } from '@unhead/vue'
import { useSeoMeta } from '#app/composables/head'
`
const result = await transform(code, '/project/components/MyComponent.vue')
expect(result).toBeDefined()
expect(result).toContain('import { useHead, useSeoMeta } from "#app/composables/head"')
// Since we're not mocking the AST parsing, we need to rely on the actual behavior
// of the plugin for handling imports from #app/composables/head
})
it('should handle renamed imports correctly', async () => {
const code = `import { useHead as useHeadAlias } from '@unhead/vue'`
const result = await transform(code, '/project/components/MyComponent.vue')
expect(result).toBeDefined()
expect(result).toContain('import { useHead as useHeadAlias } from "#app/composables/head"')
expect(result).not.toContain('import { useHead as useHeadAlias } from "@unhead/vue"')
})
it('should handle multiple imports correctly', async () => {
const code = `import { useHead, useSeoMeta } from '@unhead/vue'`
const result = await transform(code, '/project/components/MyComponent.vue')
expect(result).toBeDefined()
expect(result).toContain('import { useHead, useSeoMeta } from "#app/composables/head"')
expect(result).not.toContain('import { useHead, useSeoMeta } from "@unhead/vue"')
})
})
describe('Integration tests', () => {
it('should handle a Vue component correctly', async () => {
const sfc = `
<template>
<div>
<h1>{{ title }}</h1>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useHead } from '@unhead/vue'
const title = ref('Hello World')
useHead({
title: 'My Page'
})
</script>
`
const res = compileScript(parse(sfc).descriptor, { id: 'component.vue' })
const result = await transform(res.content, '/project/components/MyComponent.vue')
expect(result).toBeDefined()
expect(result).toContain('import { useHead } from "#app/composables/head"')
expect(result).not.toContain('import { useHead } from "@unhead/vue"')
})
it('should handle a Nuxt plugin correctly', async () => {
const code = `
import { useHead } from '@unhead/vue'
export default defineNuxtPlugin((nuxtApp) => {
useHead({
titleTemplate: '%s - My Site'
})
})
`
const result = await transform(code, '/project/plugins/head.ts')
expect(result).toBeDefined()
expect(result).toContain('import { useHead } from "#app/composables/head"')
expect(result).not.toContain('import { useHead } from "@unhead/vue"')
})
it('should handle TypeScript file in a nested directory correctly', async () => {
const code = `
import { useHead } from '@unhead/vue'
export function setupHead() {
useHead({
meta: [
{ name: 'description', content: 'My website description' }
]
})
}
`
const result = await transform(code, '/project/utils/head/setup.ts')
expect(result).toBeDefined()
expect(result).toContain('import { useHead } from "#app/composables/head"')
expect(result).not.toContain('import { useHead } from "@unhead/vue"')
})
it('should handle multiple imports from different sources', async () => {
const code = `
import { useHead, useSeoMeta } from '@unhead/vue'
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
`
const result = await transform(code, '/project/pages/index.vue')
expect(result).toBeDefined()
expect(result).toContain('import { useHead, useSeoMeta } from "#app/composables/head"')
expect(result).not.toContain('@unhead/vue')
})
it('should handle imports across multiple lines', async () => {
const code = `
import {
useHead,
useSeoMeta
} from '@unhead/vue'
`
const result = await transform(code, '/project/components/Header.vue')
expect(result).toBeDefined()
// The actual behavior will depend on how the AST parser handles multi-line imports
// This test will verify the behavior is consistent
})
it('should handle different quote styles in imports', async () => {
const code = `import { useHead } from "@unhead/vue"`
const result = await transform(code, '/project/components/MyComponent.vue')
expect(result).toBeDefined()
expect(result).toContain('import { useHead } from "#app/composables/head"')
expect(result).not.toContain('import { useHead } from "@unhead/vue"')
})
})
})

View File

@ -28,7 +28,7 @@ export default defineBuildConfig({
},
externals: [
// Type imports
'@unhead/schema',
'@unhead/vue',
'@vitejs/plugin-vue',
'chokidar',
'@vitejs/plugin-vue-jsx',

View File

@ -39,7 +39,7 @@
"@types/pug": "2.0.10",
"@types/webpack-bundle-analyzer": "4.7.0",
"@types/webpack-hot-middleware": "2.25.9",
"@unhead/schema": "1.11.20",
"@unhead/vue": "2.0.0-rc.1",
"@vitejs/plugin-vue": "5.2.1",
"@vitejs/plugin-vue-jsx": "4.1.1",
"@vue/compiler-core": "3.5.13",

View File

@ -260,7 +260,7 @@ export default defineResolvers({
/**
* Customize Nuxt root element id.
* @type {typeof import('@unhead/schema').HtmlAttributes}
* @type {typeof import('../src/types/head').SerializableHtmlAttributes}
*/
rootAttrs: {
$resolve: async (val, get) => {
@ -290,7 +290,7 @@ export default defineResolvers({
/**
* Customize Nuxt Teleport element attributes.
* @type {typeof import('@unhead/schema').HtmlAttributes}
* @type {typeof import('../src/types/head').SerializableHtmlAttributes}
*/
teleportAttrs: {
$resolve: async (val, get) => {
@ -311,7 +311,7 @@ export default defineResolvers({
/**
* Customize Nuxt Nuxt SpaLoader element attributes.
* @type {Partial<typeof import('@unhead/schema').HtmlAttributes>}
* @type {typeof import('../src/types/head').SerializableHtmlAttributes}
*/
spaLoaderAttrs: {
id: '__nuxt-loader',
@ -454,11 +454,27 @@ export default defineResolvers({
* An object that allows us to configure the `unhead` nuxt module.
*/
unhead: {
/***
* Enable the legacy compatibility mode for `unhead` module. This applies the following changes:
* - Disables Capo.js sorting
* - Adds the `DeprecationsPlugin`: supports `hid`, `vmid`, `children`, `body`
* - Adds the `PromisesPlugin`: supports promises as input
*
* @see [`unhead` migration documentation](https://unhead-unjs-io.nuxt.dev/docs/migration)
*
* @example
* ```ts
* export default defineNuxtConfig({
* unhead: {
* legacy: true
* })
* ```
* @type {boolean}
*/
legacy: false,
/**
* An object that will be passed to `renderSSRHead` to customize the output.
*
* @see [`unhead` options documentation](https://unhead.unjs.io/setup/ssr/installation#options)
*
* @example
* ```ts
* export default defineNuxtConfig({
@ -468,7 +484,7 @@ export default defineResolvers({
* }
* })
* ```
* @type {typeof import('@unhead/schema').RenderSSRHeadOptions}
* @type {typeof import('@unhead/vue/types').RenderSSRHeadOptions}
*/
renderSSRHeadOptions: {
$resolve: async (val, get) => {

View File

@ -4,7 +4,7 @@ export type { Component, ComponentMeta, ComponentsDir, ComponentsOptions, ScanDi
export type { AppConfig, AppConfigInput, CustomAppConfig, NuxtAppConfig, NuxtBuilder, NuxtConfig, NuxtConfigLayer, NuxtOptions, PublicRuntimeConfig, RuntimeConfig, RuntimeValue, SchemaDefinition, UpperSnakeCase, ViteConfig } from './types/config'
export type { GenerateAppOptions, HookResult, ImportPresetWithDeprecation, NuxtAnalyzeMeta, NuxtHookName, NuxtHooks, NuxtLayout, NuxtMiddleware, NuxtPage, TSReference, VueTSConfig, WatchEvent } from './types/hooks'
export type { ImportsOptions } from './types/imports'
export type { AppHeadMetaObject, MetaObject, MetaObjectRaw, HeadAugmentations } from './types/head'
export type { AppHeadMetaObject, MetaObject, MetaObjectRaw } from './types/head'
export type { ModuleDefinition, ModuleMeta, ModuleOptions, ModuleSetupInstallResult, ModuleSetupReturn, NuxtModule, ResolvedModuleOptions } from './types/module'
export type { Nuxt, NuxtApp, NuxtPlugin, NuxtPluginTemplate, NuxtTemplate, NuxtTypeTemplate, NuxtServerTemplate, ResolvedNuxtTemplate } from './types/nuxt'
export type { RouterConfig, RouterConfigSerializable, RouterOptions } from './types/router'

View File

@ -1,27 +1,6 @@
import type { Head, MergeHead } from '@unhead/schema'
import type { AriaAttributes, DataKeys, GlobalAttributes, SerializableHead } from '@unhead/vue/types'
/** @deprecated Extend types from `@unhead/schema` directly. This may be removed in a future minor version. */
export interface HeadAugmentations extends MergeHead {
// runtime type modifications
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
base?: {}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
link?: {}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
meta?: {}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
style?: {}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
script?: {}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
noscript?: {}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
htmlAttrs?: {}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
bodyAttrs?: {}
}
export type MetaObjectRaw = Head<HeadAugmentations>
export type MetaObjectRaw = SerializableHead
export type MetaObject = MetaObjectRaw
export type AppHeadMetaObject = MetaObjectRaw & {
@ -37,3 +16,5 @@ export type AppHeadMetaObject = MetaObjectRaw & {
*/
viewport?: string
}
export type SerializableHtmlAttributes = GlobalAttributes & AriaAttributes & DataKeys

View File

@ -122,7 +122,7 @@ export async function buildServer (ctx: ViteBuildContext) {
if (Array.isArray(serverConfig.ssr!.external)) {
serverConfig.ssr!.external.push(
// explicit dependencies we use in our ssr renderer - these can be inlined (if necessary) in the nitro build
'unhead', '@unhead/ssr', 'unctx', 'h3', 'devalue', '@nuxt/devalue', 'radix3', 'rou3', 'unstorage', 'hookable',
'unhead', '@unhead/vue', 'unctx', 'h3', 'devalue', '@nuxt/devalue', 'radix3', 'rou3', 'unstorage', 'hookable',
// ensure we only have one version of vue if nitro is going to inline anyway
...((ctx.nuxt as any)._nitro as Nitro).options.inlineDynamicImports ? ['vue', '@vue/server-renderer', '@unhead/vue'] : [],
// dependencies we might share with nitro - these can be inlined (if necessary) in the nitro build

View File

@ -14,11 +14,7 @@ overrides:
'@nuxt/vite-builder': workspace:*
'@nuxt/webpack-builder': workspace:*
'@types/node': 22.13.9
'@unhead/dom': 1.11.20
'@unhead/schema': 1.11.20
'@unhead/shared': 1.11.20
'@unhead/ssr': 1.11.20
'@unhead/vue': 1.11.20
'@unhead/vue': 2.0.0-rc.1
'@vue/compiler-core': 3.5.13
'@vue/compiler-dom': 3.5.13
'@vue/shared': 3.5.13
@ -34,7 +30,6 @@ overrides:
typescript: 5.8.2
ufo: 1.5.4
unbuild: 3.5.0
unhead: 1.11.20
unimport: 4.1.2
vite: 6.2.0
vue: 3.5.13
@ -88,12 +83,9 @@ importers:
'@types/semver':
specifier: 7.5.8
version: 7.5.8
'@unhead/schema':
specifier: 1.11.20
version: 1.11.20
'@unhead/vue':
specifier: 1.11.20
version: 1.11.20(vue@3.5.13(typescript@5.8.2))
specifier: 2.0.0-rc.1
version: 2.0.0-rc.1(vue@3.5.13(typescript@5.8.2))
'@vitest/coverage-v8':
specifier: 3.0.7
version: 3.0.7(vitest@3.0.7(@types/debug@4.1.12)(@types/node@22.13.9)(happy-dom@17.1.9)(jiti@2.4.2)(terser@5.32.0)(tsx@4.19.2)(yaml@2.7.0))
@ -347,18 +339,9 @@ importers:
'@types/node':
specifier: 22.13.9
version: 22.13.9
'@unhead/dom':
specifier: 1.11.20
version: 1.11.20
'@unhead/shared':
specifier: 1.11.20
version: 1.11.20
'@unhead/ssr':
specifier: 1.11.20
version: 1.11.20
'@unhead/vue':
specifier: 1.11.20
version: 1.11.20(vue@3.5.13(typescript@5.8.2))
specifier: 2.0.0-rc.1
version: 2.0.0-rc.1(vue@3.5.13(typescript@5.8.2))
'@vue/shared':
specifier: 3.5.13
version: 3.5.13
@ -494,9 +477,6 @@ importers:
unenv:
specifier: ^1.10.0
version: 1.10.0
unhead:
specifier: 1.11.20
version: 1.11.20
unimport:
specifier: 4.1.2
version: 4.1.2
@ -527,7 +507,7 @@ importers:
devDependencies:
'@nuxt/scripts':
specifier: 0.10.5
version: 0.10.5(@types/google.maps@3.58.1)(@types/vimeo__player@2.18.3)(@types/youtube@0.1.0)(@unhead/vue@1.11.20(vue@3.5.13(typescript@5.8.2)))(typescript@5.8.2)
version: 0.10.5(@types/google.maps@3.58.1)(@types/vimeo__player@2.18.3)(@types/youtube@0.1.0)(@unhead/vue@2.0.0-rc.1(vue@3.5.13(typescript@5.8.2)))(typescript@5.8.2)
'@parcel/watcher':
specifier: 2.5.1
version: 2.5.1
@ -719,9 +699,9 @@ importers:
'@types/webpack-hot-middleware':
specifier: 2.25.9
version: 2.25.9(esbuild@0.25.0)
'@unhead/schema':
specifier: 1.11.20
version: 1.11.20
'@unhead/vue':
specifier: 2.0.0-rc.1
version: 2.0.0-rc.1(vue@3.5.13(typescript@5.8.2))
'@vitejs/plugin-vue':
specifier: 5.2.1
version: 5.2.1(vite@6.2.0(@types/node@22.13.9)(jiti@2.4.2)(terser@5.32.0)(tsx@4.19.2)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.2))
@ -2145,7 +2125,7 @@ packages:
'@types/google.maps': ^3.58.1
'@types/vimeo__player': ^2.18.3
'@types/youtube': ^0.1.0
'@unhead/vue': 1.11.20
'@unhead/vue': 2.0.0-rc.1
peerDependenciesMeta:
'@stripe/stripe-js':
optional: true
@ -2952,20 +2932,8 @@ packages:
'@ungap/structured-clone@1.2.0':
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
'@unhead/dom@1.11.20':
resolution: {integrity: sha512-jgfGYdOH+xHJF/j8gudjsYu3oIjFyXhCWcgKaw3vQnT616gSqyqnGQGOItL+BQtQZACKNISwIfx5PuOtztMKLA==}
'@unhead/schema@1.11.20':
resolution: {integrity: sha512-0zWykKAaJdm+/Y7yi/Yds20PrUK7XabLe9c3IRcjnwYmSWY6z0Cr19VIs3ozCj8P+GhR+/TI2mwtGlueCEYouA==}
'@unhead/shared@1.11.20':
resolution: {integrity: sha512-1MOrBkGgkUXS+sOKz/DBh4U20DNoITlJwpmvSInxEUNhghSNb56S0RnaHRq0iHkhrO/cDgz2zvfdlRpoPLGI3w==}
'@unhead/ssr@1.11.20':
resolution: {integrity: sha512-j6ehzmdWGAvv0TEZyLE3WBnG1ULnsbKQcLqBDh3fvKS6b3xutcVZB7mjvrVE7ckSZt6WwOtG0ED3NJDS7IjzBA==}
'@unhead/vue@1.11.20':
resolution: {integrity: sha512-sqQaLbwqY9TvLEGeq8Fd7+F2TIuV3nZ5ihVISHjWpAM3y7DwNWRU7NmT9+yYT+2/jw1Vjwdkv5/HvDnvCLrgmg==}
'@unhead/vue@2.0.0-rc.1':
resolution: {integrity: sha512-Y9DQ8gwAVXSkVJ76sMZsfdfVmXaWCvz3viJMQN3WnZ1DcWknme5HoFgBEDHyYxIZymP8dHsxWAFk1p+yRZE4Bw==}
peerDependencies:
vue: 3.5.13
@ -5993,9 +5961,6 @@ packages:
package-manager-detector@0.2.9:
resolution: {integrity: sha512-+vYvA/Y31l8Zk8dwxHhL3JfTuHPm6tlxM2A3GeQyl7ovYnSp1+mzAxClxaOr0qO1TtPxbQxetI7v5XqKLJZk7Q==}
packrup@0.1.2:
resolution: {integrity: sha512-ZcKU7zrr5GlonoS9cxxrb5HVswGnyj6jQvwFBa6p5VFw7G71VAHcUKL5wyZSU/ECtPM/9gacWxy2KFQKt1gMNA==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@ -7301,8 +7266,8 @@ packages:
unenv@1.10.0:
resolution: {integrity: sha512-wY5bskBQFL9n3Eca5XnhH6KbUo/tfvkwm9OpcdCvLaeA7piBNbavbOKJySEwQ1V0RH6HvNlSAFRTpvTqgKRQXQ==}
unhead@1.11.20:
resolution: {integrity: sha512-3AsNQC0pjwlLqEYHLjtichGWankK8yqmocReITecmpB1H0aOabeESueyy+8X1gyJx4ftZVwo9hqQ4O3fPWffCA==}
unhead@2.0.0-rc.1:
resolution: {integrity: sha512-jy/rBmC8Q+9EvSpkMYL4gvozSJGe7XTPPcC6NCzh8dUhNxC5eiwtIYdS/gyxvgOnItb9e+B/fHvrFFgLUkwuzQ==}
unicode-emoji-modifier-base@1.0.0:
resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==}
@ -7918,9 +7883,6 @@ packages:
resolution: {integrity: sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==}
engines: {node: '>=18'}
zhead@2.2.4:
resolution: {integrity: sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag==}
zip-stream@6.0.1:
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
engines: {node: '>= 14'}
@ -8835,10 +8797,10 @@ snapshots:
string-width: 4.2.3
webpack: 5.98.0
'@nuxt/scripts@0.10.5(@types/google.maps@3.58.1)(@types/vimeo__player@2.18.3)(@types/youtube@0.1.0)(@unhead/vue@1.11.20(vue@3.5.13(typescript@5.8.2)))(typescript@5.8.2)':
'@nuxt/scripts@0.10.5(@types/google.maps@3.58.1)(@types/vimeo__player@2.18.3)(@types/youtube@0.1.0)(@unhead/vue@2.0.0-rc.1(vue@3.5.13(typescript@5.8.2)))(typescript@5.8.2)':
dependencies:
'@nuxt/kit': link:packages/kit
'@unhead/vue': 1.11.20(vue@3.5.13(typescript@5.8.2))
'@unhead/vue': 2.0.0-rc.1(vue@3.5.13(typescript@5.8.2))
'@vueuse/core': 12.7.0(typescript@5.8.2)
consola: 3.4.0
defu: 6.1.4
@ -9790,32 +9752,10 @@ snapshots:
'@ungap/structured-clone@1.2.0': {}
'@unhead/dom@1.11.20':
dependencies:
'@unhead/schema': 1.11.20
'@unhead/shared': 1.11.20
'@unhead/schema@1.11.20':
'@unhead/vue@2.0.0-rc.1(vue@3.5.13(typescript@5.8.2))':
dependencies:
hookable: 5.5.3
zhead: 2.2.4
'@unhead/shared@1.11.20':
dependencies:
'@unhead/schema': 1.11.20
packrup: 0.1.2
'@unhead/ssr@1.11.20':
dependencies:
'@unhead/schema': 1.11.20
'@unhead/shared': 1.11.20
'@unhead/vue@1.11.20(vue@3.5.13(typescript@5.8.2))':
dependencies:
'@unhead/schema': 1.11.20
'@unhead/shared': 1.11.20
hookable: 5.5.3
unhead: 1.11.20
unhead: 2.0.0-rc.1
vue: 3.5.13(typescript@5.8.2)
'@unocss/astro@66.0.0(vite@6.2.0(@types/node@22.13.9)(jiti@2.4.2)(sass@1.78.0)(terser@5.32.0)(tsx@4.19.2)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.2))':
@ -13530,8 +13470,6 @@ snapshots:
package-manager-detector@0.2.9: {}
packrup@0.1.2: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@ -14943,11 +14881,8 @@ snapshots:
node-fetch-native: 1.6.6
pathe: 1.1.2
unhead@1.11.20:
unhead@2.0.0-rc.1:
dependencies:
'@unhead/dom': 1.11.20
'@unhead/schema': 1.11.20
'@unhead/shared': 1.11.20
hookable: 5.5.3
unicode-emoji-modifier-base@1.0.0: {}
@ -15687,8 +15622,6 @@ snapshots:
yoctocolors@2.1.1: {}
zhead@2.2.4: {}
zip-stream@6.0.1:
dependencies:
archiver-utils: 5.0.2

View File

@ -7,7 +7,6 @@ import { join, normalize } from 'pathe'
import { $fetch as _$fetch, createPage, fetch, isDev, setup, startServer, url, useTestContext } from '@nuxt/test-utils/e2e'
import { $fetchComponent } from '@nuxt/test-utils/experimental'
import { resolveUnrefHeadInput } from '@unhead/vue'
import { expectNoClientErrors, expectWithPolling, gotoPath, isRenderingJson, parseData, parsePayload, renderPage } from './utils'
import type { NuxtIslandResponse } from '#app'
@ -1018,13 +1017,15 @@ describe('head tags', () => {
expect(headHtml).toContain('<meta name="description" content="overriding with an inline useHead call">')
expect(headHtml).toMatch(/<html[^>]*class="html-attrs-test"/)
expect(headHtml).toMatch(/<body[^>]*class="body-attrs-test"/)
expect(headHtml).toContain('<script src="https://a-body-appended-script.com"></script></body>')
const bodyHtml = headHtml.match(/<body[^>]*>(.*)<\/body>/s)![1]
expect(bodyHtml).toContain('<script src="https://a-body-appended-script.com"></script>')
const indexHtml = await $fetch<string>('/')
// should render charset by default
expect(indexHtml).toContain('<meta charset="utf-8">')
// should render <Head> components
expect(indexHtml).toContain('<title>Basic fixture</title>')
expect(indexHtml).toContain('<title>Basic fixture - Fixture</title>')
})
it('SSR script setup should render tags', async () => {
@ -1039,7 +1040,7 @@ describe('head tags', () => {
// useServerHead - shorthands
expect(headHtml).toContain('>/* Custom styles */</style>')
// useHeadSafe - removes dangerous content
expect(headHtml).toContain('<script id="xss-script"></script>')
expect(headHtml).not.toContain('<script id="xss-script">')
expect(headHtml).toContain('<meta content="0;javascript:alert(1)">')
})
@ -2258,6 +2259,7 @@ describe('component islands', () => {
"head": {
"link": [],
"style": [],
"titleTemplate": "%s - Fixture",
},
"html": "<pre data-island-uid> Route: /foo
</pre>",
@ -2280,6 +2282,7 @@ describe('component islands', () => {
"head": {
"link": [],
"style": [],
"titleTemplate": "%s - Fixture",
},
"html": "<div data-island-uid><div> count is above 2 </div><!--[--><div style="display: contents;" data-island-uid data-island-slot="default"><!--teleport start--><!--teleport end--></div><!--]--> that was very long ... <div id="long-async-component-count">3</div> <!--[--><div style="display: contents;" data-island-uid data-island-slot="test"><!--teleport start--><!--teleport end--></div><!--]--><p>hello world !!!</p><!--[--><div style="display: contents;" data-island-uid data-island-slot="hello"><!--teleport start--><!--teleport end--></div><!--teleport start--><!--teleport end--><!--]--><!--[--><div style="display: contents;" data-island-uid data-island-slot="fallback"><!--teleport start--><!--teleport end--></div><!--teleport start--><!--teleport end--><!--]--></div>",
"slots": {
@ -2343,6 +2346,7 @@ describe('component islands', () => {
"head": {
"link": [],
"style": [],
"titleTemplate": "%s - Fixture",
},
"html": "<div data-island-uid> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">2</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--[--><div style="display: contents;" data-island-uid data-island-slot="default"><!--teleport start--><!--teleport end--></div><!--]--></div>",
"props": {},
@ -2374,6 +2378,7 @@ describe('component islands', () => {
"head": {
"link": [],
"style": [],
"titleTemplate": "%s - Fixture",
},
"html": "<div data-island-uid> ServerWithClient.server.vue : <p>count: 0</p> This component should not be preloaded <div><!--[--><div>a</div><div>b</div><div>c</div><!--]--></div> This is not interactive <div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><div class="interactive-component-wrapper" style="border:solid 1px red;"> The component below is not a slot but declared as interactive <!--[--><div style="display: contents;" data-island-uid data-island-component></div><!--teleport start--><!--teleport end--><!--]--></div></div>",
"slots": {},
@ -2405,7 +2410,7 @@ describe('component islands', () => {
for (const key in result.head) {
if (key === 'link') {
result.head[key] = result.head[key]?.map((h) => {
h.href &&= resolveUnrefHeadInput(h.href).replace(fixtureDir, '/<rootDir>').replaceAll('//', '/')
h.href &&= (h.href).replace(fixtureDir, '/<rootDir>').replaceAll('//', '/')
return h
})
}
@ -2422,6 +2427,7 @@ describe('component islands', () => {
"innerHTML": "pre[data-v-xxxxx]{color:#00f}",
},
],
"titleTemplate": "%s - Fixture",
}
`)
} else if (isDev() && !isWebpack) {
@ -2439,6 +2445,7 @@ describe('component islands', () => {
},
],
"style": [],
"titleTemplate": "%s - Fixture",
}
`)
}

View File

@ -23,8 +23,8 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
const [clientStats, clientStatsInlined] = await Promise.all((['.output', '.output-inline'])
.map(outputDir => analyzeSizes(['**/*.js'], join(rootDir, outputDir, 'public'))))
expect.soft(roundToKilobytes(clientStats!.totalBytes)).toMatchInlineSnapshot(`"115k"`)
expect.soft(roundToKilobytes(clientStatsInlined!.totalBytes)).toMatchInlineSnapshot(`"115k"`)
expect.soft(roundToKilobytes(clientStats!.totalBytes)).toMatchInlineSnapshot(`"112k"`)
expect.soft(roundToKilobytes(clientStatsInlined!.totalBytes)).toMatchInlineSnapshot(`"112k"`)
const files = new Set([...clientStats!.files, ...clientStatsInlined!.files].map(f => f.replace(/\..*\.js/, '.js')))
@ -38,7 +38,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
it('default client bundle size (pages)', async () => {
const clientStats = await analyzeSizes(['**/*.js'], join(pagesRootDir, '.output/public'))
expect.soft(roundToKilobytes(clientStats!.totalBytes)).toMatchInlineSnapshot(`"174k"`)
expect.soft(roundToKilobytes(clientStats!.totalBytes)).toMatchInlineSnapshot(`"171k"`)
const files = clientStats!.files.map(f => f.replace(/\..*\.js/, '.js'))
@ -58,10 +58,10 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
const serverDir = join(rootDir, '.output/server')
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"210k"`)
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"208k"`)
const modules = await analyzeSizes(['node_modules/**/*'], serverDir)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"1411k"`)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"1397k"`)
const packages = modules.files
.filter(m => m.endsWith('package.json'))
@ -70,9 +70,6 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
expect(packages).toMatchInlineSnapshot(`
[
"@babel/parser",
"@unhead/dom",
"@unhead/shared",
"@unhead/ssr",
"@vue/compiler-core",
"@vue/compiler-dom",
"@vue/compiler-ssr",
@ -87,7 +84,6 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
"estree-walker",
"hookable",
"node-mock-http",
"packrup",
"source-map-js",
"ufo",
"unhead",
@ -101,10 +97,10 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
const serverDir = join(rootDir, '.output-inline/server')
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"560k"`)
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"558k"`)
const modules = await analyzeSizes(['node_modules/**/*'], serverDir)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"105k"`)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"90.9k"`)
const packages = modules.files
.filter(m => m.endsWith('package.json'))
@ -112,14 +108,10 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
.sort()
expect(packages).toMatchInlineSnapshot(`
[
"@unhead/dom",
"@unhead/shared",
"@unhead/ssr",
"db0",
"devalue",
"hookable",
"node-mock-http",
"packrup",
"unhead",
]
`)
@ -129,10 +121,10 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
const serverDir = join(pagesRootDir, '.output/server')
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"304k"`)
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"303k"`)
const modules = await analyzeSizes(['node_modules/**/*'], serverDir)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"1411k"`)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"1408k"`)
const packages = modules.files
.filter(m => m.endsWith('package.json'))
@ -141,9 +133,6 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
expect(packages).toMatchInlineSnapshot(`
[
"@babel/parser",
"@unhead/dom",
"@unhead/shared",
"@unhead/ssr",
"@vue/compiler-core",
"@vue/compiler-dom",
"@vue/compiler-ssr",
@ -158,7 +147,6 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
"estree-walker",
"hookable",
"node-mock-http",
"packrup",
"source-map-js",
"ufo",
"unhead",

View File

@ -1,4 +1,9 @@
<script lang="ts" setup>
import { TemplateParamsPlugin } from '@unhead/vue/plugins'
// Unhead v2 requires an opt-in to template params
const head = injectHead()
head.use(TemplateParamsPlugin)
const description = ref('head script setup description for %site.name')
const siteName = ref()
// server meta

View File

@ -18,7 +18,7 @@ export default defineNuxtComponent({
script: [
{
src: 'https://a-body-appended-script.com',
body: true,
tagPosition: 'bodyClose',
},
],
meta: [{ name: 'description', content: 'first' }],

View File

@ -76,6 +76,7 @@ describe('composables', () => {
'getAppManifest',
'useHydration',
'getRouteRules',
'injectHead',
'onNuxtReady',
'callOnce',
'setResponseStatus',
@ -114,10 +115,13 @@ describe('composables', () => {
'useId',
'useFetch',
'useHead',
'useHeadSafe',
'useLazyFetch',
'useLazyAsyncData',
'useRouter',
'useSeoMeta',
'useServerHead',
'useServerHeadSafe',
'useServerSeoMeta',
'usePreviewMode',
]

View File

@ -32,6 +32,9 @@
],
"#app/*": [
"./packages/nuxt/src/app/*"
],
"#unhead/composables": [
"./packages/nuxt/src/head/runtime/composables/v4"
]
}
},