Skip to content
This repository has been archived by the owner on Apr 6, 2023. It is now read-only.

Commit

Permalink
feat(nuxt3): add support for definePageMeta macro (#2678)
Browse files Browse the repository at this point in the history
  • Loading branch information
danielroe authored Jan 17, 2022
1 parent 0e984fe commit 93ef422
Show file tree
Hide file tree
Showing 13 changed files with 262 additions and 85 deletions.
59 changes: 23 additions & 36 deletions docs/content/3.docs/2.directory-structure/6.layouts.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,20 @@ Given the example above, you can use a custom layout like this:

```vue
<script>
export default {
// This will also work in `<script setup>`
definePageMeta({
layout: "custom",
};
});
</script>
```

::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 `<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
<template>
Expand All @@ -53,51 +58,33 @@ You can also take full control (for example, with slots) by using the `<NuxtLayo
</NuxtLayout>
</template>
<script>
export default {
<script setup>
definePageMeta({
layout: false,
};
});
</script>
```

## Example: using with `<script setup>`

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.

::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
```
## Example: changing the layout

And this `custom.vue` layout:
You can also use a ref or computed property for your layout.

```vue
<template>
<div>
Some shared layout content:
<slot />
<button @click="enableCustomLayout">Update layout</button>
</div>
</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>
// 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>
```
49 changes: 49 additions & 0 deletions docs/content/3.docs/2.directory-structure/9.pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,52 @@ To display the `child.vue` component, you have to insert the `<NuxtNestedPage
</div>
</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).
4 changes: 2 additions & 2 deletions examples/with-layouts/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
</div>
</template>

<script>
export default defineNuxtComponent({
<script setup>
definePageMeta({
layout: 'custom'
})
</script>
4 changes: 3 additions & 1 deletion examples/with-layouts/pages/manual.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@
</template>

<script>
definePageMeta({
layout: false
})
export default {
layout: false,
data: () => ({
layout: 'custom'
})
Expand Down
4 changes: 2 additions & 2 deletions examples/with-layouts/pages/same.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
</template>

<script>
export default {
definePageMeta({
layout: 'custom'
}
})
</script>
4 changes: 2 additions & 2 deletions packages/nuxt3/src/auto-imports/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))) {
Expand All @@ -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
}
Expand Down
92 changes: 92 additions & 0 deletions packages/nuxt3/src/pages/macros.ts
Original file line number Diff line number Diff line change
@@ -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
}
19 changes: 15 additions & 4 deletions packages/nuxt3/src/pages/module.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -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'))

Expand All @@ -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')
}
})

Expand Down
27 changes: 27 additions & 0 deletions packages/nuxt3/src/pages/runtime/composables.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ComputedRef, /* KeepAliveProps, */ Ref, TransitionProps } from 'vue'
import type { Router, RouteLocationNormalizedLoaded } from 'vue-router'
import { useNuxtApp } from '#app'

Expand All @@ -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<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')
}
}
6 changes: 3 additions & 3 deletions packages/nuxt3/src/pages/runtime/layout.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { defineComponent, h } from 'vue'
import { defineComponent, h, Ref } from 'vue'
// @ts-ignore
import layouts from '#build/layouts'

export default defineComponent({
props: {
name: {
type: [String, Boolean],
type: [String, Boolean, Object] as unknown as () => string | false | Ref<string | false>,
default: 'default'
}
},
setup (props, context) {
return () => {
const layout = props.name
const layout = (props.name && typeof props.name === 'object' ? props.name.value : props.name) ?? 'default'
if (!layouts[layout]) {
if (process.dev && layout && layout !== 'default') {
console.warn(`Invalid layout \`${layout}\` selected.`)
Expand Down
Loading

0 comments on commit 93ef422

Please sign in to comment.