UNPKG

rasengan

Version:

The modern React Framework

565 lines (564 loc) 23 kB
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(/^\/+/, '')}`; }