rasengan
Version:
The modern React Framework
349 lines (348 loc) • 12.6 kB
JavaScript
import { DefaultLayout } from '../components/template.js';
import { RouterComponent } from '../interfaces.js';
import { convertMDXPageToPageComponent, isMDXPage } from './define-router.js';
const basePath = '/src/app/_routes/';
/**
* Normalize a segment
* @param segment Segment to normalize
* @returns Normalized segment
*/
function normalizeSegment(segment) {
// Handle index
if (segment === 'index')
return '.'; // eg. index => .
// Handle dynamic segments
if (segment.startsWith('[') && segment.endsWith(']')) {
const param = segment.slice(1, -1); // eg. [locale] => locale
if (param.at(0) === '_')
return ':' + param.slice(1) + '?'; // eg. _locale => :locale?
return ':' + param;
}
// Handling optional static segment
if (segment.length > 1 && segment.at(0) === '_')
return segment.slice(1) + '?'; // eg. _edit => edit?
return segment;
}
/**
* Get path segments from file path
* @param filePath File path
* @param foldersOnly Whether to return only folders
* @returns Path segments
*/
function getPathSegments(filePath, foldersOnly = false) {
const relative = filePath.replace(basePath, ''); // eg. /src/app/_routes/docs/layout.tsx => docs/layout.tsx
if (!foldersOnly) {
let withoutExtension = '';
if (relative.includes('layout.')) {
withoutExtension = relative.replace(/(layout)\.(js|ts|jsx|tsx)$/, '_'); // eg. docs/layout.tsx => docs/_
}
else {
withoutExtension = relative.replace(/\.(page)\.(js|ts|jsx|tsx|mdx|md)$/, ''); // eg. docs/index.page.tsx => docs/index
}
return withoutExtension.split('/').map(normalizeSegment).filter(Boolean);
}
return relative
.split('/')
.filter((segment) => !segment.includes('.')) // ignore last segment (file name)
.map(normalizeSegment)
.filter(Boolean);
}
/**
* Generate the skeleton tree from modules
* @param tree Tree to generate
* @param modules Modules to generate tree from
*/
function generateSkeletonTree(tree, modules) {
let currentLevel = tree;
const root = {
path: '/',
fullPath: '/',
segment: '_',
isLayout: true,
children: [],
};
currentLevel.push(root);
currentLevel = root.children ?? []; // change level
for (const [, { segments }] of modules) {
let tmpLevel = currentLevel;
let fullPath = '';
for (const segment of segments) {
if (!(segment.startsWith('(') && segment.endsWith(')'))) {
fullPath += '/' + segment;
}
else {
// if (fullPath === '') {
// fullPath = '/';
// }
}
const existing = tmpLevel.find((n) => n.segment === segment);
if (existing) {
tmpLevel = existing.children; // change level
continue;
}
const node = {
path: fullPath,
fullPath,
segment,
isLayout: false,
children: [],
};
tmpLevel.push(node);
tmpLevel = node.children ?? []; // change level
}
}
}
function insertNodeToTree(tree, segments, routeInfo) {
let currentNode = tree[0];
let currentLevel = tree[0].children;
let currentLayout = currentNode;
// Handle the root layout
if (segments.length === 1 && segments[0] === '_') {
currentNode.isLayout = true;
currentNode.component = routeInfo.component;
currentNode.metadata = routeInfo.metadata;
currentNode.loader = routeInfo.loader;
return;
}
let fullPath = '';
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
// We reached the end of the path
if (segment === '.')
break;
const node = currentLevel.find((n) => n.segment === segment);
if (node) {
// Go to the next node
currentNode = node;
currentLevel = node.children;
fullPath = node.fullPath;
if (node.isLayout) {
currentLayout = node;
}
}
}
if (routeInfo.isLayout) {
currentNode.isLayout = true;
currentNode.component = routeInfo.component;
currentNode.metadata = routeInfo.metadata;
currentNode.loader = routeInfo.loader;
}
else {
let path = '';
// The case where we create an index page directly at the root of _routes folder
if (segments.length === 1 && segments[0] === '.') {
path = '/';
}
else {
const segment = segments.at(-1);
if (segment === '.') {
path = fullPath;
}
else {
path = fullPath + '/' + segment;
}
}
if (currentLayout.path !== '/') {
const position = path.indexOf(currentLayout.path);
if (position !== -1) {
path = path.slice(position + currentLayout.path.length);
if (path === '') {
path = '/';
}
}
}
const lastSegment = segments.at(-1);
const node = {
path,
fullPath: fullPath + '/' + (lastSegment === '.' ? '' : lastSegment),
segment: lastSegment,
isLayout: false,
component: routeInfo.component,
metadata: routeInfo.metadata,
loader: routeInfo.loader,
};
currentLevel.push(node);
}
}
/**
* This function receives a tree of routes and generate a router component
* @param tree Tree of routes
* @returns Router component
*/
async function generateRouter(tree) {
const root = tree[0];
// Generate the base router
const router = new RouterComponent();
// Get layout if defined
if (root.isLayout) {
// use default layout if not defined
const layout = (root.component || DefaultLayout);
layout.path = root.path || DefaultLayout.path;
// TODO: Add metadata here
router.layout = layout;
router.useParentLayout = true;
}
// Get pages
const { routes: pages, routers } = await generateRoutes(root.children);
// Add pages to the router
router.pages = pages;
router.routers = routers;
return router;
}
/**
* This function receives a tree of routes and generate a list of pages and routers
* @param tree Tree of routes
* @returns List of pages and routers
*/
async function generateRoutes(tree) {
try {
const routes = [];
const routers = [];
for (const node of tree) {
// Handle page
if (!node.isLayout && node.component) {
const page = node.component;
if (!page) {
console.warn(`Page component is not exported by default for route: ${node.path}`);
continue;
}
if (isMDXPage(page)) {
// Convert PageComponent to MDXPageComponent (to make ts happy)
const mdxPage = page;
mdxPage.metadata.path = node.path;
mdxPage.metadata.metadata = node.metadata;
const pageComponent = await convertMDXPageToPageComponent(mdxPage);
routes.push(pageComponent);
continue;
}
page.path = node.path;
page.metadata = node.metadata;
page.loader = node.loader;
routes.push(page);
continue;
}
// Handle layout
if (node.isLayout) {
const layout = node.component;
if (!layout) {
console.warn(`Layout component is not defined for route: ${node.path}`);
continue;
}
layout.path = node.path;
layout.metadata = node.metadata;
layout.loader = node.loader;
if (node.children) {
// Loop through children
const { routes: subRoutes, routers: subRouters } = await generateRoutes(node.children);
// Create a new router
const router = new RouterComponent();
router.layout = layout;
router.routers = subRouters;
router.pages = subRoutes;
router.useParentLayout = true;
routers.push(router);
}
continue;
}
if (node.children) {
// Handle intermediate node (folders)
const { routes: subRoutes, routers: subRouters } = await generateRoutes(node.children);
// Add sub routes and sub routers
routes.push(...subRoutes);
routers.push(...subRouters);
}
}
return {
routes,
routers,
};
}
catch (error) {
console.error(error);
// TODO: Handle error
}
}
/**
* This function receives a function that returns a record of modules and generate a router component
* @param fn Function that returns a record of modules
* @returns Router component
*/
export async function flatRoutes(fn) {
try {
let modules = fn();
// import.meta.glob can be undefined in some cases (because it's unavailable outside a vite env)
// if (import.meta.glob) {
// let modules = import.meta.glob(
// [
// '/src/app/_routes/**/layout.{jsx,tsx}',
// '/src/app/_routes/**/*.page.{md,mdx,jsx,tsx}',
// ],
// { eager: true }
// );
// }
const tree = [];
const foldersMap = new Map();
const modulesMap = new Map();
for (const [filePath, mod] of Object.entries(modules)) {
const foldersSegments = getPathSegments(filePath, true);
const modulesSegments = getPathSegments(filePath);
foldersMap.set(filePath, { segments: foldersSegments, mod });
modulesMap.set(filePath, { segments: modulesSegments, mod });
}
// Generate the skeleton tree containing just folders as nodes
generateSkeletonTree(tree, foldersMap);
// Filter out layouts
const layoutModulesMap = new Map([...modulesMap.entries()].filter(([filePath]) => filePath.includes('layout.')));
const pageModulesMap = new Map([...modulesMap.entries()].filter(([filePath]) => filePath.includes('.page.')));
// Handle the case where modules are empty
if (layoutModulesMap.size === 0) {
insertNodeToTree(tree, ['_'], {
component: DefaultLayout,
metadata: {},
isLayout: true,
});
}
for (const [filePath, { segments, mod }] of layoutModulesMap) {
if (!mod.default) {
console.warn(`Layout component is not exported by default from: ${filePath}}`);
continue;
}
let metadata = mod.default.metadata;
let loader = mod.default.loader;
insertNodeToTree(tree, segments, {
component: mod.default,
metadata: metadata ?? {},
loader,
isLayout: true,
});
}
for (const [filePath, { segments, mod }] of pageModulesMap) {
if (!mod.default) {
console.warn(`Page component is not exported by default from: ${filePath}}`);
continue;
}
let metadata = mod.default.metadata;
let loader = mod.default.loader;
// extracting the metadata
if (isMDXPage(mod.default)) {
metadata = mod.default.metadata.metadata;
}
insertNodeToTree(tree, segments, {
component: mod.default,
metadata: metadata ?? {
title: mod.default.name,
description: '',
},
loader,
isLayout: false,
});
}
// Convert the tree into a router component instance
const router = await generateRouter(tree);
return router;
}
catch (error) {
console.error(error);
// TODO: Handle error
}
}