|
| 1 | +import { |
| 2 | + type ComponentType, |
| 3 | + cloneElement, |
| 4 | + isValidElement, |
| 5 | + type ReactNode, |
| 6 | +} from 'react'; |
| 7 | +import type { Options } from './options'; |
| 8 | + |
| 9 | +const RENDER_OPTIONS_SYMBOL = Symbol.for('react-email.withRenderOptions'); |
| 10 | + |
| 11 | +/** Extends component props with optional render options. */ |
| 12 | +export type PropsWithRenderOptions<P = unknown> = P & { |
| 13 | + renderOptions?: Options; |
| 14 | +}; |
| 15 | + |
| 16 | +/** Component wrapped with withRenderOptions, marked with a symbol. */ |
| 17 | +type ComponentWithRenderOptions<P = unknown> = ComponentType< |
| 18 | + PropsWithRenderOptions<P> |
| 19 | +> & { |
| 20 | + [RENDER_OPTIONS_SYMBOL]: true; |
| 21 | +}; |
| 22 | + |
| 23 | +/** |
| 24 | + * Wraps a component to receive render options as props. |
| 25 | + * |
| 26 | + * @param Component - The component to wrap. |
| 27 | + * @return A component that accepts `renderOptions` prop. |
| 28 | + * |
| 29 | + * @example |
| 30 | + * ```tsx |
| 31 | + * export const EmailTemplate = withRenderOptions(({ renderOptions }) => { |
| 32 | + * if (renderOptions?.plainText) { |
| 33 | + * return 'Plain text version'; |
| 34 | + * } |
| 35 | + * return <div><h1>HTML version</h1></div>; |
| 36 | + * }); |
| 37 | + * ``` |
| 38 | + */ |
| 39 | +export function withRenderOptions<P = unknown>( |
| 40 | + Component: ComponentType<PropsWithRenderOptions<P>>, |
| 41 | +): ComponentWithRenderOptions<P> { |
| 42 | + const WrappedComponent = Component as ComponentWithRenderOptions<P>; |
| 43 | + WrappedComponent[RENDER_OPTIONS_SYMBOL] = true; |
| 44 | + WrappedComponent.displayName = `withRenderOptions(${Component.displayName || Component.name || 'Component'})`; |
| 45 | + return WrappedComponent; |
| 46 | +} |
| 47 | + |
| 48 | +/** @internal */ |
| 49 | +function isWithRenderOptionsComponent( |
| 50 | + component: unknown, |
| 51 | +): component is ComponentWithRenderOptions { |
| 52 | + return ( |
| 53 | + !!component && |
| 54 | + typeof component === 'function' && |
| 55 | + RENDER_OPTIONS_SYMBOL in component && |
| 56 | + component[RENDER_OPTIONS_SYMBOL] === true |
| 57 | + ); |
| 58 | +} |
| 59 | + |
| 60 | +/** |
| 61 | + * Injects render options into components wrapped with `withRenderOptions`. |
| 62 | + * Returns node unchanged if not wrapped or not a valid element. |
| 63 | + * |
| 64 | + * @param node - The React node to inject options into. |
| 65 | + * @param options - The render options to inject. |
| 66 | + * @returns The node with injected options if applicable, otherwise the original node. |
| 67 | + */ |
| 68 | +export function injectRenderOptions( |
| 69 | + node: ReactNode, |
| 70 | + options?: Options, |
| 71 | +): ReactNode { |
| 72 | + if (!isValidElement(node)) return node; |
| 73 | + if (!isWithRenderOptionsComponent(node.type)) return node; |
| 74 | + const renderOptionsProps = { renderOptions: options }; |
| 75 | + return cloneElement(node, renderOptionsProps); |
| 76 | +} |
0 commit comments