From 93ef422b5d3c86b6fefd6df5ff077f7e98648c3e Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 17 Jan 2022 18:27:23 +0000 Subject: [PATCH] feat(nuxt3): add support for `definePageMeta` macro (#2678) --- .../3.docs/2.directory-structure/6.layouts.md | 59 +++++------- .../3.docs/2.directory-structure/9.pages.md | 49 ++++++++++ examples/with-layouts/pages/index.vue | 4 +- examples/with-layouts/pages/manual.vue | 4 +- examples/with-layouts/pages/same.vue | 4 +- packages/nuxt3/src/auto-imports/transform.ts | 4 +- packages/nuxt3/src/pages/macros.ts | 92 +++++++++++++++++++ packages/nuxt3/src/pages/module.ts | 19 +++- .../nuxt3/src/pages/runtime/composables.ts | 27 ++++++ packages/nuxt3/src/pages/runtime/layout.ts | 6 +- packages/nuxt3/src/pages/runtime/page.vue | 40 ++++---- packages/nuxt3/src/pages/utils.ts | 32 ++++--- packages/schema/src/types/hooks.ts | 7 +- 13 files changed, 262 insertions(+), 85 deletions(-) create mode 100644 packages/nuxt3/src/pages/macros.ts diff --git a/docs/content/3.docs/2.directory-structure/6.layouts.md b/docs/content/3.docs/2.directory-structure/6.layouts.md index f33ddcb5d65..01c40f9d18a 100644 --- a/docs/content/3.docs/2.directory-structure/6.layouts.md +++ b/docs/content/3.docs/2.directory-structure/6.layouts.md @@ -34,15 +34,20 @@ Given the example above, you can use a custom layout like this: ```vue ``` +::alert{type=info} +Learn more about [defining page meta](/docs/directory-structure/pages#page-metadata). +:: + ## Example: using with slots -You can also take full control (for example, with slots) by using the `` 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 `` component (which is globally available throughout your application) by setting `layout: false`. ```vue - ``` -## Example: using with ` ``` diff --git a/docs/content/3.docs/2.directory-structure/9.pages.md b/docs/content/3.docs/2.directory-structure/9.pages.md index 5a19002b0ae..0401de416f2 100644 --- a/docs/content/3.docs/2.directory-structure/9.pages.md +++ b/docs/content/3.docs/2.directory-structure/9.pages.md @@ -133,3 +133,52 @@ To display the `child.vue` component, you have to insert the ` ``` + +## 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 ` +``` + +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 + +``` + +### 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 `` component that wraps your pages, or pass `false` to disable the `` wrapper for that route. [More about transitions](https://v3.vuejs.org/guide/transitions-overview.html). diff --git a/examples/with-layouts/pages/index.vue b/examples/with-layouts/pages/index.vue index c44511c1cbc..b34afd50b12 100644 --- a/examples/with-layouts/pages/index.vue +++ b/examples/with-layouts/pages/index.vue @@ -11,8 +11,8 @@ - diff --git a/examples/with-layouts/pages/manual.vue b/examples/with-layouts/pages/manual.vue index bf30c099d26..de0776aec61 100644 --- a/examples/with-layouts/pages/manual.vue +++ b/examples/with-layouts/pages/manual.vue @@ -18,8 +18,10 @@ diff --git a/packages/nuxt3/src/auto-imports/transform.ts b/packages/nuxt3/src/auto-imports/transform.ts index a0721d25c95..e15f55fc3a8 100644 --- a/packages/nuxt3/src/auto-imports/transform.ts +++ b/packages/nuxt3/src/auto-imports/transform.ts @@ -37,7 +37,7 @@ export const TransformPlugin = createUnplugin((ctx: AutoImportContext) => { enforce: 'post', transformInclude (id) { const { pathname, search } = parseURL(id) - const { type } = parseQuery(search) + const { type, macro } = parseQuery(search) // Exclude node_modules by default if (ctx.transform.exclude.some(pattern => id.match(pattern))) { @@ -47,7 +47,7 @@ export const TransformPlugin = createUnplugin((ctx: AutoImportContext) => { // vue files if ( pathname.endsWith('.vue') && - (type === 'template' || type === 'script' || !search) + (type === 'template' || type === 'script' || macro || !search) ) { return true } diff --git a/packages/nuxt3/src/pages/macros.ts b/packages/nuxt3/src/pages/macros.ts new file mode 100644 index 00000000000..a0411716f54 --- /dev/null +++ b/packages/nuxt3/src/pages/macros.ts @@ -0,0 +1,92 @@ +import { createUnplugin } from 'unplugin' +import { parseQuery, parseURL, withQuery } from 'ufo' +import { findStaticImports, findExports } from 'mlly' + +export interface TransformMacroPluginOptions { + macros: Record +} + +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 +} diff --git a/packages/nuxt3/src/pages/module.ts b/packages/nuxt3/src/pages/module.ts index 89107c75b62..4409b2ae9ab 100644 --- a/packages/nuxt3/src/pages/module.ts +++ b/packages/nuxt3/src/pages/module.ts @@ -1,8 +1,9 @@ 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 { distDir } from '../dirs' -import { resolveLayouts, resolvePagesRoutes, addComponentToRoutes } from './utils' +import { resolveLayouts, resolvePagesRoutes, normalizeRoutes } from './utils' +import { TransformMacroPlugin, TransformMacroPluginOptions } from './macros' export default defineNuxtModule({ meta: { @@ -41,8 +42,18 @@ export default defineNuxtModule({ const composablesFile = resolve(runtimeDir, 'composables') autoImports.push({ name: 'useRouter', as: 'useRouter', 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 addPlugin(resolve(runtimeDir, 'router')) @@ -52,8 +63,8 @@ export default defineNuxtModule({ async getContents () { const pages = await resolvePagesRoutes(nuxt) await nuxt.callHook('pages:extend', pages) - const serializedRoutes = addComponentToRoutes(pages) - return `export default ${templateUtils.serialize(serializedRoutes)}` + const { routes: serializedRoutes, imports } = normalizeRoutes(pages) + return [...imports, `export default ${templateUtils.serialize(serializedRoutes)}`].join('\n') } }) diff --git a/packages/nuxt3/src/pages/runtime/composables.ts b/packages/nuxt3/src/pages/runtime/composables.ts index d30975fbf5e..dcaa7ce58de 100644 --- a/packages/nuxt3/src/pages/runtime/composables.ts +++ b/packages/nuxt3/src/pages/runtime/composables.ts @@ -1,3 +1,4 @@ +import { ComputedRef, /* KeepAliveProps, */ Ref, TransitionProps } from 'vue' import type { Router, RouteLocationNormalizedLoaded } from 'vue-router' import { useNuxtApp } from '#app' @@ -8,3 +9,29 @@ export const useRouter = () => { export const useRoute = () => { return useNuxtApp()._route as RouteLocationNormalizedLoaded } + +export interface PageMeta { + [key: string]: any + transition?: false | TransitionProps + layout?: false | string | Ref | ComputedRef + // 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 ` + + ' diff --git a/packages/nuxt3/src/pages/utils.ts b/packages/nuxt3/src/pages/utils.ts index 06b655467db..8fa82f1ce6c 100644 --- a/packages/nuxt3/src/pages/utils.ts +++ b/packages/nuxt3/src/pages/utils.ts @@ -1,8 +1,8 @@ import { basename, extname, relative, resolve } from 'pathe' import { encodePath } from 'ufo' -import type { Nuxt, NuxtRoute } from '@nuxt/schema' +import type { Nuxt, NuxtPage } from '@nuxt/schema' import { resolveFiles } from '@nuxt/kit' -import { kebabCase } from 'scule' +import { kebabCase, pascalCase } from 'scule' enum SegmentParserState { initial, @@ -32,15 +32,15 @@ export async function resolvePagesRoutes (nuxt: Nuxt) { return generateRoutesFromFiles(files, pagesDir) } -export function generateRoutesFromFiles (files: string[], pagesDir: string): NuxtRoute[] { - const routes: NuxtRoute[] = [] +export function generateRoutesFromFiles (files: string[], pagesDir: string): NuxtPage[] { + const routes: NuxtPage[] = [] for (const file of files) { const segments = relative(pagesDir, file) .replace(new RegExp(`${extname(file)}$`), '') .split('/') - const route: NuxtRoute = { + const route: NuxtPage = { name: '', path: '', file, @@ -183,7 +183,7 @@ function parseSegment (segment: string) { return tokens } -function prepareRoutes (routes: NuxtRoute[], parent?: NuxtRoute) { +function prepareRoutes (routes: NuxtPage[], parent?: NuxtPage) { for (const route of routes) { // Remove -index if (route.name) { @@ -225,10 +225,18 @@ export async function resolveLayouts (nuxt: Nuxt) { }) } -export function addComponentToRoutes (routes: NuxtRoute[]) { - return routes.map(route => ({ - ...route, - children: route.children ? addComponentToRoutes(route.children) : [], - component: `{() => import('${route.file}')}` - })) +export function normalizeRoutes (routes: NuxtPage[], metaImports: Set = new Set()): { imports: Set, routes: NuxtPage[]} { + 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, + children: route.children ? normalizeRoutes(route.children, metaImports).routes : [], + meta: route.meta || `{${metaImportName}}` as any, + component: `{() => import('${route.file}')}` + } + }) + } } diff --git a/packages/schema/src/types/hooks.ts b/packages/schema/src/types/hooks.ts index 4e9d3ab5e46..356c9721aed 100644 --- a/packages/schema/src/types/hooks.ts +++ b/packages/schema/src/types/hooks.ts @@ -33,9 +33,10 @@ type RenderResult = { export type TSReference = { types: string } | { path: string } export type NuxtPage = { - name?: string, - path: string, - file: string, + name?: string + path: string + file: string + meta?: Record children?: NuxtPage[] }