rasengan
Version:
The modern React Framework
565 lines (564 loc) • 23 kB
JavaScript
import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
import { Outlet, matchRoutes, useLoaderData, useParams } from 'react-router';
import { ErrorBoundary, NotFoundPageComponent, RasenganPageComponent, } from '../components/index.js';
import MetadataProvider from '../providers/metadata.js';
import { convertMDXPageToPageComponent, isMDXPage, } from './index.js';
const defaultMetadata = {
title: 'Not Found',
description: 'Page not found',
};
/**
* This function merge the metadata, giving priority to the ones comming from loader
*/
const mergeMetaData = (responseMeta, meta, isLayout = false) => {
let mergedMetaData = {
metaTags: [],
links: [],
openGraph: {
url: '',
image: '',
},
twitter: {
card: 'summary_large_image',
image: '',
title: '',
},
};
if (!isLayout) {
// merge title and description
mergedMetaData['title'] =
responseMeta.title ?? meta.title;
mergedMetaData['description'] =
responseMeta.description ?? meta.description;
}
// merge openGraph datas
mergedMetaData['openGraph'] = {
...meta.openGraph,
...responseMeta.openGraph,
};
// merge twitter data
mergedMetaData['twitter'] = {
...meta.twitter,
...responseMeta.twitter,
};
// merge elements of type <array> eg. metaTags and links
const metaSet = new Set();
const linkSet = new Set();
if (meta['metaTags'] && Array.isArray(meta.metaTags)) {
// Loop through the metaTags and add every key to the set
for (const element of meta.metaTags) {
metaSet.add(element.name ?? element.property);
}
if (responseMeta['metaTags'] && Array.isArray(responseMeta.metaTags)) {
// Loop through the responseMeta and check if the key is already in the set
for (const element of responseMeta.metaTags) {
if (metaSet.has(element.name ?? element.property)) {
// remove the element from the set
metaSet.delete(element.name ?? element.property);
}
mergedMetaData.metaTags.push(element);
}
}
// Loop through the remaining elements in the set
for (const element of metaSet) {
const metaElement = meta.metaTags.find((el) => el.name === element);
if (metaElement) {
mergedMetaData.metaTags.push(metaElement);
}
}
}
else {
mergedMetaData.metaTags = responseMeta.metaTags ?? [];
}
if (meta['links'] && Array.isArray(meta.links)) {
// Loop through the links and add every key to the set
for (const element of meta.links) {
linkSet.add(element.rel);
}
if (responseMeta['links'] && Array.isArray(responseMeta.links)) {
// Loop through the responseMeta and check if the key is already in the set
for (const element of responseMeta.links) {
if (linkSet.has(element.rel)) {
// remove the element from the set
linkSet.delete(element.rel);
}
mergedMetaData.links.push(element);
}
}
// Loop through the remaining elements in the set
for (const element of linkSet) {
const linkElement = meta.links.find((el) => el.rel === element);
if (linkElement) {
mergedMetaData.links.push(linkElement);
}
}
}
else {
mergedMetaData.links = responseMeta.links ?? [];
}
return mergedMetaData;
};
/**
* This function create a loader function
*/
const createLoaderFunction = ({ loader, metadata, isLayout = false, source, }) => {
return async ({ params, request }) => {
try {
// Check if the loader is defined
if (!loader) {
return {
props: {},
meta: metadata,
source,
};
}
// Get the response from the loader
const response = await loader({ params, request });
// Handle redirection
if (response.redirect) {
const formData = new FormData();
formData.append('redirect', response.redirect);
return new Response(formData, {
status: 302,
headers: {
Location: response.redirect,
},
});
}
return {
props: response.props,
meta: mergeMetaData(response.meta ?? {}, metadata, isLayout),
source,
};
}
catch (error) {
console.error(error);
return {
props: {},
meta: {
openGraph: {
url: '',
image: '',
},
twitter: {
card: 'summary_large_image',
image: '',
title: '',
},
metaTags: [],
links: [],
},
source,
};
}
};
};
/**
* This function preload the matching lazy routes
* @param url
* @param routes
*/
export const preloadMatches = async (url, routes) => {
const matches = matchRoutes(routes, url);
if (!matches)
return;
await Promise.all(matches.map(async (match) => {
if (match.route.lazy) {
const resolved = await match.route.lazy();
Object.assign(match.route, resolved);
// Strip lazy so Router never tries to suspend again
delete match.route.lazy;
}
}));
};
/**
* This function receives a router component and return a formated router for static routing
* @param router Represents the router component
* @returns
*/
export const generateRoutes = (router, isRoot = true, parentLayoutPath = undefined) => {
// Initialization of the list of routes
const routes = [];
let route;
let layoutPath;
try {
// Check if the layout is coming from the file-based routing system
if (router.layout.source) {
const layoutNode = router.layout;
layoutPath = layoutNode.path;
route = {
path: !isRoot
? router.useParentLayout
? layoutNode.path.replace(parentLayoutPath + '/', '')
: layoutNode.path
: layoutNode.path,
errorElement: _jsx(ErrorBoundary, {}),
lazy: async () => {
const Layout = (await layoutNode.module()).default;
if (!Layout) {
console.warn(`Layout component is not exported by default from: ${layoutNode.source}}`);
return {
Component() {
return _jsx(Outlet, {});
},
};
}
return {
Component() {
// Default data
const defaultData = {
props: {},
};
// Get SSR data
let { props } = useLoaderData() || defaultData;
// get params
const params = useParams();
const layoutProps = {
...props,
params,
};
// Check if the layout is the root layout and wrap it in a MetadataProvider
if (isRoot || !router.useParentLayout) {
// Generate metadata mapping
// const metadataMapping = generateMetadataMapping(router);
return (_jsx(MetadataProvider, { children: _jsx(Layout, { ...layoutProps }) }));
}
return _jsx(Layout, { ...layoutProps });
},
async loader({ params, request }) {
// Extract metadata from the layout
const metadata = {
...Layout.metadata,
};
return createLoaderFunction({
loader: Layout.loader,
metadata,
isLayout: true,
source: layoutNode.source,
})({
params,
request,
});
},
};
},
children: [],
nested: router.useParentLayout,
hydrateFallbackElement: _jsx(_Fragment, {}),
// shouldRevalidate: ({ currentUrl, nextUrl, defaultShouldRevalidate }) => {
// // Only revalidate if navigating to a different route
// return currentUrl.pathname !== nextUrl.pathname;
// },
};
}
else {
const Layout = router.layout;
layoutPath = Layout.path;
route = {
path: !isRoot
? router.useParentLayout
? layoutPath.replace(parentLayoutPath + '/', '')
: layoutPath
: layoutPath,
errorElement: _jsx(ErrorBoundary, {}),
Component() {
// Default data
const defaultData = {
props: {},
};
// Get SSR data
let { props } = useLoaderData() || defaultData;
// get params
const params = useParams();
const layoutProps = {
...props,
params,
};
// Check if the layout is the root layout and wrap it in a MetadataProvider
if (isRoot || !router.useParentLayout) {
// Generate metadata mapping
// const metadataMapping = generateMetadataMapping(router);
return (_jsx(MetadataProvider, { children: _jsx(Layout, { ...layoutProps }) }));
}
return _jsx(Layout, { ...layoutProps });
},
async loader({ params, request }) {
// Extract metadata from the layout
const metadata = {
...Layout.metadata,
};
return createLoaderFunction({
loader: Layout.loader,
metadata,
isLayout: true,
})({
params,
request,
});
},
children: [],
nested: router.useParentLayout,
hydrateFallbackElement: _jsx(_Fragment, {}),
// hydrateFallbackElement: <>Loading...</>, // TODO: enable override
// shouldRevalidate: ({ currentUrl, nextUrl, defaultShouldRevalidate }) => {
// // Only revalidate if navigating to a different route
// return currentUrl.pathname !== nextUrl.pathname;
// },
};
}
// Defining the page not found route
if (isRoot || router.notFoundComponent) {
route.children.push({
path: '*',
element: router.notFoundComponent ?? _jsx(NotFoundPageComponent, {}),
loader: async () => {
return {
props: {},
meta: defaultMetadata,
};
},
});
}
// Get informations about pages
const pages = router.pages.map((p) => {
// Check if the page is coming from file-based routing system
if (p.source) {
const pageNode = p;
// /home => home
// / => /
const pagePathFormated = pageNode.path.startsWith('/') && pageNode.path !== '/'
? pageNode.path.slice(1)
: pageNode.path;
// Get the path of the page
const path = pageNode.path === '/'
? layoutPath
: layoutPath.length > 1
? pagePathFormated
: pageNode.path;
return {
path: path === layoutPath ? undefined : path,
index: path === layoutPath,
async lazy() {
let Page = (await pageNode.module()).default;
if (!Page) {
console.warn(`Page component is not exported by default from: ${pageNode.source}}`);
return {
Component() {
return null;
},
};
}
// Detech if the page is a MDXPageComponent or not
// When Page is a MDXPageComponent
// type property holds the "MDXPageComponent" value, coming from @rasenganjs/mdx plugin
if (isMDXPage(Page)) {
// Convert PageComponent to MDXPageComponent (to make ts happy)
const mdxPage = Page;
// mdxPage.metadata.path = node.path;
// mdxPage.metadata.metadata = Page.metadata;
Page = await convertMDXPageToPageComponent(mdxPage);
}
return {
Component() {
// Default data
const defaultData = {
props: {
params: {},
},
};
const loaderData = useLoaderData() || defaultData;
return (
// <Suspense fallback={<>Loading</>}>
_jsx(RasenganPageComponent, { page: Page, data: loaderData })
// </Suspense>
);
},
async loader({ params, request }) {
// Extracting metadata from the page
const metadata = {
...Page.metadata,
};
return createLoaderFunction({
loader: Page.loader,
metadata,
source: pageNode.source,
})({
params,
request,
});
},
};
},
errorElement: _jsx(ErrorBoundary, {}),
hydrateFallbackElement: _jsx(_Fragment, {}),
// hydrateFallbackElement: <>Loading...</>,
shouldRevalidate: ({ currentUrl, nextUrl, defaultShouldRevalidate, }) => {
// Only revalidate if navigating to a different route
return currentUrl.pathname !== nextUrl.pathname;
},
module: pageNode.module,
};
}
else {
const Page = p;
// /home => home
// / => /
const pagePathFormated = Page.path.startsWith('/') && Page.path !== '/'
? Page.path.slice(1)
: Page.path;
// Get the path of the page
const path = Page.path === '/'
? layoutPath
: layoutPath.length > 1
? pagePathFormated
: Page.path;
return {
path: path === layoutPath ? undefined : path,
index: path === layoutPath,
async loader({ params, request }) {
// Extracting metadata from the page
const metadata = {
...Page.metadata,
};
return createLoaderFunction({
loader: Page.loader,
metadata,
})({
params,
request,
});
},
Component() {
// Default data
const defaultData = {
props: {
params: {},
},
};
const loaderData = useLoaderData() || defaultData;
return (
// <Suspense fallback={<>Loading</>}>
_jsx(RasenganPageComponent, { page: Page, data: loaderData })
// </Suspense>
);
},
errorElement: _jsx(ErrorBoundary, {}),
hydrateFallbackElement: _jsx(_Fragment, {}),
// hydrateFallbackElement: <>Loading...</>,
shouldRevalidate: ({ currentUrl, nextUrl, defaultShouldRevalidate, }) => {
// Only revalidate if navigating to a different route
return currentUrl.pathname !== nextUrl.pathname;
},
module: () => Promise.resolve({ default: Page }),
};
}
});
// Add pages into children of the current route
pages.forEach((page) => {
route.children.push(page);
});
// Loop through imported routers in order to apply the same logic like above.
for (const importedRouter of router.routers) {
const importedRoutes = generateRoutes(importedRouter, false, layoutPath);
for (const importedRoute of importedRoutes) {
if (importedRoute.nested) {
route.children.push(importedRoute);
}
else {
routes.push(importedRoute);
}
}
}
// Make sure to add the route at the beginning of the list
routes.unshift(route);
}
catch (error) {
console.error(error);
throw error;
}
finally {
// Return the formated router
return routes;
}
};
/**
* Recursively extract all full paths from a nested routes tree,
* including index routes.
*/
export async function getAllRoutesPath(routes, parentPath = '') {
const allPaths = [];
const error = new Set();
for (const route of routes) {
// Compute the full path
const fullPath = route.path
? pathJoin(parentPath, route.path)
: parentPath || '/';
// If route is an index route, it represents its parent path
if (route.index) {
if (parentPath.includes(':')) {
const { paths: staticPaths, error: staticError } = await getStaticRoutesPath(parentPath, route);
allPaths.push(...staticPaths);
Array.from(staticError).forEach((err) => error.add(err));
}
else {
allPaths.push(parentPath || '/');
}
}
// If route has a path and isn't only an index, add it
else if (route.path) {
if (route.path.includes(':')) {
const { paths: staticPaths, error: staticError } = await getStaticRoutesPath(fullPath, route);
allPaths.push(...staticPaths);
Array.from(staticError).forEach((err) => error.add(err));
}
else {
allPaths.push(fullPath);
}
}
// Handle children recursively
if (route.children?.length) {
const { paths: childPaths, error: childError } = await getAllRoutesPath(route.children, fullPath);
allPaths.push(...childPaths);
Array.from(childError).forEach((err) => error.add(err));
}
}
// Ensure uniqueness (optional)
return { paths: Array.from(new Set(allPaths)), error };
}
async function getStaticRoutesPath(path, route) {
const allPaths = [];
const error = new Set();
const module = await route.module();
const Page = module.default;
if (Page.generatePaths) {
const { paths } = await Page.generatePaths();
for (const { params } of paths) {
for (const [key, value] of Object.entries(params)) {
if (!path.includes(`:${key}`)) {
error.add(`[rasengan:router]: Path '${path}' does not have a dynamic segment '${key}'`);
continue;
}
// Replace the dynamic segment with the static value
allPaths.push(path.replace(`:${key}`, value));
}
}
}
else {
// If no generatePaths function is provided, show a warning
error.add(`[rasengan:router]: Path '${path}' does not have a generatePaths function`);
}
// if (error.size > 0) {
// // Log errors
// Array.from(error).forEach((error) => console.warn(error));
// }
return { paths: allPaths, error };
}
/**
* Helper to safely join URL paths (avoids double slashes)
* eg: pathJoin('/admin', 'users') => '/admin/users'
*/
function pathJoin(parent, child) {
if (!parent)
return child.startsWith('/') ? child : `/${child}`;
return `${parent.replace(/\/+$/, '')}/${child.replace(/^\/+/, '')}`;
}