mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-25 07:05:11 +00:00
feat(nuxt): add support for routeRules
defined within pages (#20391)
This commit is contained in:
parent
427e64d175
commit
29f4eeff69
@ -9,11 +9,14 @@ import { createRoutesContext } from 'unplugin-vue-router'
|
||||
import { resolveOptions } from 'unplugin-vue-router/options'
|
||||
import type { EditableTreeNode, Options as TypedRouterOptions } from 'unplugin-vue-router'
|
||||
|
||||
import type { NitroRouteConfig } from 'nitropack'
|
||||
import { defu } from 'defu'
|
||||
import { distDir } from '../dirs'
|
||||
import { normalizeRoutes, resolvePagesRoutes } from './utils'
|
||||
import type { PageMetaPluginOptions } from './page-meta'
|
||||
import { PageMetaPlugin } from './page-meta'
|
||||
import { RouteInjectionPlugin } from './route-injection'
|
||||
import { extractRouteRules, getMappedPages } from './route-rules'
|
||||
import type { PageMetaPluginOptions } from './plugins/page-meta'
|
||||
import { PageMetaPlugin } from './plugins/page-meta'
|
||||
import { RouteInjectionPlugin } from './plugins/route-injection'
|
||||
|
||||
const OPTIONAL_PARAM_RE = /^\/?:.*(\?|\(\.\*\)\*)$/
|
||||
|
||||
@ -239,8 +242,69 @@ export default defineNuxtModule({
|
||||
{ name: 'definePageMeta', as: 'definePageMeta', from: resolve(runtimeDir, 'composables') },
|
||||
{ name: 'useLink', as: 'useLink', from: '#vue-router' }
|
||||
)
|
||||
if (nuxt.options.experimental.inlineRouteRules) {
|
||||
imports.push({ name: 'defineRouteRules', as: 'defineRouteRules', from: resolve(runtimeDir, 'composables') })
|
||||
}
|
||||
})
|
||||
|
||||
if (nuxt.options.experimental.inlineRouteRules) {
|
||||
// Track mappings of absolute files to globs
|
||||
let pageToGlobMap = {} as { [absolutePath: string]: string | null }
|
||||
nuxt.hook('pages:extend', (pages) => { pageToGlobMap = getMappedPages(pages) })
|
||||
|
||||
// Extracted route rules defined inline in pages
|
||||
const inlineRules = {} as { [glob: string]: NitroRouteConfig }
|
||||
|
||||
// Allow telling Nitro to reload route rules
|
||||
let updateRouteConfig: () => void | Promise<void>
|
||||
nuxt.hook('nitro:init', (nitro) => {
|
||||
updateRouteConfig = () => nitro.updateConfig({ routeRules: defu(inlineRules, nitro.options._config.routeRules) })
|
||||
})
|
||||
|
||||
async function updatePage (path: string) {
|
||||
const glob = pageToGlobMap[path]
|
||||
const code = path in nuxt.vfs ? nuxt.vfs[path] : await readFile(path!, 'utf-8')
|
||||
try {
|
||||
const extractedRule = await extractRouteRules(code)
|
||||
if (extractedRule) {
|
||||
if (!glob) {
|
||||
const relativePath = relative(nuxt.options.srcDir, path)
|
||||
console.error(`[nuxt] Could not set inline route rules in \`~/${relativePath}\` as it could not be mapped to a Nitro route.`)
|
||||
return
|
||||
}
|
||||
|
||||
inlineRules[glob] = extractedRule
|
||||
} else if (glob) {
|
||||
delete inlineRules[glob]
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.toString().includes('Error parsing route rules')) {
|
||||
const relativePath = relative(nuxt.options.srcDir, path)
|
||||
console.error(`[nuxt] Error parsing route rules within \`~/${relativePath}\`. They should be JSON-serializable.`)
|
||||
} else {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nuxt.hook('builder:watch', async (event, relativePath) => {
|
||||
const path = join(nuxt.options.srcDir, relativePath)
|
||||
if (!(path in pageToGlobMap)) { return }
|
||||
if (event === 'unlink') {
|
||||
delete inlineRules[path]
|
||||
delete pageToGlobMap[path]
|
||||
} else {
|
||||
await updatePage(path)
|
||||
}
|
||||
await updateRouteConfig?.()
|
||||
})
|
||||
|
||||
nuxt.hooks.hookOnce('pages:extend', async () => {
|
||||
for (const page in pageToGlobMap) { await updatePage(page) }
|
||||
await updateRouteConfig?.()
|
||||
})
|
||||
}
|
||||
|
||||
// Extract macros from pages
|
||||
const pageMetaOptions: PageMetaPluginOptions = {
|
||||
dev: nuxt.options.dev,
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { createUnplugin } from 'unplugin'
|
||||
import MagicString from 'magic-string'
|
||||
import type { Nuxt } from '@nuxt/schema'
|
||||
import { isVue } from '../core/utils'
|
||||
import { isVue } from '../../core/utils'
|
||||
|
||||
const INJECTION_RE = /\b_ctx\.\$route\b/g
|
||||
const INJECTION_SINGLE_RE = /\b_ctx\.\$route\b/
|
60
packages/nuxt/src/pages/route-rules.ts
Normal file
60
packages/nuxt/src/pages/route-rules.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import type { Node } from 'estree-walker'
|
||||
import type { CallExpression } from 'estree'
|
||||
import { walk } from 'estree-walker'
|
||||
import { transform } from 'esbuild'
|
||||
import { parse } from 'acorn'
|
||||
import type { NuxtPage } from '@nuxt/schema'
|
||||
import type { NitroRouteConfig } from 'nitropack'
|
||||
import { normalize } from 'pathe'
|
||||
import { extractScriptContent, pathToNitroGlob } from './utils'
|
||||
|
||||
const ROUTE_RULE_RE = /\bdefineRouteRules\(/
|
||||
const ruleCache: Record<string, NitroRouteConfig | null> = {}
|
||||
|
||||
export async function extractRouteRules (code: string): Promise<NitroRouteConfig | null> {
|
||||
if (code in ruleCache) {
|
||||
return ruleCache[code]
|
||||
}
|
||||
if (!ROUTE_RULE_RE.test(code)) { return null }
|
||||
|
||||
code = extractScriptContent(code) || code
|
||||
|
||||
let rule: NitroRouteConfig | null = null
|
||||
|
||||
const js = await transform(code, { loader: 'ts' })
|
||||
walk(parse(js.code, {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 'latest'
|
||||
}) as Node, {
|
||||
enter (_node) {
|
||||
if (_node.type !== 'CallExpression' || (_node as CallExpression).callee.type !== 'Identifier') { return }
|
||||
const node = _node as CallExpression & { start: number, end: number }
|
||||
const name = 'name' in node.callee && node.callee.name
|
||||
if (name === 'defineRouteRules') {
|
||||
const rulesString = js.code.slice(node.start, node.end)
|
||||
try {
|
||||
rule = JSON.parse(runInNewContext(rulesString.replace('defineRouteRules', 'JSON.stringify'), {}))
|
||||
} catch {
|
||||
throw new Error('[nuxt] Error parsing route rules. They should be JSON-serializable.')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ruleCache[code] = rule
|
||||
return rule
|
||||
}
|
||||
|
||||
export function getMappedPages (pages: NuxtPage[], paths = {} as { [absolutePath: string]: string | null }, prefix = '') {
|
||||
for (const page of pages) {
|
||||
if (page.file) {
|
||||
const filename = normalize(page.file)
|
||||
paths[filename] = pathToNitroGlob(prefix + page.path)
|
||||
}
|
||||
if (page.children) {
|
||||
getMappedPages(page.children, paths, page.path + '/')
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
@ -2,6 +2,7 @@ import type { KeepAliveProps, TransitionProps, UnwrapRef } from 'vue'
|
||||
import { getCurrentInstance } from 'vue'
|
||||
import type { RouteLocationNormalized, RouteLocationNormalizedLoaded, RouteRecordRedirectOption } from '#vue-router'
|
||||
import { useRoute } from 'vue-router'
|
||||
import type { NitroRouteConfig } from 'nitropack'
|
||||
import type { NuxtError } from '#app'
|
||||
|
||||
export interface PageMeta {
|
||||
@ -64,3 +65,15 @@ export const definePageMeta = (meta: PageMeta): void => {
|
||||
warnRuntimeUsage('definePageMeta')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* You can define route rules for the current page. Matching route rules will be created, based on the page's _path_.
|
||||
*
|
||||
* For example, a rule defined in `~/pages/foo/bar.vue` will be applied to `/foo/bar` requests. A rule in
|
||||
* `~/pages/foo/[id].vue` will be applied to `/foo/**` requests.
|
||||
*
|
||||
* For more control, such as if you are using a custom `path` or `alias` set in the page's `definePageMeta`, you
|
||||
* should set `routeRules` directly within your `nuxt.config`.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const defineRouteRules = (rules: NitroRouteConfig): void => {}
|
||||
|
@ -107,7 +107,7 @@ export async function generateRoutesFromFiles (files: string[], pagesDir: string
|
||||
}
|
||||
|
||||
const SFC_SCRIPT_RE = /<script\s*[^>]*>([\s\S]*?)<\/script\s*[^>]*>/i
|
||||
function extractScriptContent (html: string) {
|
||||
export function extractScriptContent (html: string) {
|
||||
const match = html.match(SFC_SCRIPT_RE)
|
||||
|
||||
if (match && match[1]) {
|
||||
@ -335,3 +335,15 @@ export function normalizeRoutes (routes: NuxtPage[], metaImports: Set<string> =
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export function pathToNitroGlob (path: string) {
|
||||
if (!path) {
|
||||
return null
|
||||
}
|
||||
// Ignore pages with multiple dynamic parameters.
|
||||
if (path.indexOf(':') !== path.lastIndexOf(':')) {
|
||||
return null
|
||||
}
|
||||
|
||||
return path.replace(/\/(?:[^:/]+)?:\w+.*$/, '/**')
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NuxtPage } from 'nuxt/schema'
|
||||
import { generateRoutesFromFiles } from '../src/pages/utils'
|
||||
import { generateRoutesFromFiles, pathToNitroGlob } from '../src/pages/utils'
|
||||
import { generateRouteKey } from '../src/pages/runtime/utils'
|
||||
|
||||
describe('pages:generateRoutesFromFiles', () => {
|
||||
@ -442,3 +442,20 @@ describe('pages:generateRouteKey', () => {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const pathToNitroGlobTests = {
|
||||
'/': '/',
|
||||
'/:id': '/**',
|
||||
'/:id()': '/**',
|
||||
'/:id?': '/**',
|
||||
'/some-:id?': '/**',
|
||||
'/other/some-:id?': '/other/**',
|
||||
'/other/some-:id()-more': '/other/**',
|
||||
'/other/nested': '/other/nested'
|
||||
}
|
||||
|
||||
describe('pages:pathToNitroGlob', () => {
|
||||
it.each(Object.entries(pathToNitroGlobTests))('should convert %s to %s', (path, expected) => {
|
||||
expect(pathToNitroGlob(path)).to.equal(expected)
|
||||
})
|
||||
})
|
||||
|
@ -238,6 +238,18 @@ export default defineUntypedSchema({
|
||||
*
|
||||
* @see https://github.com/nuxt/nuxt/discussions/22632
|
||||
*/
|
||||
headNext: false
|
||||
headNext: false,
|
||||
|
||||
/**
|
||||
* Allow defining `routeRules` directly within your `~/pages` directory using `defineRouteRules`.
|
||||
*
|
||||
* Rules are converted (based on the path) and applied for server requests. For example, a rule
|
||||
* defined in `~/pages/foo/bar.vue` will be applied to `/foo/bar` requests. A rule in `~/pages/foo/[id].vue`
|
||||
* will be applied to `/foo/**` requests.
|
||||
*
|
||||
* For more control, such as if you are using a custom `path` or `alias` set in the page's `definePageMeta`, you
|
||||
* should set `routeRules` directly within your `nuxt.config`.
|
||||
*/
|
||||
inlineRouteRules: false
|
||||
}
|
||||
})
|
||||
|
@ -83,7 +83,7 @@ export async function buildClient (ctx: ViteBuildContext) {
|
||||
viteNodePlugin(ctx),
|
||||
pureAnnotationsPlugin.vite({
|
||||
sourcemap: ctx.nuxt.options.sourcemap.client,
|
||||
functions: ['defineComponent', 'defineAsyncComponent', 'defineNuxtLink', 'createClientOnly', 'defineNuxtPlugin', 'defineNuxtRouteMiddleware', 'defineNuxtComponent', 'useRuntimeConfig']
|
||||
functions: ['defineComponent', 'defineAsyncComponent', 'defineNuxtLink', 'createClientOnly', 'defineNuxtPlugin', 'defineNuxtRouteMiddleware', 'defineNuxtComponent', 'useRuntimeConfig', 'defineRouteRules']
|
||||
})
|
||||
],
|
||||
appType: 'custom',
|
||||
|
@ -107,7 +107,7 @@ export async function buildServer (ctx: ViteBuildContext) {
|
||||
plugins: [
|
||||
pureAnnotationsPlugin.vite({
|
||||
sourcemap: ctx.nuxt.options.sourcemap.server,
|
||||
functions: ['defineComponent', 'defineAsyncComponent', 'defineNuxtLink', 'createClientOnly', 'defineNuxtPlugin', 'defineNuxtRouteMiddleware', 'defineNuxtComponent', 'useRuntimeConfig']
|
||||
functions: ['defineComponent', 'defineAsyncComponent', 'defineNuxtLink', 'createClientOnly', 'defineNuxtPlugin', 'defineNuxtRouteMiddleware', 'defineNuxtComponent', 'useRuntimeConfig', 'defineRouteRules']
|
||||
})
|
||||
]
|
||||
} satisfies vite.InlineConfig, ctx.nuxt.options.vite.$server || {}))
|
||||
|
@ -52,6 +52,12 @@ describe('route rules', () => {
|
||||
await expectNoClientErrors('/route-rules/spa')
|
||||
})
|
||||
|
||||
it('should allow defining route rules inline', async () => {
|
||||
const res = await fetch('/route-rules/inline')
|
||||
expect(res.status).toEqual(200)
|
||||
expect(res.headers.get('x-extend')).toEqual('added in routeRules')
|
||||
})
|
||||
|
||||
it('test noScript routeRules', async () => {
|
||||
const html = await $fetch('/no-scripts')
|
||||
expect(html).not.toContain('<script')
|
||||
|
3
test/fixtures/basic/nuxt.config.ts
vendored
3
test/fixtures/basic/nuxt.config.ts
vendored
@ -195,7 +195,8 @@ export default defineNuxtConfig({
|
||||
treeshakeClientOnly: true,
|
||||
payloadExtraction: true,
|
||||
asyncContext: process.env.TEST_CONTEXT === 'async',
|
||||
headNext: true
|
||||
headNext: true,
|
||||
inlineRouteRules: true
|
||||
},
|
||||
appConfig: {
|
||||
fromNuxtConfig: true,
|
||||
|
13
test/fixtures/basic/pages/route-rules/inline.vue
vendored
Normal file
13
test/fixtures/basic/pages/route-rules/inline.vue
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
defineRouteRules({
|
||||
headers: {
|
||||
'x-extend': 'added in routeRules'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
Route rules defined inline
|
||||
</div>
|
||||
</template>
|
@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isWindows } from 'std-env'
|
||||
import { join } from 'pathe'
|
||||
import { $fetch, setup } from '@nuxt/test-utils'
|
||||
import { $fetch, fetch, setup } from '@nuxt/test-utils'
|
||||
|
||||
import { expectWithPolling, renderPage } from './utils'
|
||||
|
||||
@ -70,15 +70,33 @@ if (process.env.TEST_ENV !== 'built' && !isWindows) {
|
||||
}, 60_000)
|
||||
|
||||
it('should detect new routes', async () => {
|
||||
const html = await $fetch('/some-404')
|
||||
expect(html).toContain('catchall at some-404')
|
||||
await expectWithPolling(
|
||||
() => $fetch('/some-404').then(r => r.includes('catchall at some-404')).catch(() => null),
|
||||
true
|
||||
)
|
||||
|
||||
// write new page route
|
||||
const indexVue = await fsp.readFile(join(fixturePath, 'pages/index.vue'), 'utf8')
|
||||
await fsp.writeFile(join(fixturePath, 'pages/some-404.vue'), indexVue)
|
||||
|
||||
await expectWithPolling(
|
||||
() => $fetch('/some-404').then(r => r.includes('Hello Nuxt 3')),
|
||||
() => $fetch('/some-404').then(r => r.includes('Hello Nuxt 3')).catch(() => null),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('should hot reload route rules', async () => {
|
||||
await expectWithPolling(
|
||||
() => fetch('/route-rules/inline').then(r => r.headers.get('x-extend') === 'added in routeRules').catch(() => null),
|
||||
true
|
||||
)
|
||||
|
||||
// write new page route
|
||||
const file = await fsp.readFile(join(fixturePath, 'pages/route-rules/inline.vue'), 'utf8')
|
||||
await fsp.writeFile(join(fixturePath, 'pages/route-rules/inline.vue'), file.replace('added in routeRules', 'edited in dev'))
|
||||
|
||||
await expectWithPolling(
|
||||
() => fetch('/route-rules/inline').then(r => r.headers.get('x-extend') === 'edited in dev').catch(() => null),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user