render.tsx 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. import React from "react";
  2. import satori, { type SatoriOptions } from "satori";
  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. import type { ComponentRegistry } from "./types";
  12. export { standardComponents };
  13. export interface RenderOptions {
  14. registry?: ComponentRegistry;
  15. includeStandard?: boolean;
  16. state?: Record<string, unknown>;
  17. fonts?: SatoriOptions["fonts"];
  18. /** Override the Frame width. When omitted, uses the Frame component's width prop. */
  19. width?: number;
  20. /** Override the Frame height. When omitted, uses the Frame component's height prop. */
  21. height?: number;
  22. }
  23. const noopEmit = () => {};
  24. function renderElement(
  25. elementKey: string,
  26. spec: Spec,
  27. registry: ComponentRegistry,
  28. stateModel: Record<string, unknown>,
  29. repeatItem?: unknown,
  30. repeatIndex?: number,
  31. repeatBasePath?: string,
  32. ): React.ReactElement | null {
  33. const element = spec.elements[elementKey];
  34. if (!element) return null;
  35. const ctx: PropResolutionContext = {
  36. stateModel,
  37. repeatItem,
  38. repeatIndex,
  39. repeatBasePath,
  40. };
  41. if (element.visible !== undefined) {
  42. if (!evaluateVisibility(element.visible, ctx)) {
  43. return null;
  44. }
  45. }
  46. const resolvedProps = resolveElementProps(
  47. element.props as Record<string, unknown>,
  48. ctx,
  49. );
  50. const resolvedElement: UIElement = { ...element, props: resolvedProps };
  51. const Component = registry[resolvedElement.type];
  52. if (!Component) return null;
  53. if (resolvedElement.repeat) {
  54. const items =
  55. (getByPath(stateModel, resolvedElement.repeat.statePath) as
  56. | unknown[]
  57. | undefined) ?? [];
  58. const fragments = items.map((item, index) => {
  59. const key =
  60. resolvedElement.repeat!.key && typeof item === "object" && item !== null
  61. ? String(
  62. (item as Record<string, unknown>)[resolvedElement.repeat!.key!] ??
  63. index,
  64. )
  65. : String(index);
  66. const childPath = `${resolvedElement.repeat!.statePath}/${index}`;
  67. const children = resolvedElement.children?.map((childKey) =>
  68. renderElement(
  69. childKey,
  70. spec,
  71. registry,
  72. stateModel,
  73. item,
  74. index,
  75. childPath,
  76. ),
  77. );
  78. return (
  79. <Component key={key} element={resolvedElement} emit={noopEmit}>
  80. {children}
  81. </Component>
  82. );
  83. });
  84. return <>{fragments}</>;
  85. }
  86. const children = resolvedElement.children?.map((childKey) =>
  87. renderElement(
  88. childKey,
  89. spec,
  90. registry,
  91. stateModel,
  92. repeatItem,
  93. repeatIndex,
  94. repeatBasePath,
  95. ),
  96. );
  97. return (
  98. <Component key={elementKey} element={resolvedElement} emit={noopEmit}>
  99. {children && children.length > 0 ? children : undefined}
  100. </Component>
  101. );
  102. }
  103. interface ImageDimensions {
  104. width: number;
  105. height: number;
  106. }
  107. function getDimensions(
  108. spec: Spec,
  109. options: RenderOptions = {},
  110. ): ImageDimensions {
  111. if (options.width && options.height) {
  112. return { width: options.width, height: options.height };
  113. }
  114. const rootElement = spec.elements[spec.root];
  115. const props = rootElement?.props as Record<string, unknown> | undefined;
  116. return {
  117. width: options.width ?? (props?.width as number) ?? 1200,
  118. height: options.height ?? (props?.height as number) ?? 630,
  119. };
  120. }
  121. function buildTree(
  122. spec: Spec,
  123. options: RenderOptions = {},
  124. ): React.ReactElement {
  125. const {
  126. registry: customRegistry,
  127. includeStandard = true,
  128. state = {},
  129. } = options;
  130. const mergedState: Record<string, unknown> = {
  131. ...spec.state,
  132. ...state,
  133. };
  134. const registry: ComponentRegistry = {
  135. ...(includeStandard ? standardComponents : {}),
  136. ...customRegistry,
  137. };
  138. const root = renderElement(spec.root, spec, registry, mergedState);
  139. return root ?? <></>;
  140. }
  141. /**
  142. * Render a json-render spec to an SVG string.
  143. *
  144. * Uses Satori to convert the spec's component tree into SVG.
  145. * No additional dependencies are needed beyond satori.
  146. */
  147. export async function renderToSvg(
  148. spec: Spec,
  149. options: RenderOptions = {},
  150. ): Promise<string> {
  151. const tree = buildTree(spec, options);
  152. const { width, height } = getDimensions(spec, options);
  153. return satori(tree as React.ReactNode, {
  154. width,
  155. height,
  156. fonts: options.fonts ?? [],
  157. });
  158. }
  159. /**
  160. * Render a json-render spec to a PNG buffer.
  161. *
  162. * Requires `@resvg/resvg-js` to be installed as a peer dependency.
  163. * The SVG is first generated via Satori, then rasterized to PNG.
  164. */
  165. export async function renderToPng(
  166. spec: Spec,
  167. options: RenderOptions = {},
  168. ): Promise<Uint8Array> {
  169. const svg = await renderToSvg(spec, options);
  170. let Resvg: typeof import("@resvg/resvg-js").Resvg;
  171. try {
  172. const mod = await import("@resvg/resvg-js");
  173. Resvg = mod.Resvg;
  174. } catch {
  175. throw new Error(
  176. "@resvg/resvg-js is required for PNG output. Install it with: npm install @resvg/resvg-js",
  177. );
  178. }
  179. const resvg = new Resvg(svg);
  180. const pngData = resvg.render();
  181. return pngData.asPng();
  182. }