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 ## 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. `<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> <Head>
<Title>{{ title }}</Title> <Title>{{ title }}</Title>
<Meta name="description" :content="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> </Head>
<h1>{{ title }}</h1> <h1>{{ title }}</h1>
@ -126,6 +129,8 @@ const title = ref('Hello World')
</template> </template>
``` ```
It's suggested to wrap your components in either a `<Head>` or `<Html>` components as tags will be deduped more intuitively.
## Types ## Types
Below are the non-reactive types used for [`useHead`](/docs/api/composables/use-head), [`app.head`](/docs/api/nuxt-config#head) and components. 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 { defineComponent, inject, onUnmounted, provide, reactive } from 'vue'
import type { PropType, SetupContext } 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 { import type {
CrossOrigin, CrossOrigin,
FetchPriority, FetchPriority,
HTTPEquiv, HTTPEquiv,
LinkRelationship, LinkRelationship,
Props,
ReferrerPolicy, ReferrerPolicy,
Target, Target,
} from './types' } from './types'
import { useHead } from '#app/composables/head' import { useHead } from '#app/composables/head'
const removeUndefinedProps = (props: Props) => { interface HeadComponents {
const filteredProps = Object.create(null) base?: UnheadBase | null
for (const key in props) { bodyAttrs?: BodyAttributes | null
const value = props[key] htmlAttrs?: HtmlAttributes | null
if (value !== undefined) { link?: (UnheadLink | null)[]
filteredProps[key] = value meta?: (UnheadMeta | null)[]
} noscript?: (Noscript | null)[]
} style?: (UnheadStyle | null)[]
return filteredProps 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) => { const normalizeProps = <T extends Record<string, any>>(_props: T): Partial<T> => {
useHead(() => metaFactory({ ...removeUndefinedProps(props), ...ctx.attrs }, ctx)) const props = Object.fromEntries(
return () => renderChild ? ctx.slots.default?.() : null 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 = { const globalProps = {
@ -34,7 +77,7 @@ const globalProps = {
type: Boolean, type: Boolean,
default: undefined, default: undefined,
}, },
class: [String, Object, Array], class: { type: [String, Object, Array], default: undefined },
contenteditable: { contenteditable: {
type: Boolean, type: Boolean,
default: undefined, default: undefined,
@ -67,10 +110,18 @@ const globalProps = {
type: Boolean, type: Boolean,
default: undefined, default: undefined,
}, },
style: [String, Object, Array], style: { type: [String, Object, Array], default: undefined },
tabindex: String, tabindex: String,
title: String, title: String,
translate: 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> // <noscript>
@ -79,32 +130,33 @@ export const NoScript = defineComponent({
inheritAttrs: false, inheritAttrs: false,
props: { props: {
...globalProps, ...globalProps,
...TagPositionProps,
title: String, title: String,
body: Boolean,
renderPriority: [String, Number],
}, },
setup: setupForUseMeta((props, { slots }) => { setup (props, { slots }) {
const noscript = { ...props } const noscript = normalizeProps(props) as Noscript
const slotVnodes = slots.default?.() const slotVnodes = slots.default?.()
const textContent = slotVnodes const textContent = slotVnodes
? slotVnodes.filter(({ children }) => children).map(({ children }) => children).join('') ? slotVnodes.filter(({ children }) => children).map(({ children }) => children).join('')
: '' : ''
if (textContent) { if (textContent) {
noscript.children = textContent noscript.innerHTML = textContent
} }
return { const { input } = useHeadComponentCtx()
noscript: [noscript], input.noscript ||= []
} const idx: keyof typeof input.noscript = input.noscript.push(noscript) - 1
}), onUnmounted(() => input.noscript![idx] = null)
return () => null
},
}) })
// <link> // <link>
export const Link = defineComponent({ export const Link = defineComponent({
name: 'Link', name: 'Link',
inheritAttrs: false, inheritAttrs: false,
props: { props: {
...globalProps, ...globalProps,
...TagPositionProps,
as: String, as: String,
crossorigin: String as PropType<CrossOrigin>, crossorigin: String as PropType<CrossOrigin>,
disabled: Boolean, disabled: Boolean,
@ -128,17 +180,19 @@ export const Link = defineComponent({
methods: String, methods: String,
/** @deprecated **/ /** @deprecated **/
target: String as PropType<Target>, target: String as PropType<Target>,
body: Boolean,
renderPriority: [String, Number],
}, },
setup: setupForUseMeta(link => ({ setup (props) {
link: [link], 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> // <base>
export const Base = defineComponent({ export const Base = defineComponent({
name: 'Base', name: 'Base',
inheritAttrs: false, inheritAttrs: false,
props: { props: {
@ -146,38 +200,34 @@ export const Base = defineComponent({
href: String, href: String,
target: String as PropType<Target>, target: String as PropType<Target>,
}, },
setup: setupForUseMeta(base => ({ setup (props) {
base, const { input } = useHeadComponentCtx()
})), input.base = normalizeProps(props) as UnheadBase
onUnmounted(() => input.base = null)
return () => null
},
}) })
// <title> // <title>
export const Title = defineComponent({ export const Title = defineComponent({
name: 'Title', name: 'Title',
inheritAttrs: false, inheritAttrs: false,
setup: setupForUseMeta((_, { slots }) => { setup (_, { slots }) {
const defaultSlot = slots.default?.()
if (import.meta.dev) { if (import.meta.dev) {
const defaultSlot = slots.default?.()
if (defaultSlot && (defaultSlot.length > 1 || (defaultSlot[0] && typeof defaultSlot[0].children !== 'string'))) { if (defaultSlot && (defaultSlot.length > 1 || (defaultSlot[0] && typeof defaultSlot[0].children !== 'string'))) {
console.error('<Title> can take only one string in its default slot.') console.error('<Title> can take only one string in its default slot.')
} }
return {
title: defaultSlot?.[0]?.children || null,
}
} }
const { input } = useHeadComponentCtx()
return { input.title = defaultSlot?.[0]?.children ? String(defaultSlot?.[0]?.children) : undefined
title: slots.default?.()?.[0]?.children || null, onUnmounted(() => input.title = null)
} return () => null
}), },
}) })
// <meta> // <meta>
export const Meta = defineComponent({ export const Meta = defineComponent({
name: 'Meta', name: 'Meta',
inheritAttrs: false, inheritAttrs: false,
props: { props: {
@ -186,29 +236,29 @@ export const Meta = defineComponent({
content: String, content: String,
httpEquiv: String as PropType<HTTPEquiv>, httpEquiv: String as PropType<HTTPEquiv>,
name: String, name: String,
body: Boolean, property: String,
renderPriority: [String, Number],
}, },
setup: setupForUseMeta((props) => { setup (props) {
const meta = { ...props } const meta = { 'http-equiv': props.httpEquiv, ...normalizeProps(props) } as UnheadMeta
// fix casing for http-equiv // fix casing for http-equiv
if (meta.httpEquiv) { if ('httpEquiv' in meta) {
meta['http-equiv'] = meta.httpEquiv
delete meta.httpEquiv delete meta.httpEquiv
} }
return { const { input } = useHeadComponentCtx()
meta: [meta], input.meta ||= []
} const idx: keyof typeof input.meta = input.meta.push(meta) - 1
}), onUnmounted(() => input.meta![idx] = null)
return () => null
},
}) })
// <style> // <style>
export const Style = defineComponent({ export const Style = defineComponent({
name: 'Style', name: 'Style',
inheritAttrs: false, inheritAttrs: false,
props: { props: {
...globalProps, ...globalProps,
...TagPositionProps,
type: String, type: String,
media: String, media: String,
nonce: String, nonce: String,
@ -218,35 +268,36 @@ export const Style = defineComponent({
type: Boolean, type: Boolean,
default: undefined, default: undefined,
}, },
body: Boolean,
renderPriority: [String, Number],
}, },
setup: setupForUseMeta((props, { slots }) => { setup (props, { slots }) {
const style = { ...props } const style = normalizeProps(props) as UnheadStyle
const textContent = slots.default?.()?.[0]?.children const textContent = slots.default?.()?.[0]?.children
if (textContent) { if (textContent) {
if (import.meta.dev && typeof textContent !== 'string') { if (import.meta.dev && typeof textContent !== 'string') {
console.error('<Style> can only take a string in its default slot.') console.error('<Style> can only take a string in its default slot.')
} }
style.children = textContent style.textContent = textContent
} }
return { const { input } = useHeadComponentCtx()
style: [style], input.style ||= []
} const idx: keyof typeof input.style = input.style.push(style) - 1
}), onUnmounted(() => input.style![idx] = null)
return () => null
},
}) })
// <head> // <head>
export const Head = defineComponent({ export const Head = defineComponent({
name: 'Head', name: 'Head',
inheritAttrs: false, inheritAttrs: false,
setup: (_props, ctx) => () => ctx.slots.default?.(), setup: (_props, ctx) => {
createHeadComponentCtx()
return () => ctx.slots.default?.()
},
}) })
// <html> // <html>
export const Html = defineComponent({ export const Html = defineComponent({
name: 'Html', name: 'Html',
inheritAttrs: false, inheritAttrs: false,
props: { props: {
@ -254,19 +305,24 @@ export const Html = defineComponent({
manifest: String, manifest: String,
version: String, version: String,
xmlns: 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> // <body>
export const Body = defineComponent({ export const Body = defineComponent({
name: 'Body', name: 'Body',
inheritAttrs: false, inheritAttrs: false,
props: { props: globalProps,
...globalProps, setup (_props, ctx) {
renderPriority: [String, Number], 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 () => { it('should render http-equiv correctly', async () => {
const html = await $fetch<string>('/head') const html = await $fetch<string>('/head')
// http-equiv should be rendered kebab case // 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 // TODO: Doesn't adds header in test environment