render.tsx 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. import React from "react";
  2. import { render } from "@react-email/render";
  3. import type { Spec, UIElement } from "@json-render/core";
  4. import {
  5. resolveElementProps,
  6. evaluateVisibility,
  7. getByPath,
  8. type PropResolutionContext,
  9. } from "@json-render/core";
  10. import { standardComponents } from "./components/standard";
  11. // Re-export the standard components for use in custom registries
  12. export { standardComponents };
  13. export type RenderComponentRegistry = Record<string, React.ComponentType<any>>;
  14. export interface RenderOptions {
  15. registry?: RenderComponentRegistry;
  16. includeStandard?: boolean;
  17. state?: Record<string, unknown>;
  18. }
  19. const noopEmit = () => {};
  20. function renderElement(
  21. elementKey: string,
  22. spec: Spec,
  23. registry: RenderComponentRegistry,
  24. stateModel: Record<string, unknown>,
  25. repeatItem?: unknown,
  26. repeatIndex?: number,
  27. repeatBasePath?: string,
  28. ): React.ReactElement | null {
  29. const element = spec.elements[elementKey];
  30. if (!element) return null;
  31. const ctx: PropResolutionContext = {
  32. stateModel,
  33. repeatItem,
  34. repeatIndex,
  35. repeatBasePath,
  36. };
  37. if (element.visible !== undefined) {
  38. if (!evaluateVisibility(element.visible, ctx)) {
  39. return null;
  40. }
  41. }
  42. const resolvedProps = resolveElementProps(
  43. element.props as Record<string, unknown>,
  44. ctx,
  45. );
  46. const resolvedElement: UIElement = { ...element, props: resolvedProps };
  47. const Component = registry[resolvedElement.type];
  48. if (!Component) return null;
  49. if (resolvedElement.repeat) {
  50. const items =
  51. (getByPath(stateModel, resolvedElement.repeat.statePath) as
  52. | unknown[]
  53. | undefined) ?? [];
  54. const repeat = resolvedElement.repeat!;
  55. const fragments = items.map((item, index) => {
  56. const repeatKey = repeat.key;
  57. const key =
  58. repeatKey && typeof item === "object" && item !== null
  59. ? String((item as Record<string, unknown>)[repeatKey] ?? index)
  60. : String(index);
  61. const childPath = `${repeat.statePath}/${index}`;
  62. const children = resolvedElement.children?.map((childKey) =>
  63. renderElement(
  64. childKey,
  65. spec,
  66. registry,
  67. stateModel,
  68. item,
  69. index,
  70. childPath,
  71. ),
  72. );
  73. return (
  74. <Component key={key} element={resolvedElement} emit={noopEmit}>
  75. {children}
  76. </Component>
  77. );
  78. });
  79. return <>{fragments}</>;
  80. }
  81. const children = resolvedElement.children?.map((childKey) =>
  82. renderElement(
  83. childKey,
  84. spec,
  85. registry,
  86. stateModel,
  87. repeatItem,
  88. repeatIndex,
  89. repeatBasePath,
  90. ),
  91. );
  92. return (
  93. <Component key={elementKey} element={resolvedElement} emit={noopEmit}>
  94. {children && children.length > 0 ? children : undefined}
  95. </Component>
  96. );
  97. }
  98. function buildDocument(
  99. spec: Spec,
  100. options: RenderOptions = {},
  101. ): React.ReactElement {
  102. const {
  103. registry: customRegistry,
  104. includeStandard = true,
  105. state = {},
  106. } = options;
  107. const mergedState: Record<string, unknown> = {
  108. ...spec.state,
  109. ...state,
  110. };
  111. const registry: RenderComponentRegistry = {
  112. ...(includeStandard ? standardComponents : {}),
  113. ...customRegistry,
  114. };
  115. const root = renderElement(spec.root, spec, registry, mergedState);
  116. if (!root) {
  117. console.warn(
  118. `[json-render/react-email] Root element "${spec.root}" not found in spec.elements`,
  119. );
  120. }
  121. return root ?? <></>;
  122. }
  123. /**
  124. * Render a json-render spec to an HTML email string.
  125. *
  126. * This is a standalone server-side function that resolves the spec tree
  127. * without React hooks or contexts, making it safe to import in Next.js
  128. * route handlers and other server-only environments.
  129. */
  130. export async function renderToHtml(
  131. spec: Spec,
  132. options?: RenderOptions,
  133. ): Promise<string> {
  134. const document = buildDocument(spec, options);
  135. return render(document);
  136. }
  137. /**
  138. * Render a json-render spec to a plain text email string.
  139. */
  140. export async function renderToPlainText(
  141. spec: Spec,
  142. options?: RenderOptions,
  143. ): Promise<string> {
  144. const document = buildDocument(spec, options);
  145. return render(document, { plainText: true });
  146. }