@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
JavaScript
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;