feat(nuxt3): add support for definePageMeta macro (#2678)

This commit is contained in:
Daniel Roe 2022-01-17 18:27:23 +00:00 committed by GitHub
parent 0e984fe496
commit 93ef422b5d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 262 additions and 85 deletions

View File

@ -34,15 +34,20 @@ Given the example above, you can use a custom layout like this:
```vue ```vue
<script> <script>
export default { // This will also work in `<script setup>`
definePageMeta({
layout: "custom", layout: "custom",
}; });
</script> </script>
``` ```
::alert{type=info}
Learn more about [defining page meta](/docs/directory-structure/pages#page-metadata).
::
## Example: using with slots ## Example: using with slots
You can also take full control (for example, with slots) by using the `<NuxtLayout>` component (which is globally available throughout your application) and set `layout: false` in your component options. You can also take full control (for example, with slots) by using the `<NuxtLayout>` component (which is globally available throughout your application) by setting `layout: false`.
```vue ```vue
<template> <template>
@ -53,51 +58,33 @@ You can also take full control (for example, with slots) by using the `<NuxtLayo
</NuxtLayout> </NuxtLayout>
</template> </template>
<script> <script setup>
export default { definePageMeta({
layout: false, layout: false,
}; });
</script> </script>
``` ```
## Example: using with `<script setup>` ## Example: changing the layout
If you are utilizing Vue `<script setup>` [compile-time syntactic sugar](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup), you can use a secondary `<script>` tag to set `layout` options as needed. You can also use a ref or computed property for your layout.
::alert{type=info}
Learn more about [`<script setup>` and `<script>` tags co-existing](https://v3.vuejs.org/api/sfc-script-setup.html#usage-alongside-normal-script) in the Vue docs.
::
Assuming this directory structure:
```bash
-| layouts/
---| custom.vue
-| pages/
---| my-page.vue
```
And this `custom.vue` layout:
```vue ```vue
<template> <template>
<div> <div>
Some shared layout content: <button @click="enableCustomLayout">Update layout</button>
<slot />
</div> </div>
</template> </template>
```
You can set a page layout in `my-page.vue` — alongside the `<script setup>` tag — like this:
```vue
<script>
export default {
layout: "custom",
};
</script>
<script setup> <script setup>
// your setup script const route = useRoute()
function enableCustomLayout () {
// Note: because it's within a ref, it will persist if
// you navigate away and then back to the page.
route.layout.value = "custom"
}
definePageMeta({
layout: ref(false),
});
</script> </script>
``` ```

View File

@ -133,3 +133,52 @@ To display the `child.vue` component, you have to insert the `<NuxtNestedPage
</div> </div>
</template> </template>
``` ```
## Page Metadata
You might want to define metadata for each route in your app. You can do this using the `definePageMeta` macro, which will work both in `<script>` and in `<script setup>`:
```vue
<script setup>
definePageMeta({
title: 'My home page'
})
```
This data can then be accessed throughout the rest of your app from the `route.meta` object.
```vue
<script setup>
const route = useRoute()
console.log(route.meta.title) // My home page
</script>
```
If you are using nested routes, the page metadata from all these routes will be merged into a single object. For more on route meta, see the [vue-router docs](https://next.router.vuejs.org/guide/advanced/meta.html#route-meta-fields).
Much like `defineEmits` or `defineProps` (see [Vue docs](https://v3.vuejs.org/api/sfc-script-setup.html#defineprops-and-defineemits)), `definePageMeta` is a **compiler macro**. It will be compiled away so you cannot reference it within your component. Instead, the metadata passed to it will be hoisted out of the component. Therefore, the page meta object cannot reference the component (or values defined on the component). However, it can reference imported bindings.
```vue
<script setup>
import { someData } from '~/utils/example'
const title = ref('')
definePageMeta({
title,
someData
})
</script>
```
### Special Metadata
Of course, you are welcome to define metadata for your own use throughout your app. But some metadata defined with `definePageMeta` has a particular purpose:
#### `layout`
You can define the layout used to render the route. This can be either false (to disable any layout), a string or a ref/computed, if you want to make it reactive in some way. [More about layouts](/docs/directory-structure/layouts).
#### `transition`
You can define transition properties for the `<transition>` component that wraps your pages, or pass `false` to disable the `<transition>` wrapper for that route. [More about transitions](https://v3.vuejs.org/guide/transitions-overview.html).

View File

@ -11,8 +11,8 @@
</div> </div>
</template> </template>
<script> <script setup>
export default defineNuxtComponent({ definePageMeta({
layout: 'custom' layout: 'custom'
}) })
</script> </script>

View File

@ -18,8 +18,10 @@
</template> </template>
<script> <script>
definePageMeta({
layout: false
})
export default { export default {
layout: false,
data: () => ({ data: () => ({
layout: 'custom' layout: 'custom'
}) })

View File

@ -8,7 +8,7 @@
</template> </template>
<script> <script>
export default { definePageMeta({
layout: 'custom' layout: 'custom'
} })
</script> </script>

View File

@ -37,7 +37,7 @@ export const TransformPlugin = createUnplugin((ctx: AutoImportContext) => {
enforce: 'post', enforce: 'post',
transformInclude (id) { transformInclude (id) {
const { pathname, search } = parseURL(id) const { pathname, search } = parseURL(id)
const { type } = parseQuery(search) const { type, macro } = parseQuery(search)
// Exclude node_modules by default // Exclude node_modules by default
if (ctx.transform.exclude.some(pattern => id.match(pattern))) { if (ctx.transform.exclude.some(pattern => id.match(pattern))) {
@ -47,7 +47,7 @@ export const TransformPlugin = createUnplugin((ctx: AutoImportContext) => {
// vue files // vue files
if ( if (
pathname.endsWith('.vue') && pathname.endsWith('.vue') &&
(type === 'template' || type === 'script' || !search) (type === 'template' || type === 'script' || macro || !search)
) { ) {
return true return true
} }

View File

@ -0,0 +1,92 @@
import { createUnplugin } from 'unplugin'
import { parseQuery, parseURL, withQuery } from 'ufo'
import { findStaticImports, findExports } from 'mlly'
export interface TransformMacroPluginOptions {
macros: Record<string, string>
}
export const TransformMacroPlugin = createUnplugin((options: TransformMacroPluginOptions) => {
return {
name: 'nuxt-pages-macros-transform',
enforce: 'post',
transformInclude (id) {
// We only process SFC files for macros
return parseURL(id).pathname.endsWith('.vue')
},
transform (code, id) {
const { search } = parseURL(id)
// Tree-shake out any runtime references to the macro.
// We do this first as it applies to all files, not just those with the query
for (const macro in options.macros) {
const match = code.match(new RegExp(`\\b${macro}\\s*\\(\\s*`))?.[0]
if (match) {
code = code.replace(match, `/*#__PURE__*/ false && ${match}`)
}
}
if (!parseQuery(search).macro) { return code }
// [webpack] Re-export any imports from script blocks in the components
// with workaround for vue-loader bug: https://github.com/vuejs/vue-loader/pull/1911
const scriptImport = findStaticImports(code).find(i => parseQuery(i.specifier.replace('?macro=true', '')).type === 'script')
if (scriptImport) {
const specifier = withQuery(scriptImport.specifier.replace('?macro=true', ''), { macro: 'true' })
return `export { meta } from "${specifier}"`
}
const currentExports = findExports(code)
for (const match of currentExports) {
if (match.type !== 'default') { continue }
if (match.specifier && match._type === 'named') {
// [webpack] Export named exports rather than the default (component)
return code.replace(match.code, `export {${Object.values(options.macros).join(', ')}} from "${match.specifier}"`)
} else {
// ensure we tree-shake any _other_ default exports out of the macro script
code = code.replace(match.code, '/*#__PURE__*/ false &&')
code += '\nexport default {}'
}
}
for (const macro in options.macros) {
// Skip already-processed macros
if (currentExports.some(e => e.name === options.macros[macro])) { continue }
const { 0: match, index = 0 } = code.match(new RegExp(`\\b${macro}\\s*\\(\\s*`)) || {} as RegExpMatchArray
const macroContent = match ? extractObject(code.slice(index + match.length)) : 'undefined'
code += `\nexport const ${options.macros[macro]} = ${macroContent}`
}
return code
}
}
})
const starts = {
'{': '}',
'[': ']',
'(': ')',
'<': '>',
'"': '"',
"'": "'"
}
function extractObject (code: string) {
// Strip comments
code = code.replace(/^\s*\/\/.*$/gm, '')
const stack = []
let result = ''
do {
if (stack[0] === code[0] && result.slice(-1) !== '\\') {
stack.shift()
} else if (code[0] in starts) {
stack.unshift(starts[code[0]])
}
result += code[0]
code = code.slice(1)
} while (stack.length && code.length)
return result
}

View File

@ -1,8 +1,9 @@
import { existsSync } from 'fs' import { existsSync } from 'fs'
import { defineNuxtModule, addTemplate, addPlugin, templateUtils } from '@nuxt/kit' import { defineNuxtModule, addTemplate, addPlugin, templateUtils, addVitePlugin, addWebpackPlugin } from '@nuxt/kit'
import { resolve } from 'pathe' import { resolve } from 'pathe'
import { distDir } from '../dirs' import { distDir } from '../dirs'
import { resolveLayouts, resolvePagesRoutes, addComponentToRoutes } from './utils' import { resolveLayouts, resolvePagesRoutes, normalizeRoutes } from './utils'
import { TransformMacroPlugin, TransformMacroPluginOptions } from './macros'
export default defineNuxtModule({ export default defineNuxtModule({
meta: { meta: {
@ -41,8 +42,18 @@ export default defineNuxtModule({
const composablesFile = resolve(runtimeDir, 'composables') const composablesFile = resolve(runtimeDir, 'composables')
autoImports.push({ name: 'useRouter', as: 'useRouter', from: composablesFile }) autoImports.push({ name: 'useRouter', as: 'useRouter', from: composablesFile })
autoImports.push({ name: 'useRoute', as: 'useRoute', from: composablesFile }) autoImports.push({ name: 'useRoute', as: 'useRoute', from: composablesFile })
autoImports.push({ name: 'definePageMeta', as: 'definePageMeta', from: composablesFile })
}) })
// Extract macros from pages
const macroOptions: TransformMacroPluginOptions = {
macros: {
definePageMeta: 'meta'
}
}
addVitePlugin(TransformMacroPlugin.vite(macroOptions))
addWebpackPlugin(TransformMacroPlugin.webpack(macroOptions))
// Add router plugin // Add router plugin
addPlugin(resolve(runtimeDir, 'router')) addPlugin(resolve(runtimeDir, 'router'))
@ -52,8 +63,8 @@ export default defineNuxtModule({
async getContents () { async getContents () {
const pages = await resolvePagesRoutes(nuxt) const pages = await resolvePagesRoutes(nuxt)
await nuxt.callHook('pages:extend', pages) await nuxt.callHook('pages:extend', pages)
const serializedRoutes = addComponentToRoutes(pages) const { routes: serializedRoutes, imports } = normalizeRoutes(pages)
return `export default ${templateUtils.serialize(serializedRoutes)}` return [...imports, `export default ${templateUtils.serialize(serializedRoutes)}`].join('\n')
} }
}) })

View File

@ -1,3 +1,4 @@
import { ComputedRef, /* KeepAliveProps, */ Ref, TransitionProps } from 'vue'
import type { Router, RouteLocationNormalizedLoaded } from 'vue-router' import type { Router, RouteLocationNormalizedLoaded } from 'vue-router'
import { useNuxtApp } from '#app' import { useNuxtApp } from '#app'
@ -8,3 +9,29 @@ export const useRouter = () => {
export const useRoute = () => { export const useRoute = () => {
return useNuxtApp()._route as RouteLocationNormalizedLoaded return useNuxtApp()._route as RouteLocationNormalizedLoaded
} }
export interface PageMeta {
[key: string]: any
transition?: false | TransitionProps
layout?: false | string | Ref<false | string> | ComputedRef<false | string>
// TODO: https://github.com/vuejs/vue-next/issues/3652
// keepalive?: false | KeepAliveProps
}
declare module 'vue-router' {
interface RouteMeta extends PageMeta {}
}
const warnRuntimeUsage = (method: string) =>
console.warn(
`${method}() is a compiler-hint helper that is only usable inside ` +
'<script setup> of a single file component. Its arguments should be ' +
'compiled away and passing it at runtime has no effect.'
)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const definePageMeta = (meta: PageMeta): void => {
if (process.dev) {
warnRuntimeUsage('definePageMeta')
}
}

View File

@ -1,17 +1,17 @@
import { defineComponent, h } from 'vue' import { defineComponent, h, Ref } from 'vue'
// @ts-ignore // @ts-ignore
import layouts from '#build/layouts' import layouts from '#build/layouts'
export default defineComponent({ export default defineComponent({
props: { props: {
name: { name: {
type: [String, Boolean], type: [String, Boolean, Object] as unknown as () => string | false | Ref<string | false>,
default: 'default' default: 'default'
} }
}, },
setup (props, context) { setup (props, context) {
return () => { return () => {
const layout = props.name const layout = (props.name && typeof props.name === 'object' ? props.name.value : props.name) ?? 'default'
if (!layouts[layout]) { if (!layouts[layout]) {
if (process.dev && layout && layout !== 'default') { if (process.dev && layout && layout !== 'default') {
console.warn(`Invalid layout \`${layout}\` selected.`) console.warn(`Invalid layout \`${layout}\` selected.`)

View File

@ -1,27 +1,34 @@
<template> <template>
<RouterView v-slot="{ Component }"> <RouterView v-slot="{ Component, route }">
<NuxtLayout v-if="Component" :name="layout || updatedComponentLayout || Component.type.layout"> <NuxtLayout v-if="Component" :name="layout || route.meta.layout">
<transition name="page" mode="out-in"> <NuxtTransition :options="route.meta.transition ?? { name: 'page', mode: 'out-in' }">
<!-- <keep-alive> -->
<Suspense @pending="() => onSuspensePending(Component)" @resolve="() => onSuspenseResolved(Component)"> <Suspense @pending="() => onSuspensePending(Component)" @resolve="() => onSuspenseResolved(Component)">
<component :is="Component" :key="$route.path" /> <component :is="Component" :key="route.path" />
</Suspense> </Suspense>
<!-- <keep-alive --> </NuxtTransition>
</transition>
</NuxtLayout> </NuxtLayout>
<!-- TODO: Handle 404 placeholder --> <!-- TODO: Handle 404 placeholder -->
</RouterView> </RouterView>
</template> </template>
<script> <script lang="ts">
import { ref } from 'vue' import { defineComponent, h, Transition } from 'vue'
import NuxtLayout from './layout' import NuxtLayout from './layout'
import { useNuxtApp } from '#app' import { useNuxtApp } from '#app'
export default { const NuxtTransition = defineComponent({
name: 'NuxtTransition',
props: {
options: [Object, Boolean]
},
setup (props, { slots }) {
return () => props.options ? h(Transition, props.options, slots.default) : slots.default()
}
})
export default defineComponent({
name: 'NuxtPage', name: 'NuxtPage',
components: { NuxtLayout }, components: { NuxtLayout, NuxtTransition },
props: { props: {
layout: { layout: {
type: String, type: String,
@ -29,15 +36,9 @@ export default {
} }
}, },
setup () { setup () {
// Disable HMR reactivity in production
const updatedComponentLayout = process.dev ? ref(null) : null
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
function onSuspensePending (Component) { function onSuspensePending (Component) {
if (process.dev) {
updatedComponentLayout.value = Component.type.layout || null
}
return nuxtApp.callHook('page:start', Component) return nuxtApp.callHook('page:start', Component)
} }
@ -46,10 +47,9 @@ export default {
} }
return { return {
updatedComponentLayout,
onSuspensePending, onSuspensePending,
onSuspenseResolved onSuspenseResolved
} }
} }
} })
</script> </script>

View File

@ -1,8 +1,8 @@
import { basename, extname, relative, resolve } from 'pathe' import { basename, extname, relative, resolve } from 'pathe'
import { encodePath } from 'ufo' import { encodePath } from 'ufo'
import type { Nuxt, NuxtRoute } from '@nuxt/schema' import type { Nuxt, NuxtPage } from '@nuxt/schema'
import { resolveFiles } from '@nuxt/kit' import { resolveFiles } from '@nuxt/kit'
import { kebabCase } from 'scule' import { kebabCase, pascalCase } from 'scule'
enum SegmentParserState { enum SegmentParserState {
initial, initial,
@ -32,15 +32,15 @@ export async function resolvePagesRoutes (nuxt: Nuxt) {
return generateRoutesFromFiles(files, pagesDir) return generateRoutesFromFiles(files, pagesDir)
} }
export function generateRoutesFromFiles (files: string[], pagesDir: string): NuxtRoute[] { export function generateRoutesFromFiles (files: string[], pagesDir: string): NuxtPage[] {
const routes: NuxtRoute[] = [] const routes: NuxtPage[] = []
for (const file of files) { for (const file of files) {
const segments = relative(pagesDir, file) const segments = relative(pagesDir, file)
.replace(new RegExp(`${extname(file)}$`), '') .replace(new RegExp(`${extname(file)}$`), '')
.split('/') .split('/')
const route: NuxtRoute = { const route: NuxtPage = {
name: '', name: '',
path: '', path: '',
file, file,
@ -183,7 +183,7 @@ function parseSegment (segment: string) {
return tokens return tokens
} }
function prepareRoutes (routes: NuxtRoute[], parent?: NuxtRoute) { function prepareRoutes (routes: NuxtPage[], parent?: NuxtPage) {
for (const route of routes) { for (const route of routes) {
// Remove -index // Remove -index
if (route.name) { if (route.name) {
@ -225,10 +225,18 @@ export async function resolveLayouts (nuxt: Nuxt) {
}) })
} }
export function addComponentToRoutes (routes: NuxtRoute[]) { export function normalizeRoutes (routes: NuxtPage[], metaImports: Set<string> = new Set()): { imports: Set<string>, routes: NuxtPage[]} {
return routes.map(route => ({ return {
imports: metaImports,
routes: routes.map((route) => {
const metaImportName = `${pascalCase(route.file.replace(/[^\w]/g, ''))}Meta`
metaImports.add(`import { meta as ${metaImportName} } from '${route.file}?macro=true'`)
return {
...route, ...route,
children: route.children ? addComponentToRoutes(route.children) : [], children: route.children ? normalizeRoutes(route.children, metaImports).routes : [],
meta: route.meta || `{${metaImportName}}` as any,
component: `{() => import('${route.file}')}` component: `{() => import('${route.file}')}`
})) }
})
}
} }

View File

@ -33,9 +33,10 @@ type RenderResult = {
export type TSReference = { types: string } | { path: string } export type TSReference = { types: string } | { path: string }
export type NuxtPage = { export type NuxtPage = {
name?: string, name?: string
path: string, path: string
file: string, file: string
meta?: Record<string, any>
children?: NuxtPage[] children?: NuxtPage[]
} }