feat: initial work for pages routing (#113)

* feat: initial work for routing

Co-Authored-By: Eduardo San Martin Morote <posva@users.noreply.github.com>

* 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 <seb@nuxtjs.com>
Co-authored-by: Pooya Parsa <pyapar@gmail.com>
This commit is contained in:
Eduardo San Martin Morote 2021-01-18 13:22:38 +01:00 committed by GitHub
parent 0091dba181
commit a6f9fb4c7a
2 changed files with 189 additions and 40 deletions

View File

@ -8,13 +8,17 @@ export interface NuxtApp {
routes: NuxtRoute[] routes: NuxtRoute[]
dir: string dir: string
extensions: string[] extensions: string[]
templates: Record<string, string>
pages?: { pages?: {
dir: string dir: string
} }
} }
// Scan project structure // Scan project structure
export async function createApp (builder: Builder, options: Partial<NuxtApp> = {}): Promise<NuxtApp> { export async function createApp (
builder: Builder,
options: Partial<NuxtApp> = {}
): Promise<NuxtApp> {
const { nuxt } = builder const { nuxt } = builder
// Create base app object // Create base app object
@ -22,6 +26,7 @@ export async function createApp (builder: Builder, options: Partial<NuxtApp> = {
dir: nuxt.options.srcDir, dir: nuxt.options.srcDir,
extensions: nuxt.options.extensions, extensions: nuxt.options.extensions,
routes: [], routes: [],
templates: {},
pages: { pages: {
dir: 'pages' dir: 'pages'
} }
@ -29,13 +34,18 @@ export async function createApp (builder: Builder, options: Partial<NuxtApp> = {
// Resolve app.main // Resolve app.main
if (!app.main) { if (!app.main) {
app.main = nuxt.resolver.tryResolvePath('~/App') || app.main =
nuxt.resolver.tryResolvePath('~/App') ||
nuxt.resolver.tryResolvePath('~/app') nuxt.resolver.tryResolvePath('~/app')
} }
// Resolve pages/ // Resolve pages/
if (app.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 // Fallback app.main
@ -47,3 +57,22 @@ export async function createApp (builder: Builder, options: Partial<NuxtApp> = {
return app 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}' */)}`
}
}

View File

@ -1,14 +1,14 @@
import { resolve, extname, relative } from 'path' import { resolve, extname, relative } from 'path'
import { encodePath } from 'ufo'
import { NuxtApp } from './app' import { NuxtApp } from './app'
import { resolveFiles } from './utils' import { resolveFiles } from './utils'
const isDynamicRoute = (s: string) => /^\[.+\]$/.test(s) // Check if name has [slug]
export interface NuxtRoute { export interface NuxtRoute {
name?: string name: string
path: string path: string
file: string file: string
children?: NuxtRoute[] children: NuxtRoute[]
} }
export async function resolvePagesRoutes (builder, app: NuxtApp) { 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 pagesPattern = `${app.pages!.dir}/**/*.{${app.extensions.join(',')}}`
const files = await resolveFiles(builder, pagesPattern, app.dir) 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[] = [] const routes: NuxtRoute[] = []
for (const file of files) { for (const file of files) {
const pathParts = relative(pagesDir, file) const segments = relative(pagesDir, file)
.replace(new RegExp(`${extname(file)}$`), '') .replace(new RegExp(`${extname(file)}$`), '')
.split('/') .split('/')
const route: NuxtRoute = { const route: NuxtRoute = {
name: '', name: '',
path: '', path: '',
file file,
children: []
} }
// array where routes should be added, useful when adding child routes
let parent = routes let parent = routes
for (let i = 0; i < pathParts.length; i++) { for (let i = 0; i < segments.length; i++) {
const part = pathParts[i] const segment = segments[i]
// Remove square brackets at the start and end.
const isDynamicPart = isDynamicRoute(part)
const normalizedPart = (isDynamicPart
? part.replace(/^\[(\.{3})?/, '').replace(/\]$/, '')
: part
).toLowerCase()
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( // ex: parent/[slug].vue -> parent-slug
parentRoute => parentRoute.name === route.name route.name += (route.name && '-') + segmentName
)
// ex: parent.vue + parent/child.vue
const child = parent.find(parentRoute => parentRoute.name === route.name)
if (child) { if (child) {
child.children = child.children || []
parent = child.children parent = child.children
route.path = '' route.path = ''
} else if (normalizedPart === 'index' && !route.path) { } else if (segmentName === '404' && isSingleSegment) {
route.path += '/:catchAll(.*)*'
} else if (segmentName === 'index' && !route.path) {
route.path += '/' route.path += '/'
} else if (normalizedPart !== 'index') { } else if (segmentName !== 'index') {
if (isDynamicPart) { route.path += getRoutePath(tokens)
route.path += `/:${normalizedPart}` if (isLastSegment && tokens.length === 1 && tokens[0].type === SegmentTokenType.dynamic) {
route.path += '?'
// Catch-all route
if (/^\[\.{3}/.test(part)) {
route.path += '(.*)'
} else if (i === pathParts.length - 1) {
// route.path += '?'
}
} else {
route.path += `/${normalizedPart}`
} }
} }
} }
@ -73,20 +75,138 @@ export async function resolvePagesRoutes (builder, app: NuxtApp) {
return prepareRoutes(routes) 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) { for (const route of routes) {
// Remove -index
if (route.name) { if (route.name) {
route.name = route.name.replace(/-index$/, '') route.name = route.name.replace(/-index$/, '')
} }
if (hasParent) { if (route.path === '/') {
route.path = route.path.replace(/^\//, '').replace(/\?$/, '') // 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 delete route.name
route.children = prepareRoutes(route.children, true)
} }
} }
return routes return routes
} }