mirror of
https://github.com/nuxt/nuxt.git
synced 2025-03-20 00:11:18 +00:00
fix(nuxt): consolidate head component context (#31209)
This commit is contained in:
parent
36d5d533b8
commit
bc0a3948e2
@ -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.
|
||||||
|
@ -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),
|
|
||||||
})
|
})
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user