fix(nuxt): consolidate head component context (#31209)

This commit is contained in:
Harlan Wilton 2025-03-05 22:47:58 +11:00 committed by Daniel Roe
parent 36d5d533b8
commit bc0a3948e2
No known key found for this signature in database
GPG Key ID: 3714AB03996F442B
3 changed files with 145 additions and 84 deletions

View File

@ -100,9 +100,10 @@ useSeoMeta({
## Components
Nuxt provides `<Title>`, `<Base>`, `<NoScript>`, `<Style>`, `<Meta>`, `<Link>`, `<Body>`, `<Html>` and `<Head>` components so that you can interact directly with your metadata within your component's template.
While using [`useHead`](/docs/api/composables/use-head) is recommended in all cases, you may have a personal preference for defining your head tags in your template using components.
Because these component names match native HTML elements, they must be capitalized in the template.
Nuxt provides the following components for this purpose: `<Title>`, `<Base>`, `<NoScript>`, `<Style>`, `<Meta>`, `<Link>`, `<Body>`, `<Html>` and `<Head>`. Note
the capitalization of these components ensuring we don't use invalid native HTML tags.
`<Head>` and `<Body>` can accept nested meta tags (for aesthetic reasons) but this does not affect _where_ the nested meta tags are rendered in the final HTML.
@ -118,7 +119,9 @@ const title = ref('Hello World')
<Head>
<Title>{{ title }}</Title>
<Meta name="description" :content="title" />
<Style type="text/css" children="body { background-color: green; }" ></Style>
<Style type="text/css">
body { background-color: green; }
</Style>
</Head>
<h1>{{ title }}</h1>
@ -126,6 +129,8 @@ const title = ref('Hello World')
</template>
```
It's suggested to wrap your components in either a `<Head>` or `<Html>` components as tags will be deduped more intuitively.
## Types
Below are the non-reactive types used for [`useHead`](/docs/api/composables/use-head), [`app.head`](/docs/api/nuxt-config#head) and components.

View File

@ -1,30 +1,73 @@
import { defineComponent } from 'vue'
import type { PropType, SetupContext } from 'vue'
import { defineComponent, inject, onUnmounted, provide, reactive } from 'vue'
import type { PropType } from 'vue'
import type {
BodyAttributes,
HtmlAttributes,
Noscript,
Base as UnheadBase,
Link as UnheadLink,
Meta as UnheadMeta,
Style as UnheadStyle,
} from '@unhead/vue/types'
import type {
CrossOrigin,
FetchPriority,
HTTPEquiv,
LinkRelationship,
Props,
ReferrerPolicy,
Target,
} from './types'
import { useHead } from '#app/composables/head'
const removeUndefinedProps = (props: Props) => {
const filteredProps = Object.create(null)
for (const key in props) {
const value = props[key]
if (value !== undefined) {
filteredProps[key] = value
}
}
return filteredProps
interface HeadComponents {
base?: UnheadBase | null
bodyAttrs?: BodyAttributes | null
htmlAttrs?: HtmlAttributes | null
link?: (UnheadLink | null)[]
meta?: (UnheadMeta | null)[]
noscript?: (Noscript | null)[]
style?: (UnheadStyle | null)[]
title?: string | null
}
type HeadComponentCtx = { input: HeadComponents, entry: ReturnType<typeof useHead> }
const HeadComponentCtxSymbol = Symbol('head-component')
const TagPositionProps = {
/**
* @deprecated Use tagPosition
*/
body: { type: Boolean, default: undefined },
tagPosition: { type: String as PropType<UnheadStyle['tagPosition']> },
}
const setupForUseMeta = (metaFactory: (props: Props, ctx: SetupContext) => Record<string, any>, renderChild?: boolean) => (props: Props, ctx: SetupContext) => {
useHead(() => metaFactory({ ...removeUndefinedProps(props), ...ctx.attrs }, ctx))
return () => renderChild ? ctx.slots.default?.() : null
const normalizeProps = <T extends Record<string, any>>(_props: T): Partial<T> => {
const props = Object.fromEntries(
Object.entries(_props).filter(([_, value]) => value !== undefined),
) as Partial<T> & { tagPosition?: UnheadStyle['tagPosition'], tagPriority: UnheadStyle['tagPriority'] }
if (typeof props.body !== 'undefined') {
props.tagPosition = props.body ? 'bodyClose' : 'head'
}
if (typeof props.renderPriority !== 'undefined') {
props.tagPriority = props.renderPriority
}
return props
}
function useHeadComponentCtx (): HeadComponentCtx {
return inject<HeadComponentCtx>(HeadComponentCtxSymbol, createHeadComponentCtx, true)
}
function createHeadComponentCtx (): HeadComponentCtx {
// avoid creating multiple contexts
const prev = inject<HeadComponentCtx | null>(HeadComponentCtxSymbol, null)
if (prev) {
return prev
}
const input = reactive({})
const entry = useHead(input)
const ctx: HeadComponentCtx = { input, entry }
provide(HeadComponentCtxSymbol, ctx)
return ctx
}
const globalProps = {
@ -34,7 +77,7 @@ const globalProps = {
type: Boolean,
default: undefined,
},
class: [String, Object, Array],
class: { type: [String, Object, Array], default: undefined },
contenteditable: {
type: Boolean,
default: undefined,
@ -67,10 +110,18 @@ const globalProps = {
type: Boolean,
default: undefined,
},
style: [String, Object, Array],
style: { type: [String, Object, Array], default: undefined },
tabindex: String,
title: String,
translate: String,
/**
* @deprecated Use tagPriority
*/
renderPriority: [String, Number],
/**
* Unhead prop to modify the priority of the tag.
*/
tagPriority: { type: [String, Number] as PropType<UnheadStyle['tagPriority']> },
}
// <noscript>
@ -79,32 +130,33 @@ export const NoScript = defineComponent({
inheritAttrs: false,
props: {
...globalProps,
...TagPositionProps,
title: String,
body: Boolean,
renderPriority: [String, Number],
},
setup: setupForUseMeta((props, { slots }) => {
const noscript = { ...props }
setup (props, { slots }) {
const noscript = normalizeProps(props) as Noscript
const slotVnodes = slots.default?.()
const textContent = slotVnodes
? slotVnodes.filter(({ children }) => children).map(({ children }) => children).join('')
: ''
if (textContent) {
noscript.children = textContent
noscript.innerHTML = textContent
}
return {
noscript: [noscript],
}
}),
const { input } = useHeadComponentCtx()
input.noscript ||= []
const idx: keyof typeof input.noscript = input.noscript.push(noscript) - 1
onUnmounted(() => input.noscript![idx] = null)
return () => null
},
})
// <link>
export const Link = defineComponent({
name: 'Link',
inheritAttrs: false,
props: {
...globalProps,
...TagPositionProps,
as: String,
crossorigin: String as PropType<CrossOrigin>,
disabled: Boolean,
@ -128,17 +180,19 @@ export const Link = defineComponent({
methods: String,
/** @deprecated **/
target: String as PropType<Target>,
body: Boolean,
renderPriority: [String, Number],
},
setup: setupForUseMeta(link => ({
link: [link],
})),
setup (props) {
const link = normalizeProps(props) as UnheadLink
const { input } = useHeadComponentCtx()
input.link ||= []
const idx: keyof typeof input.link = input.link.push(link) - 1
onUnmounted(() => input.link![idx] = null)
return () => null
},
})
// <base>
export const Base = defineComponent({
name: 'Base',
inheritAttrs: false,
props: {
@ -146,38 +200,34 @@ export const Base = defineComponent({
href: String,
target: String as PropType<Target>,
},
setup: setupForUseMeta(base => ({
base,
})),
setup (props) {
const { input } = useHeadComponentCtx()
input.base = normalizeProps(props) as UnheadBase
onUnmounted(() => input.base = null)
return () => null
},
})
// <title>
export const Title = defineComponent({
name: 'Title',
inheritAttrs: false,
setup: setupForUseMeta((_, { slots }) => {
setup (_, { slots }) {
const defaultSlot = slots.default?.()
if (import.meta.dev) {
const defaultSlot = slots.default?.()
if (defaultSlot && (defaultSlot.length > 1 || (defaultSlot[0] && typeof defaultSlot[0].children !== 'string'))) {
console.error('<Title> can take only one string in its default slot.')
}
return {
title: defaultSlot?.[0]?.children || null,
}
}
return {
title: slots.default?.()?.[0]?.children || null,
}
}),
const { input } = useHeadComponentCtx()
input.title = defaultSlot?.[0]?.children ? String(defaultSlot?.[0]?.children) : undefined
onUnmounted(() => input.title = null)
return () => null
},
})
// <meta>
export const Meta = defineComponent({
name: 'Meta',
inheritAttrs: false,
props: {
@ -186,29 +236,29 @@ export const Meta = defineComponent({
content: String,
httpEquiv: String as PropType<HTTPEquiv>,
name: String,
body: Boolean,
renderPriority: [String, Number],
property: String,
},
setup: setupForUseMeta((props) => {
const meta = { ...props }
setup (props) {
const meta = { 'http-equiv': props.httpEquiv, ...normalizeProps(props) } as UnheadMeta
// fix casing for http-equiv
if (meta.httpEquiv) {
meta['http-equiv'] = meta.httpEquiv
if ('httpEquiv' in meta) {
delete meta.httpEquiv
}
return {
meta: [meta],
}
}),
const { input } = useHeadComponentCtx()
input.meta ||= []
const idx: keyof typeof input.meta = input.meta.push(meta) - 1
onUnmounted(() => input.meta![idx] = null)
return () => null
},
})
// <style>
export const Style = defineComponent({
name: 'Style',
inheritAttrs: false,
props: {
...globalProps,
...TagPositionProps,
type: String,
media: String,
nonce: String,
@ -218,35 +268,36 @@ export const Style = defineComponent({
type: Boolean,
default: undefined,
},
body: Boolean,
renderPriority: [String, Number],
},
setup: setupForUseMeta((props, { slots }) => {
const style = { ...props }
setup (props, { slots }) {
const style = normalizeProps(props) as UnheadStyle
const textContent = slots.default?.()?.[0]?.children
if (textContent) {
if (import.meta.dev && typeof textContent !== 'string') {
console.error('<Style> can only take a string in its default slot.')
}
style.children = textContent
style.textContent = textContent
}
return {
style: [style],
}
}),
const { input } = useHeadComponentCtx()
input.style ||= []
const idx: keyof typeof input.style = input.style.push(style) - 1
onUnmounted(() => input.style![idx] = null)
return () => null
},
})
// <head>
export const Head = defineComponent({
name: 'Head',
inheritAttrs: false,
setup: (_props, ctx) => () => ctx.slots.default?.(),
setup: (_props, ctx) => {
createHeadComponentCtx()
return () => ctx.slots.default?.()
},
})
// <html>
export const Html = defineComponent({
name: 'Html',
inheritAttrs: false,
props: {
@ -254,19 +305,24 @@ export const Html = defineComponent({
manifest: String,
version: String,
xmlns: String,
renderPriority: [String, Number],
},
setup: setupForUseMeta(htmlAttrs => ({ htmlAttrs }), true),
setup (_props, ctx) {
const { input } = useHeadComponentCtx()
input.htmlAttrs = { ..._props } as HtmlAttributes
onUnmounted(() => input.htmlAttrs = null)
return () => ctx.slots.default?.()
},
})
// <body>
export const Body = defineComponent({
name: 'Body',
inheritAttrs: false,
props: {
...globalProps,
renderPriority: [String, Number],
props: globalProps,
setup (_props, ctx) {
const { input } = useHeadComponentCtx()
input.bodyAttrs = { ..._props } as BodyAttributes
onUnmounted(() => input.bodyAttrs = null)
return () => ctx.slots.default?.()
},
setup: setupForUseMeta(bodyAttrs => ({ bodyAttrs }), true),
})

View File

@ -1058,7 +1058,7 @@ describe('head tags', () => {
it('should render http-equiv correctly', async () => {
const html = await $fetch<string>('/head')
// http-equiv should be rendered kebab case
expect(html).toContain('<meta content="default-src https" http-equiv="content-security-policy">')
expect(html).toContain('<meta http-equiv="content-security-policy" content="default-src https">')
})
// TODO: Doesn't adds header in test environment