mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-22 13:45:18 +00:00
feat(nuxt): add useHeadSafe
and remove layer around head imports (#19548)
This commit is contained in:
parent
3f9a05601c
commit
c91e4d7933
31
docs/3.api/1.composables/use-head-safe.md
Normal file
31
docs/3.api/1.composables/use-head-safe.md
Normal file
@ -0,0 +1,31 @@
|
||||
---
|
||||
description: The recommended way to provide head data with user input.
|
||||
---
|
||||
|
||||
# `useHeadSafe`
|
||||
|
||||
The useHeadSafe composable is a wrapper around the [useHead](/docs/api/composables/use-head) composable that restricts the input to only allow safe values.
|
||||
|
||||
::ReadMore{link="https://unhead.harlanzw.com/guide/composables/use-head-safe"}
|
||||
::
|
||||
|
||||
## Type
|
||||
|
||||
```ts
|
||||
useHeadSafe(input: MaybeComputedRef<HeadSafe>): void
|
||||
```
|
||||
|
||||
The whitelist of safe 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'],
|
||||
}
|
||||
```
|
||||
|
||||
See [@unhead/schema](https://github.com/unjs/unhead/blob/main/packages/schema/src/safeSchema.ts) for more detailed types.
|
@ -2,11 +2,12 @@ import { defineComponent, createStaticVNode, computed, ref, watch } from 'vue'
|
||||
import { debounce } from 'perfect-debounce'
|
||||
import { hash } from 'ohash'
|
||||
import { appendHeader } from 'h3'
|
||||
import { useHead } from '@unhead/vue'
|
||||
|
||||
// eslint-disable-next-line import/no-restricted-paths
|
||||
import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer'
|
||||
import { useNuxtApp } from '#app/nuxt'
|
||||
import { useRequestEvent } from '#app/composables/ssr'
|
||||
import { useHead } from '#app/composables/head'
|
||||
|
||||
const pKey = '_islandPromises'
|
||||
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { getCurrentInstance, reactive, toRefs } from 'vue'
|
||||
import type { DefineComponent, defineComponent } from 'vue'
|
||||
import { useHead } from '@unhead/vue'
|
||||
import type { NuxtApp } from '../nuxt'
|
||||
import { useNuxtApp } from '../nuxt'
|
||||
import { useAsyncData } from './asyncData'
|
||||
import { useRoute } from './router'
|
||||
import { useHead } from './head'
|
||||
|
||||
export const NuxtComponentIndicator = '__nuxt_component'
|
||||
|
||||
|
@ -1,4 +0,0 @@
|
||||
// eslint-disable-next-line import/no-restricted-paths
|
||||
export type { MetaObject } from '#head'
|
||||
// eslint-disable-next-line import/no-restricted-paths
|
||||
export { useHead, useSeoMeta, useServerSeoMeta } from '#head'
|
@ -1,3 +1,17 @@
|
||||
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 } from './asyncData'
|
||||
@ -15,7 +29,5 @@ export { abortNavigation, addRouteMiddleware, defineNuxtRouteMiddleware, onBefor
|
||||
export type { AddRouteMiddlewareOptions, RouteMiddleware } from './router'
|
||||
export { preloadComponents, prefetchComponents, preloadRouteComponents } from './preload'
|
||||
export { isPrerendered, loadPayload, preloadPayload } from './payload'
|
||||
export type { MetaObject } from './head'
|
||||
export { useHead, useSeoMeta, useServerSeoMeta } from './head'
|
||||
export type { ReloadNuxtAppOptions } from './chunk'
|
||||
export { reloadNuxtApp } from './chunk'
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { joinURL, hasProtocol } from 'ufo'
|
||||
import { useHead } from '@unhead/vue'
|
||||
import { useNuxtApp, useRuntimeConfig } from '../nuxt'
|
||||
import { useHead } from './head'
|
||||
|
||||
interface LoadPayloadOptions {
|
||||
fresh?: boolean
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { parseURL } from 'ufo'
|
||||
import { useHead } from '@unhead/vue'
|
||||
import { defineNuxtPlugin } from '#app/nuxt'
|
||||
import { useHead } from '#app/composables/head'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
const externalURLs = ref(new Set<string>())
|
||||
|
@ -3,11 +3,11 @@ import { debounce } from 'perfect-debounce'
|
||||
import { hash } from 'ohash'
|
||||
import { appendHeader } from 'h3'
|
||||
|
||||
import { useHead } from '@unhead/vue'
|
||||
import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer'
|
||||
import { useNuxtApp } from '#app/nuxt'
|
||||
import { useRequestEvent } from '#app/composables/ssr'
|
||||
import { useAsyncData } from '#app/composables/asyncData'
|
||||
import { useHead } from '#app/composables/head'
|
||||
|
||||
const pKey = '_islandPromises'
|
||||
|
||||
|
@ -1,21 +1,26 @@
|
||||
import { resolve } from 'pathe'
|
||||
import { addComponent, addPlugin, defineNuxtModule, tryResolveModule } from '@nuxt/kit'
|
||||
import { addComponent, addImportsSources, addPlugin, defineNuxtModule, tryResolveModule } from '@nuxt/kit'
|
||||
import { distDir } from '../dirs'
|
||||
|
||||
const components = ['NoScript', 'Link', 'Base', 'Title', 'Meta', 'Style', 'Head', 'Html', 'Body']
|
||||
|
||||
export default defineNuxtModule({
|
||||
meta: {
|
||||
name: 'meta'
|
||||
name: 'head'
|
||||
},
|
||||
setup (options, nuxt) {
|
||||
const runtimeDir = nuxt.options.alias['#head'] || resolve(distDir, 'head/runtime')
|
||||
const runtimeDir = resolve(distDir, 'head/runtime')
|
||||
|
||||
// Transpile @unhead/vue
|
||||
nuxt.options.build.transpile.push('@unhead/vue')
|
||||
|
||||
// Add #head alias
|
||||
nuxt.options.alias['#head'] = runtimeDir
|
||||
// TODO: remove alias in v3.4
|
||||
nuxt.options.alias['#head'] = nuxt.options.alias['#app']
|
||||
nuxt.hook('prepare:types', ({ tsConfig }) => {
|
||||
tsConfig.compilerOptions = tsConfig.compilerOptions || {}
|
||||
delete tsConfig.compilerOptions.paths['#head']
|
||||
delete tsConfig.compilerOptions.paths['#head/*']
|
||||
})
|
||||
|
||||
// Register components
|
||||
const componentsPath = resolve(runtimeDir, 'components')
|
||||
@ -30,14 +35,29 @@ export default defineNuxtModule({
|
||||
kebabName: componentName
|
||||
})
|
||||
}
|
||||
|
||||
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'
|
||||
]
|
||||
})
|
||||
|
||||
// Opt-out feature allowing dependencies using @vueuse/head to work
|
||||
if (nuxt.options.experimental.polyfillVueUseHead) {
|
||||
// backwards compatibility
|
||||
nuxt.options.alias['@vueuse/head'] = tryResolveModule('@unhead/vue') || '@unhead/vue'
|
||||
addPlugin({ src: resolve(runtimeDir, 'lib/vueuse-head-polyfill.plugin') })
|
||||
addPlugin({ src: resolve(runtimeDir, 'plugins/vueuse-head-polyfill') })
|
||||
}
|
||||
|
||||
// Add library-specific plugin
|
||||
addPlugin({ src: resolve(runtimeDir, 'lib/unhead.plugin') })
|
||||
addPlugin({ src: resolve(runtimeDir, 'plugins/unhead') })
|
||||
}
|
||||
})
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { defineComponent } from 'vue'
|
||||
import type { PropType, SetupContext } from 'vue'
|
||||
import { useHead } from './composables'
|
||||
import { useHead } from '@unhead/vue'
|
||||
import type {
|
||||
CrossOrigin,
|
||||
FetchPriority,
|
||||
|
@ -1,39 +0,0 @@
|
||||
import type { HeadEntryOptions, UseHeadInput, ActiveHeadEntry } from '@unhead/vue'
|
||||
import { useSeoMeta as _useSeoMeta } from '@unhead/vue'
|
||||
import type { HeadAugmentations } from 'nuxt/schema'
|
||||
import { useNuxtApp } from '#app/nuxt'
|
||||
|
||||
/**
|
||||
* You can pass in a meta object, which has keys corresponding to meta tags:
|
||||
* `title`, `base`, `script`, `style`, `meta` and `link`, as well as `htmlAttrs` and `bodyAttrs`.
|
||||
*
|
||||
* Alternatively, for reactive meta state, you can pass in a function
|
||||
* that returns a meta object.
|
||||
*/
|
||||
export function useHead<T extends HeadAugmentations> (input: UseHeadInput<T>, options?: HeadEntryOptions): ActiveHeadEntry<UseHeadInput<T>> | void {
|
||||
return useNuxtApp()._useHead(input, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `useSeoMeta` composable lets you define your site's SEO meta tags
|
||||
* as a flat object with full TypeScript support.
|
||||
*
|
||||
* This helps you avoid typos and common mistakes, such as using `name`
|
||||
* instead of `property`.
|
||||
*
|
||||
* It is advised to use `useServerSeoMeta` unless you _need_ client-side
|
||||
* rendering of your SEO meta tags.
|
||||
*/
|
||||
export const useSeoMeta: typeof _useSeoMeta = (meta) => {
|
||||
return _useSeoMeta(meta)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `useServerSeoMeta` composable is identical to `useSeoMeta` except that
|
||||
* it will have no effect (and will return nothing) if called on the client.
|
||||
*/
|
||||
export const useServerSeoMeta: typeof _useSeoMeta = (meta) => {
|
||||
if (process.server) {
|
||||
return _useSeoMeta(meta)
|
||||
}
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
import type { UseHeadInput } from '@unhead/vue'
|
||||
import type { HeadAugmentations } from 'nuxt/schema'
|
||||
|
||||
export * from './composables'
|
||||
|
||||
export type MetaObject = UseHeadInput<HeadAugmentations>
|
@ -1,4 +1,4 @@
|
||||
import { createHead, useHead } from '@unhead/vue'
|
||||
import { createHead } from '@unhead/vue'
|
||||
import { renderSSRHead } from '@unhead/ssr'
|
||||
import { defineNuxtPlugin } from '#app/nuxt'
|
||||
// @ts-expect-error untyped
|
||||
@ -25,9 +25,6 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.hooks.hook('app:mounted', unpauseDom)
|
||||
}
|
||||
|
||||
// support backwards compatibility, remove at some point
|
||||
nuxtApp._useHead = useHead
|
||||
|
||||
if (process.server) {
|
||||
nuxtApp.ssrContext!.renderMeta = async () => {
|
||||
const meta = await renderSSRHead(head)
|
@ -15,9 +15,6 @@ const commonPresets: InlinePreset[] = [
|
||||
const appPreset = defineUnimportPreset({
|
||||
from: '#app',
|
||||
imports: [
|
||||
'useHead',
|
||||
'useSeoMeta',
|
||||
'useServerSeoMeta',
|
||||
'useAsyncData',
|
||||
'useLazyAsyncData',
|
||||
'useNuxtData',
|
||||
|
@ -54,7 +54,7 @@ describe('imports:transform', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const excludedNuxtHelpers = ['useHydration']
|
||||
const excludedNuxtHelpers = ['useHydration', 'useHead', 'useSeoMeta', 'useServerSeoMeta']
|
||||
|
||||
describe('imports:nuxt', () => {
|
||||
try {
|
||||
@ -62,7 +62,8 @@ describe('imports:nuxt', () => {
|
||||
const entrypointContents = readFileSync(join(__dirname, '../src/app/composables/index.ts'), 'utf8')
|
||||
|
||||
const names = findExports(entrypointContents).flatMap(i => i.names || i.name)
|
||||
for (const name of names) {
|
||||
for (let name of names) {
|
||||
name = name.replace(/\/\*.*\*\//, '').trim()
|
||||
if (excludedNuxtHelpers.includes(name)) {
|
||||
continue
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { defineUntypedSchema } from 'untyped'
|
||||
import { defu } from 'defu'
|
||||
import type { AppHeadMetaObject } from '../types/meta'
|
||||
import type { AppHeadMetaObject } from '../types/head'
|
||||
|
||||
export default defineUntypedSchema({
|
||||
/**
|
||||
|
@ -7,7 +7,7 @@ export * from './types/components'
|
||||
export * from './types/config'
|
||||
export * from './types/hooks'
|
||||
export * from './types/imports'
|
||||
export * from './types/meta'
|
||||
export * from './types/head'
|
||||
export * from './types/module'
|
||||
export * from './types/nuxt'
|
||||
export * from './types/router'
|
||||
|
@ -3,7 +3,7 @@ import type { ConfigSchema } from '../../schema/config'
|
||||
import type { ServerOptions as ViteServerOptions, UserConfig as ViteUserConfig } from 'vite'
|
||||
import type { Options as VuePluginOptions } from '@vitejs/plugin-vue'
|
||||
import type { Options as VueJsxPluginOptions } from '@vitejs/plugin-vue-jsx'
|
||||
import type { AppHeadMetaObject } from './meta'
|
||||
import type { AppHeadMetaObject } from './head'
|
||||
import type { Nuxt } from './nuxt'
|
||||
import type { SchemaDefinition } from 'untyped'
|
||||
export type { SchemaDefinition } from 'untyped'
|
||||
|
@ -1,5 +1,6 @@
|
||||
import type { Head, MergeHead } from '@unhead/schema'
|
||||
|
||||
/** @deprecated Extend types from `@unhead/schema` directly. This may be removed in a future minor version. */
|
||||
export interface HeadAugmentations extends MergeHead {
|
||||
// runtime type modifications
|
||||
base?: {}
|
@ -416,6 +416,22 @@ describe('head tags', () => {
|
||||
expect(indexHtml).toContain('<title>Basic fixture</title>')
|
||||
})
|
||||
|
||||
it('SSR script setup should render tags', async () => {
|
||||
const headHtml = await $fetch('/head-script-setup')
|
||||
|
||||
// useHead - title & titleTemplate are working
|
||||
expect(headHtml).toContain('<title>head script setup - Nuxt Playground</title>')
|
||||
// useSeoMeta - template params
|
||||
expect(headHtml).toContain('<meta property="og:title" content="head script setup - Nuxt Playground">')
|
||||
// useSeoMeta - refs
|
||||
expect(headHtml).toContain('<meta name="description" content="head script setup description for Nuxt Playground">')
|
||||
// useServerHead - shorthands
|
||||
expect(headHtml).toContain('>/* Custom styles */</style>')
|
||||
// useHeadSafe - removes dangerous content
|
||||
expect(headHtml).toContain('<script id="xss-script"></script>')
|
||||
expect(headHtml).toContain('<meta content="0;javascript:alert(1)">')
|
||||
})
|
||||
|
||||
it('SPA should render appHead tags', async () => {
|
||||
const headHtml = await $fetch('/head', { headers: { 'x-nuxt-no-ssr': '1' } })
|
||||
|
||||
|
@ -26,7 +26,7 @@ describe.skipIf(isWindows)('minimal nuxt application', () => {
|
||||
|
||||
it('default client bundle size', async () => {
|
||||
stats.client = await analyzeSizes('**/*.js', publicDir)
|
||||
expect(stats.client.totalBytes).toBeLessThan(106200)
|
||||
expect(stats.client.totalBytes).toBeLessThan(105700)
|
||||
expect(stats.client.files.map(f => f.replace(/\..*\.js/, '.js'))).toMatchInlineSnapshot(`
|
||||
[
|
||||
"_nuxt/_plugin-vue_export-helper.js",
|
||||
@ -40,7 +40,7 @@ describe.skipIf(isWindows)('minimal nuxt application', () => {
|
||||
|
||||
it('default server bundle size', async () => {
|
||||
stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
|
||||
expect(stats.server.totalBytes).toBeLessThan(94450)
|
||||
expect(stats.server.totalBytes).toBeLessThan(94000)
|
||||
|
||||
const modules = await analyzeSizes('node_modules/**/*', serverDir)
|
||||
expect(modules.totalBytes).toBeLessThan(2713000)
|
||||
|
57
test/fixtures/basic/pages/head-script-setup.vue
vendored
Normal file
57
test/fixtures/basic/pages/head-script-setup.vue
vendored
Normal file
@ -0,0 +1,57 @@
|
||||
<script lang="ts" setup>
|
||||
const description = ref('head script setup description for %site.name')
|
||||
const siteName = ref()
|
||||
// server meta
|
||||
useServerSeoMeta({
|
||||
description,
|
||||
ogDescription: description,
|
||||
ogImage: '%site.url/og-image.png',
|
||||
ogTitle: '%s %separator %site.name',
|
||||
ogType: 'website',
|
||||
ogUrl: '%site.url/head-script-setup'
|
||||
})
|
||||
|
||||
useServerHead({
|
||||
style: [
|
||||
'/* Custom styles */',
|
||||
'h1 { color: salmon; }'
|
||||
]
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'head script setup',
|
||||
titleTemplate: '%s %separator %site.name',
|
||||
templateParams: {
|
||||
separator: () => '-',
|
||||
site: {
|
||||
url: 'https://example.com',
|
||||
name: siteName
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
useHeadSafe({
|
||||
script: [
|
||||
{
|
||||
id: 'xss-script',
|
||||
// @ts-expect-error not allowed
|
||||
innerHTML: 'alert("xss")'
|
||||
}
|
||||
],
|
||||
meta: [
|
||||
{
|
||||
// @ts-expect-error not allowed
|
||||
'http-equiv': 'refresh',
|
||||
content: '0;javascript:alert(1)'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
siteName.value = 'Nuxt Playground'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1>head script setup</h1>
|
||||
</div>
|
||||
</template>
|
3
test/fixtures/basic/plugins/my-plugin.ts
vendored
3
test/fixtures/basic/plugins/my-plugin.ts
vendored
@ -1,3 +1,6 @@
|
||||
// @ts-expect-error
|
||||
import { useHead } from '#head'
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
useHead({
|
||||
titleTemplate: '%s - Fixture'
|
||||
|
@ -22,9 +22,6 @@
|
||||
"#app/*": [
|
||||
"./packages/nuxt/src/app/*"
|
||||
],
|
||||
"#head": [
|
||||
"./packages/nuxt/src/head/runtime/index"
|
||||
],
|
||||
"#internal/nitro": [
|
||||
"./node_modules/nitropack/dist/runtime"
|
||||
],
|
||||
|
Loading…
Reference in New Issue
Block a user