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