From a6f9fb4c7ac4d4b90b88f5341acad9120a2fa1ee Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Mon, 18 Jan 2021 13:22:38 +0100 Subject: [PATCH] feat: initial work for pages routing (#113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: initial work for routing Co-Authored-By: Eduardo San Martin Morote * test: test for page builder * test: add tests for names * feat(routes): stringify routes * feat(routes): support multiple slugs * feat(routes): encode paths * test: remove nuxt test utils * refactor: app.template.* * chore: fix lint errors * simplify and fix jest/test * refactor: move hook todo to app.ts (since pages/ might be optional) * add todo for __file exposing Co-authored-by: Sébastien Chopin Co-authored-by: Pooya Parsa --- packages/nuxt3/src/builder/app.ts | 35 ++++- packages/nuxt3/src/builder/pages.ts | 194 ++++++++++++++++++++++------ 2 files changed, 189 insertions(+), 40 deletions(-) diff --git a/packages/nuxt3/src/builder/app.ts b/packages/nuxt3/src/builder/app.ts index f0267c8ebb..b87496b4cf 100644 --- a/packages/nuxt3/src/builder/app.ts +++ b/packages/nuxt3/src/builder/app.ts @@ -8,13 +8,17 @@ export interface NuxtApp { routes: NuxtRoute[] dir: string extensions: string[] + templates: Record pages?: { dir: string } } // Scan project structure -export async function createApp (builder: Builder, options: Partial = {}): Promise { +export async function createApp ( + builder: Builder, + options: Partial = {} +): Promise { const { nuxt } = builder // Create base app object @@ -22,6 +26,7 @@ export async function createApp (builder: Builder, options: Partial = { dir: nuxt.options.srcDir, extensions: nuxt.options.extensions, routes: [], + templates: {}, pages: { dir: 'pages' } @@ -29,13 +34,18 @@ export async function createApp (builder: Builder, options: Partial = { // Resolve app.main if (!app.main) { - app.main = nuxt.resolver.tryResolvePath('~/App') || + app.main = + nuxt.resolver.tryResolvePath('~/App') || nuxt.resolver.tryResolvePath('~/app') } // Resolve pages/ if (app.pages) { - app.routes.push(...await resolvePagesRoutes(builder, app)) + app.routes.push(...(await resolvePagesRoutes(builder, app))) + } + // TODO: Hook to extend routes + if (app.routes.length) { + app.templates.routes = serializeRoutes(app.routes) } // Fallback app.main @@ -47,3 +57,22 @@ export async function createApp (builder: Builder, options: Partial = { return app } + +function serializeRoutes (routes: NuxtRoute[]) { + return JSON.stringify( + routes.map(formatRoute), + null, + 2 + ).replace(/"{(.+)}"/g, '$1') +} + +function formatRoute (route: NuxtRoute) { + return { + name: route.name, + path: route.path, + children: route.children.map(formatRoute), + // TODO: avoid exposing to prod + __file: route.file, + component: `{() => import('${route.file}' /* webpackChunkName: '${route.name}' */)}` + } +} diff --git a/packages/nuxt3/src/builder/pages.ts b/packages/nuxt3/src/builder/pages.ts index 7c62467017..e387e3100a 100644 --- a/packages/nuxt3/src/builder/pages.ts +++ b/packages/nuxt3/src/builder/pages.ts @@ -1,14 +1,14 @@ import { resolve, extname, relative } from 'path' +import { encodePath } from 'ufo' import { NuxtApp } from './app' import { resolveFiles } from './utils' -const isDynamicRoute = (s: string) => /^\[.+\]$/.test(s) - +// Check if name has [slug] export interface NuxtRoute { - name?: string + name: string path: string file: string - children?: NuxtRoute[] + children: NuxtRoute[] } export async function resolvePagesRoutes (builder, app: NuxtApp) { @@ -16,53 +16,55 @@ export async function resolvePagesRoutes (builder, app: NuxtApp) { const pagesPattern = `${app.pages!.dir}/**/*.{${app.extensions.join(',')}}` const files = await resolveFiles(builder, pagesPattern, app.dir) + // Sort to make sure parent are listed first + return generateRoutesFromFiles(files.sort(), pagesDir) +} + +export function generateRoutesFromFiles ( + files: string[], + pagesDir: string +): NuxtRoute[] { const routes: NuxtRoute[] = [] for (const file of files) { - const pathParts = relative(pagesDir, file) + const segments = relative(pagesDir, file) .replace(new RegExp(`${extname(file)}$`), '') .split('/') const route: NuxtRoute = { name: '', path: '', - file + file, + children: [] } + // array where routes should be added, useful when adding child routes let parent = routes - for (let i = 0; i < pathParts.length; i++) { - const part = pathParts[i] - // Remove square brackets at the start and end. - const isDynamicPart = isDynamicRoute(part) - const normalizedPart = (isDynamicPart - ? part.replace(/^\[(\.{3})?/, '').replace(/\]$/, '') - : part - ).toLowerCase() + for (let i = 0; i < segments.length; i++) { + const segment = segments[i] - route.name += route.name ? `-${normalizedPart}` : normalizedPart + const tokens = parseSegment(segment) + const segmentName = tokens.map(({ value }) => value).join('') + const isSingleSegment = segments.length === 1 + const isLastSegment = i === segments.length - 1 - const child = parent.find( - parentRoute => parentRoute.name === route.name - ) + // ex: parent/[slug].vue -> parent-slug + route.name += (route.name && '-') + segmentName + + // ex: parent.vue + parent/child.vue + const child = parent.find(parentRoute => parentRoute.name === route.name) if (child) { - child.children = child.children || [] parent = child.children route.path = '' - } else if (normalizedPart === 'index' && !route.path) { + } else if (segmentName === '404' && isSingleSegment) { + route.path += '/:catchAll(.*)*' + } else if (segmentName === 'index' && !route.path) { route.path += '/' - } else if (normalizedPart !== 'index') { - if (isDynamicPart) { - route.path += `/:${normalizedPart}` - - // Catch-all route - if (/^\[\.{3}/.test(part)) { - route.path += '(.*)' - } else if (i === pathParts.length - 1) { - // route.path += '?' - } - } else { - route.path += `/${normalizedPart}` + } else if (segmentName !== 'index') { + route.path += getRoutePath(tokens) + if (isLastSegment && tokens.length === 1 && tokens[0].type === SegmentTokenType.dynamic) { + route.path += '?' } } } @@ -73,20 +75,138 @@ export async function resolvePagesRoutes (builder, app: NuxtApp) { return prepareRoutes(routes) } -function prepareRoutes (routes: NuxtRoute[], hasParent = false) { +function getRoutePath (tokens: SegmentToken[]): string { + return tokens.reduce((path, token) => { + return ( + path + + (token.type === SegmentTokenType.dynamic + ? `:${token.value}` + : encodePath(token.value)) + ) + }, '/') +} + +// TODO: should be const +enum SegmentParserState { + initial, + static, + dynamic, +} + +// TODO: should be const +enum SegmentTokenType { + static, + dynamic, +} + +interface SegmentToken { + type: SegmentTokenType + value: string +} + +const PARAM_CHAR_RE = /[\w\d_]/ + +function parseSegment (segment: string) { + let state = SegmentParserState.initial + let i = 0 + + let buffer = '' + const tokens: SegmentToken[] = [] + + function consumeBuffer () { + if (!buffer) { + return + } + if (state === SegmentParserState.initial) { + throw new Error('wrong state') + } + + tokens.push({ + type: + state === SegmentParserState.static + ? SegmentTokenType.static + : SegmentTokenType.dynamic, + value: buffer + }) + + buffer = '' + } + + while (i < segment.length) { + const c = segment[i] + + switch (state) { + case SegmentParserState.initial: + buffer = '' + if (c === '[') { + state = SegmentParserState.dynamic + } else { + i-- + state = SegmentParserState.static + } + break + + case SegmentParserState.static: + if (c === '[') { + consumeBuffer() + state = SegmentParserState.dynamic + } else { + buffer += c + } + break + + case SegmentParserState.dynamic: + if (c === ']') { + consumeBuffer() + state = SegmentParserState.initial + } else if (PARAM_CHAR_RE.test(c)) { + buffer += c + } else { + // eslint-disable-next-line no-console + console.log(`Ignored character "${c}" while building param "${buffer}" from "segment"`) + } + break + } + i++ + } + + if (state === SegmentParserState.dynamic) { + throw new Error(`Unfinished param "${buffer}"`) + } + + consumeBuffer() + + return tokens +} + +function prepareRoutes (routes: NuxtRoute[], parent?: NuxtRoute) { for (const route of routes) { + // Remove -index if (route.name) { route.name = route.name.replace(/-index$/, '') } - if (hasParent) { - route.path = route.path.replace(/^\//, '').replace(/\?$/, '') + if (route.path === '/') { + // Remove ? suffix when index page at same level + routes.forEach((siblingRoute) => { + if (siblingRoute.path.endsWith('?')) { + siblingRoute.path = siblingRoute.path.slice(0, -1) + } + }) + } + // Remove leading / if children route + if (parent && route.path.startsWith('/')) { + route.path = route.path.slice(1) } - if (route.children) { + if (route.children.length) { + route.children = prepareRoutes(route.children, route) + } + + if (route.children.find(childRoute => childRoute.path === '')) { delete route.name - route.children = prepareRoutes(route.children, true) } } + return routes }