mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-22 05:35:13 +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 { resolveOptions } from 'unplugin-vue-router/options'
|
||||||
import type { EditableTreeNode, Options as TypedRouterOptions } from 'unplugin-vue-router'
|
import type { EditableTreeNode, Options as TypedRouterOptions } from 'unplugin-vue-router'
|
||||||
|
|
||||||
|
import type { NitroRouteConfig } from 'nitropack'
|
||||||
|
import { defu } from 'defu'
|
||||||
import { distDir } from '../dirs'
|
import { distDir } from '../dirs'
|
||||||
import { normalizeRoutes, resolvePagesRoutes } from './utils'
|
import { normalizeRoutes, resolvePagesRoutes } from './utils'
|
||||||
import type { PageMetaPluginOptions } from './page-meta'
|
import { extractRouteRules, getMappedPages } from './route-rules'
|
||||||
import { PageMetaPlugin } from './page-meta'
|
import type { PageMetaPluginOptions } from './plugins/page-meta'
|
||||||
import { RouteInjectionPlugin } from './route-injection'
|
import { PageMetaPlugin } from './plugins/page-meta'
|
||||||
|
import { RouteInjectionPlugin } from './plugins/route-injection'
|
||||||
|
|
||||||
const OPTIONAL_PARAM_RE = /^\/?:.*(\?|\(\.\*\)\*)$/
|
const OPTIONAL_PARAM_RE = /^\/?:.*(\?|\(\.\*\)\*)$/
|
||||||
|
|
||||||
@ -239,8 +242,69 @@ export default defineNuxtModule({
|
|||||||
{ name: 'definePageMeta', as: 'definePageMeta', from: resolve(runtimeDir, 'composables') },
|
{ name: 'definePageMeta', as: 'definePageMeta', from: resolve(runtimeDir, 'composables') },
|
||||||
{ name: 'useLink', as: 'useLink', from: '#vue-router' }
|
{ 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
|
// Extract macros from pages
|
||||||
const pageMetaOptions: PageMetaPluginOptions = {
|
const pageMetaOptions: PageMetaPluginOptions = {
|
||||||
dev: nuxt.options.dev,
|
dev: nuxt.options.dev,
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { createUnplugin } from 'unplugin'
|
import { createUnplugin } from 'unplugin'
|
||||||
import MagicString from 'magic-string'
|
import MagicString from 'magic-string'
|
||||||
import type { Nuxt } from '@nuxt/schema'
|
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_RE = /\b_ctx\.\$route\b/g
|
||||||
const INJECTION_SINGLE_RE = /\b_ctx\.\$route\b/
|
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 { getCurrentInstance } from 'vue'
|
||||||
import type { RouteLocationNormalized, RouteLocationNormalizedLoaded, RouteRecordRedirectOption } from '#vue-router'
|
import type { RouteLocationNormalized, RouteLocationNormalizedLoaded, RouteRecordRedirectOption } from '#vue-router'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
import type { NitroRouteConfig } from 'nitropack'
|
||||||
import type { NuxtError } from '#app'
|
import type { NuxtError } from '#app'
|
||||||
|
|
||||||
export interface PageMeta {
|
export interface PageMeta {
|
||||||
@ -64,3 +65,15 @@ export const definePageMeta = (meta: PageMeta): void => {
|
|||||||
warnRuntimeUsage('definePageMeta')
|
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
|
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)
|
const match = html.match(SFC_SCRIPT_RE)
|
||||||
|
|
||||||
if (match && match[1]) {
|
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 { describe, expect, it } from 'vitest'
|
||||||
import type { NuxtPage } from 'nuxt/schema'
|
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'
|
import { generateRouteKey } from '../src/pages/runtime/utils'
|
||||||
|
|
||||||
describe('pages:generateRoutesFromFiles', () => {
|
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
|
* @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),
|
viteNodePlugin(ctx),
|
||||||
pureAnnotationsPlugin.vite({
|
pureAnnotationsPlugin.vite({
|
||||||
sourcemap: ctx.nuxt.options.sourcemap.client,
|
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',
|
appType: 'custom',
|
||||||
|
@ -107,7 +107,7 @@ export async function buildServer (ctx: ViteBuildContext) {
|
|||||||
plugins: [
|
plugins: [
|
||||||
pureAnnotationsPlugin.vite({
|
pureAnnotationsPlugin.vite({
|
||||||
sourcemap: ctx.nuxt.options.sourcemap.server,
|
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 || {}))
|
} satisfies vite.InlineConfig, ctx.nuxt.options.vite.$server || {}))
|
||||||
|
@ -52,6 +52,12 @@ describe('route rules', () => {
|
|||||||
await expectNoClientErrors('/route-rules/spa')
|
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 () => {
|
it('test noScript routeRules', async () => {
|
||||||
const html = await $fetch('/no-scripts')
|
const html = await $fetch('/no-scripts')
|
||||||
expect(html).not.toContain('<script')
|
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,
|
treeshakeClientOnly: true,
|
||||||
payloadExtraction: true,
|
payloadExtraction: true,
|
||||||
asyncContext: process.env.TEST_CONTEXT === 'async',
|
asyncContext: process.env.TEST_CONTEXT === 'async',
|
||||||
headNext: true
|
headNext: true,
|
||||||
|
inlineRouteRules: true
|
||||||
},
|
},
|
||||||
appConfig: {
|
appConfig: {
|
||||||
fromNuxtConfig: true,
|
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 { describe, expect, it } from 'vitest'
|
||||||
import { isWindows } from 'std-env'
|
import { isWindows } from 'std-env'
|
||||||
import { join } from 'pathe'
|
import { join } from 'pathe'
|
||||||
import { $fetch, setup } from '@nuxt/test-utils'
|
import { $fetch, fetch, setup } from '@nuxt/test-utils'
|
||||||
|
|
||||||
import { expectWithPolling, renderPage } from './utils'
|
import { expectWithPolling, renderPage } from './utils'
|
||||||
|
|
||||||
@ -70,15 +70,33 @@ if (process.env.TEST_ENV !== 'built' && !isWindows) {
|
|||||||
}, 60_000)
|
}, 60_000)
|
||||||
|
|
||||||
it('should detect new routes', async () => {
|
it('should detect new routes', async () => {
|
||||||
const html = await $fetch('/some-404')
|
await expectWithPolling(
|
||||||
expect(html).toContain('catchall at some-404')
|
() => $fetch('/some-404').then(r => r.includes('catchall at some-404')).catch(() => null),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
// write new page route
|
// write new page route
|
||||||
const indexVue = await fsp.readFile(join(fixturePath, 'pages/index.vue'), 'utf8')
|
const indexVue = await fsp.readFile(join(fixturePath, 'pages/index.vue'), 'utf8')
|
||||||
await fsp.writeFile(join(fixturePath, 'pages/some-404.vue'), indexVue)
|
await fsp.writeFile(join(fixturePath, 'pages/some-404.vue'), indexVue)
|
||||||
|
|
||||||
await expectWithPolling(
|
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
|
true
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
Loading…
Reference in New Issue
Block a user