UNPKG

@ai-growth/nextjs

Version:

Seamlessly integrate Sanity CMS with Next.js applications for automated blog routing and rendering

337 lines (336 loc) 12.7 kB
import { jsx as _jsx } from "react/jsx-runtime"; import React, { forwardRef, useEffect, useState } from 'react'; import { useRouter } from 'next/router'; import { isValidCmsRoute } from '../utils/route-config'; import { validateContentRoute } from '../utils/cms-content'; import { CmsRouteHandler } from './CmsRouteHandler'; // ============================================================================ // HOC IMPLEMENTATION // ============================================================================ /** * Higher-Order Component that wraps page components with CMS routing capabilities * * This HOC provides automatic CMS content handling for pages by injecting CMS-related * props into the wrapped component. The wrapped component maintains full control over * its rendering while receiving CMS state as props. * * @param WrappedComponent - The page component to wrap with CMS functionality * @param options - Configuration options for CMS behavior * @returns A new component with CMS routing capabilities * * @example * ```tsx * // Basic usage - component receives CMS props * const BlogPage = ({ title, cmsContent, isCmsRoute, cmsLoading }) => { * if (isCmsRoute && cmsLoading) { * return <div>Loading CMS content...</div>; * } * * if (isCmsRoute && cmsContent) { * return ( * <div> * <h1>{cmsContent.title}</h1> * <div>{cmsContent.content}</div> * </div> * ); * } * * return <div>Regular page: {title}</div>; * }; * * export default withCmsRouting(BlogPage); * * // With options * export default withCmsRouting(BlogPage, { * contentOptions: { includeSEO: true, includeAuthor: true }, * onContentLoaded: (content) => console.log('Loaded:', content.title) * }); * * // TypeScript usage * interface BlogPageProps { * title: string; * } * * const BlogPage: React.FC<BlogPageProps & CmsInjectedProps> = ({ * title, * cmsContent, * isCmsRoute, * cmsLoading, * cmsError * }) => { * // Component implementation with full control over rendering * }; * * export default withCmsRouting<BlogPageProps>(BlogPage, options); * ``` */ export function withCmsRouting(WrappedComponent, options = {}) { const { contentOptions = {}, displayName, autoFetch = true, customTemplate, onContentLoaded, onContentError, onRouteChange, } = options; // Create the wrapped component const WithCmsRoutingComponent = forwardRef((props, ref) => { const router = useRouter(); const [cmsState, setCmsState] = useState({ content: null, isLoading: false, error: null, isCmsRoute: false, currentPath: '', }); // ============================================================================ // CONTENT FETCHING // ============================================================================ const fetchContent = async (path) => { try { setCmsState(prev => ({ ...prev, isLoading: true, error: null, })); // Validate the route and fetch content const validationResult = await validateContentRoute(path, contentOptions); if (validationResult.isValid && validationResult.content) { setCmsState(prev => ({ ...prev, isLoading: false, content: validationResult.content, error: null, })); // Notify about successful content load onContentLoaded?.(validationResult.content); } else { const error = validationResult.error || 'Content not found'; setCmsState(prev => ({ ...prev, isLoading: false, content: null, error, })); // Notify about content error onContentError?.(error); } } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to fetch content'; setCmsState(prev => ({ ...prev, isLoading: false, content: null, error: errorMessage, })); // Notify about content error onContentError?.(errorMessage); } }; const handleFetchCmsContent = async () => { if (cmsState.currentPath && cmsState.isCmsRoute) { await fetchContent(cmsState.currentPath); } }; const handleClearCmsContent = () => { setCmsState(prev => ({ ...prev, content: null, error: null, isLoading: false, })); }; // ============================================================================ // ROUTER INTEGRATION // ============================================================================ useEffect(() => { // Skip if router is not ready if (!router.isReady) { return; } const currentPath = router.asPath; // Skip if path hasn't changed if (currentPath === cmsState.currentPath) { return; } try { // Check if this is a valid CMS route const isCmsRoute = isValidCmsRoute(currentPath); // Update state with new path and CMS route status setCmsState(prev => ({ ...prev, currentPath, isCmsRoute, content: null, error: null, isLoading: false, })); // Notify about route change onRouteChange?.(currentPath, isCmsRoute); // Fetch content if this is a CMS route and autoFetch is enabled if (isCmsRoute && autoFetch) { fetchContent(currentPath); } } catch { // If route validation fails, treat as non-CMS route setCmsState(prev => ({ ...prev, currentPath, isCmsRoute: false, content: null, error: null, isLoading: false, })); onRouteChange?.(currentPath, false); } }, [router.isReady, router.asPath, cmsState.currentPath, contentOptions, onRouteChange, autoFetch]); // ============================================================================ // RENDERING // ============================================================================ // Create enhanced props with CMS data const enhancedProps = React.useMemo(() => ({ ...props, cmsContent: cmsState.content, isCmsRoute: cmsState.isCmsRoute, cmsLoading: cmsState.isLoading, cmsError: cmsState.error, fetchCmsContent: handleFetchCmsContent, clearCmsContent: handleClearCmsContent, }), [props, cmsState]); // If we have a custom template and this is a CMS route, use CmsRouteHandler if (customTemplate && cmsState.isCmsRoute) { const cmsHandlerProps = { customTemplate, contentOptions, }; if (onContentLoaded) cmsHandlerProps.onContentLoaded = onContentLoaded; if (onContentError) cmsHandlerProps.onContentError = onContentError; if (onRouteChange) cmsHandlerProps.onRouteChange = onRouteChange; return (_jsx(CmsRouteHandler, { ...cmsHandlerProps, children: _jsx(WrappedComponent, { ...props }) })); } // Always render the wrapped component with enhanced props return _jsx(WrappedComponent, { ...enhancedProps, ref: ref }); }); // Set display name for debugging const componentName = displayName || WrappedComponent.displayName || WrappedComponent.name || 'Component'; WithCmsRoutingComponent.displayName = `withCmsRouting(${componentName})`; // Copy static properties from the wrapped component (improved version) const hoistStatics = (target, source) => { // Get all property names from the source component const sourceProperties = Object.getOwnPropertyNames(source); // List of properties that should not be copied const skipProperties = [ 'prototype', 'length', 'name', 'caller', 'callee', 'arguments', 'constructor' ]; sourceProperties.forEach(propName => { if (!skipProperties.includes(propName)) { try { const descriptor = Object.getOwnPropertyDescriptor(source, propName); if (descriptor && (descriptor.value !== undefined || descriptor.get)) { Object.defineProperty(target, propName, descriptor); } } catch { // Ignore copy failures for read-only properties } } }); return target; }; return hoistStatics(WithCmsRoutingComponent, WrappedComponent); } // ============================================================================ // CONVENIENCE FUNCTIONS // ============================================================================ /** * Creates a pre-configured version of withCmsRouting with default options * * @param defaultOptions - Default options to apply to all wrapped components * @returns A withCmsRouting function with pre-configured options * * @example * ```tsx * // Create a factory with common options * const withBlogCms = createCmsRoutingFactory({ * contentOptions: { includeSEO: true, includeAuthor: true }, * autoFetch: true, * }); * * // Use the factory * const BlogPage = ({ title, cmsContent, isCmsRoute }) => { * if (isCmsRoute && cmsContent) { * return <div>CMS: {cmsContent.title}</div>; * } * return <div>Regular: {title}</div>; * }; * export default withBlogCms(BlogPage); * * // Override specific options when needed * const SpecialBlogPage = ({ title }) => <div>{title}</div>; * export default withBlogCms(SpecialBlogPage, { * autoFetch: false * }); * ``` */ export function createCmsRoutingFactory(defaultOptions) { return function (WrappedComponent, overrideOptions = {}) { const mergedOptions = { ...defaultOptions, ...overrideOptions, // Merge nested objects contentOptions: { ...defaultOptions.contentOptions, ...overrideOptions.contentOptions, }, }; return withCmsRouting(WrappedComponent, mergedOptions); }; } /** * Hook to access CMS routing state from within a component wrapped by withCmsRouting * * Note: When using withCmsRouting, CMS state is available directly as props. * This hook is provided for consistency but will warn and return default values. * * @returns CMS routing state including content, loading, and error information * * @example * ```tsx * function MyPageComponent({ cmsContent, isCmsRoute, cmsLoading, cmsError }) { * // Access CMS state directly from props when using withCmsRouting * * if (cmsLoading) { * return <div>Loading CMS content...</div>; * } * * if (cmsError) { * return <div>Error: {cmsError}</div>; * } * * if (isCmsRoute && cmsContent) { * return <div>CMS: {cmsContent.title}</div>; * } * * return <div>Regular page content</div>; * } * ``` */ export function useCmsRoutingContext() { // This hook would typically use a context provider, but since we're injecting // props directly into components, we'll provide a warning for now console.warn('useCmsRoutingContext: CMS state is available as props when using withCmsRouting. ' + 'Access cmsContent, isCmsRoute, cmsLoading, and cmsError from component props instead.'); return { isCmsRoute: false, cmsLoading: false, cmsError: null, }; } export default withCmsRouting;