feat(nuxt): add useHeadSafe and remove layer around head imports (#19548)

This commit is contained in:
Harlan Wilton 2023-03-10 19:01:21 +11:00 committed by GitHub
parent 3f9a05601c
commit c91e4d7933
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 165 additions and 81 deletions

View 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.

View File

@ -2,11 +2,12 @@ import { defineComponent, createStaticVNode, computed, ref, watch } from 'vue'
import { debounce } from 'perfect-debounce' import { debounce } from 'perfect-debounce'
import { hash } from 'ohash' import { hash } from 'ohash'
import { appendHeader } from 'h3' import { appendHeader } from 'h3'
import { useHead } from '@unhead/vue'
// eslint-disable-next-line import/no-restricted-paths // eslint-disable-next-line import/no-restricted-paths
import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer' import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer'
import { useNuxtApp } from '#app/nuxt' import { useNuxtApp } from '#app/nuxt'
import { useRequestEvent } from '#app/composables/ssr' import { useRequestEvent } from '#app/composables/ssr'
import { useHead } from '#app/composables/head'
const pKey = '_islandPromises' const pKey = '_islandPromises'

View File

@ -1,10 +1,10 @@
import { getCurrentInstance, reactive, toRefs } from 'vue' import { getCurrentInstance, reactive, toRefs } from 'vue'
import type { DefineComponent, defineComponent } from 'vue' import type { DefineComponent, defineComponent } from 'vue'
import { useHead } from '@unhead/vue'
import type { NuxtApp } from '../nuxt' import type { NuxtApp } from '../nuxt'
import { useNuxtApp } from '../nuxt' import { useNuxtApp } from '../nuxt'
import { useAsyncData } from './asyncData' import { useAsyncData } from './asyncData'
import { useRoute } from './router' import { useRoute } from './router'
import { useHead } from './head'
export const NuxtComponentIndicator = '__nuxt_component' export const NuxtComponentIndicator = '__nuxt_component'

View File

@ -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'

View File

@ -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 { defineNuxtComponent } from './component'
export { useAsyncData, useLazyAsyncData, useNuxtData, refreshNuxtData, clearNuxtData } from './asyncData' export { useAsyncData, useLazyAsyncData, useNuxtData, refreshNuxtData, clearNuxtData } from './asyncData'
export type { AsyncDataOptions, AsyncData } from './asyncData' export type { AsyncDataOptions, AsyncData } from './asyncData'
@ -15,7 +29,5 @@ export { abortNavigation, addRouteMiddleware, defineNuxtRouteMiddleware, onBefor
export type { AddRouteMiddlewareOptions, RouteMiddleware } from './router' export type { AddRouteMiddlewareOptions, RouteMiddleware } from './router'
export { preloadComponents, prefetchComponents, preloadRouteComponents } from './preload' export { preloadComponents, prefetchComponents, preloadRouteComponents } from './preload'
export { isPrerendered, loadPayload, preloadPayload } from './payload' export { isPrerendered, loadPayload, preloadPayload } from './payload'
export type { MetaObject } from './head'
export { useHead, useSeoMeta, useServerSeoMeta } from './head'
export type { ReloadNuxtAppOptions } from './chunk' export type { ReloadNuxtAppOptions } from './chunk'
export { reloadNuxtApp } from './chunk' export { reloadNuxtApp } from './chunk'

View File

@ -1,6 +1,6 @@
import { joinURL, hasProtocol } from 'ufo' import { joinURL, hasProtocol } from 'ufo'
import { useHead } from '@unhead/vue'
import { useNuxtApp, useRuntimeConfig } from '../nuxt' import { useNuxtApp, useRuntimeConfig } from '../nuxt'
import { useHead } from './head'
interface LoadPayloadOptions { interface LoadPayloadOptions {
fresh?: boolean fresh?: boolean

View File

@ -1,7 +1,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { parseURL } from 'ufo' import { parseURL } from 'ufo'
import { useHead } from '@unhead/vue'
import { defineNuxtPlugin } from '#app/nuxt' import { defineNuxtPlugin } from '#app/nuxt'
import { useHead } from '#app/composables/head'
export default defineNuxtPlugin((nuxtApp) => { export default defineNuxtPlugin((nuxtApp) => {
const externalURLs = ref(new Set<string>()) const externalURLs = ref(new Set<string>())

View File

@ -3,11 +3,11 @@ import { debounce } from 'perfect-debounce'
import { hash } from 'ohash' import { hash } from 'ohash'
import { appendHeader } from 'h3' import { appendHeader } from 'h3'
import { useHead } from '@unhead/vue'
import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer' import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer'
import { useNuxtApp } from '#app/nuxt' import { useNuxtApp } from '#app/nuxt'
import { useRequestEvent } from '#app/composables/ssr' import { useRequestEvent } from '#app/composables/ssr'
import { useAsyncData } from '#app/composables/asyncData' import { useAsyncData } from '#app/composables/asyncData'
import { useHead } from '#app/composables/head'
const pKey = '_islandPromises' const pKey = '_islandPromises'

View File

@ -1,21 +1,26 @@
import { resolve } from 'pathe' 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' import { distDir } from '../dirs'
const components = ['NoScript', 'Link', 'Base', 'Title', 'Meta', 'Style', 'Head', 'Html', 'Body'] const components = ['NoScript', 'Link', 'Base', 'Title', 'Meta', 'Style', 'Head', 'Html', 'Body']
export default defineNuxtModule({ export default defineNuxtModule({
meta: { meta: {
name: 'meta' name: 'head'
}, },
setup (options, nuxt) { setup (options, nuxt) {
const runtimeDir = nuxt.options.alias['#head'] || resolve(distDir, 'head/runtime') const runtimeDir = resolve(distDir, 'head/runtime')
// Transpile @unhead/vue // Transpile @unhead/vue
nuxt.options.build.transpile.push('@unhead/vue') nuxt.options.build.transpile.push('@unhead/vue')
// Add #head alias // TODO: remove alias in v3.4
nuxt.options.alias['#head'] = runtimeDir 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 // Register components
const componentsPath = resolve(runtimeDir, 'components') const componentsPath = resolve(runtimeDir, 'components')
@ -30,14 +35,29 @@ export default defineNuxtModule({
kebabName: componentName 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 // Opt-out feature allowing dependencies using @vueuse/head to work
if (nuxt.options.experimental.polyfillVueUseHead) { if (nuxt.options.experimental.polyfillVueUseHead) {
// backwards compatibility // backwards compatibility
nuxt.options.alias['@vueuse/head'] = tryResolveModule('@unhead/vue') || '@unhead/vue' 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 // Add library-specific plugin
addPlugin({ src: resolve(runtimeDir, 'lib/unhead.plugin') }) addPlugin({ src: resolve(runtimeDir, 'plugins/unhead') })
} }
}) })

View File

@ -1,6 +1,6 @@
import { defineComponent } from 'vue' import { defineComponent } from 'vue'
import type { PropType, SetupContext } from 'vue' import type { PropType, SetupContext } from 'vue'
import { useHead } from './composables' import { useHead } from '@unhead/vue'
import type { import type {
CrossOrigin, CrossOrigin,
FetchPriority, FetchPriority,

View File

@ -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)
}
}

View File

@ -1,6 +0,0 @@
import type { UseHeadInput } from '@unhead/vue'
import type { HeadAugmentations } from 'nuxt/schema'
export * from './composables'
export type MetaObject = UseHeadInput<HeadAugmentations>

View File

@ -1,4 +1,4 @@
import { createHead, useHead } from '@unhead/vue' import { createHead } from '@unhead/vue'
import { renderSSRHead } from '@unhead/ssr' import { renderSSRHead } from '@unhead/ssr'
import { defineNuxtPlugin } from '#app/nuxt' import { defineNuxtPlugin } from '#app/nuxt'
// @ts-expect-error untyped // @ts-expect-error untyped
@ -25,9 +25,6 @@ export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hooks.hook('app:mounted', unpauseDom) nuxtApp.hooks.hook('app:mounted', unpauseDom)
} }
// support backwards compatibility, remove at some point
nuxtApp._useHead = useHead
if (process.server) { if (process.server) {
nuxtApp.ssrContext!.renderMeta = async () => { nuxtApp.ssrContext!.renderMeta = async () => {
const meta = await renderSSRHead(head) const meta = await renderSSRHead(head)

View File

@ -15,9 +15,6 @@ const commonPresets: InlinePreset[] = [
const appPreset = defineUnimportPreset({ const appPreset = defineUnimportPreset({
from: '#app', from: '#app',
imports: [ imports: [
'useHead',
'useSeoMeta',
'useServerSeoMeta',
'useAsyncData', 'useAsyncData',
'useLazyAsyncData', 'useLazyAsyncData',
'useNuxtData', 'useNuxtData',

View File

@ -54,7 +54,7 @@ describe('imports:transform', () => {
}) })
}) })
const excludedNuxtHelpers = ['useHydration'] const excludedNuxtHelpers = ['useHydration', 'useHead', 'useSeoMeta', 'useServerSeoMeta']
describe('imports:nuxt', () => { describe('imports:nuxt', () => {
try { try {
@ -62,7 +62,8 @@ describe('imports:nuxt', () => {
const entrypointContents = readFileSync(join(__dirname, '../src/app/composables/index.ts'), 'utf8') const entrypointContents = readFileSync(join(__dirname, '../src/app/composables/index.ts'), 'utf8')
const names = findExports(entrypointContents).flatMap(i => i.names || i.name) 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)) { if (excludedNuxtHelpers.includes(name)) {
continue continue
} }

View File

@ -1,6 +1,6 @@
import { defineUntypedSchema } from 'untyped' import { defineUntypedSchema } from 'untyped'
import { defu } from 'defu' import { defu } from 'defu'
import type { AppHeadMetaObject } from '../types/meta' import type { AppHeadMetaObject } from '../types/head'
export default defineUntypedSchema({ export default defineUntypedSchema({
/** /**

View File

@ -7,7 +7,7 @@ export * from './types/components'
export * from './types/config' export * from './types/config'
export * from './types/hooks' export * from './types/hooks'
export * from './types/imports' export * from './types/imports'
export * from './types/meta' export * from './types/head'
export * from './types/module' export * from './types/module'
export * from './types/nuxt' export * from './types/nuxt'
export * from './types/router' export * from './types/router'

View File

@ -3,7 +3,7 @@ import type { ConfigSchema } from '../../schema/config'
import type { ServerOptions as ViteServerOptions, UserConfig as ViteUserConfig } from 'vite' import type { ServerOptions as ViteServerOptions, UserConfig as ViteUserConfig } from 'vite'
import type { Options as VuePluginOptions } from '@vitejs/plugin-vue' import type { Options as VuePluginOptions } from '@vitejs/plugin-vue'
import type { Options as VueJsxPluginOptions } from '@vitejs/plugin-vue-jsx' 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 { Nuxt } from './nuxt'
import type { SchemaDefinition } from 'untyped' import type { SchemaDefinition } from 'untyped'
export type { SchemaDefinition } from 'untyped' export type { SchemaDefinition } from 'untyped'

View File

@ -1,5 +1,6 @@
import type { Head, MergeHead } from '@unhead/schema' 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 { export interface HeadAugmentations extends MergeHead {
// runtime type modifications // runtime type modifications
base?: {} base?: {}

View File

@ -416,6 +416,22 @@ describe('head tags', () => {
expect(indexHtml).toContain('<title>Basic fixture</title>') 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 () => { it('SPA should render appHead tags', async () => {
const headHtml = await $fetch('/head', { headers: { 'x-nuxt-no-ssr': '1' } }) const headHtml = await $fetch('/head', { headers: { 'x-nuxt-no-ssr': '1' } })

View File

@ -26,7 +26,7 @@ describe.skipIf(isWindows)('minimal nuxt application', () => {
it('default client bundle size', async () => { it('default client bundle size', async () => {
stats.client = await analyzeSizes('**/*.js', publicDir) 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(` expect(stats.client.files.map(f => f.replace(/\..*\.js/, '.js'))).toMatchInlineSnapshot(`
[ [
"_nuxt/_plugin-vue_export-helper.js", "_nuxt/_plugin-vue_export-helper.js",
@ -40,7 +40,7 @@ describe.skipIf(isWindows)('minimal nuxt application', () => {
it('default server bundle size', async () => { it('default server bundle size', async () => {
stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) 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) const modules = await analyzeSizes('node_modules/**/*', serverDir)
expect(modules.totalBytes).toBeLessThan(2713000) expect(modules.totalBytes).toBeLessThan(2713000)

View 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>

View File

@ -1,3 +1,6 @@
// @ts-expect-error
import { useHead } from '#head'
export default defineNuxtPlugin(() => { export default defineNuxtPlugin(() => {
useHead({ useHead({
titleTemplate: '%s - Fixture' titleTemplate: '%s - Fixture'

View File

@ -22,9 +22,6 @@
"#app/*": [ "#app/*": [
"./packages/nuxt/src/app/*" "./packages/nuxt/src/app/*"
], ],
"#head": [
"./packages/nuxt/src/head/runtime/index"
],
"#internal/nitro": [ "#internal/nitro": [
"./node_modules/nitropack/dist/runtime" "./node_modules/nitropack/dist/runtime"
], ],