diff --git a/packages/react/src/components/Menu.tsx b/packages/react/src/components/Menu.tsx index e1f2af1..9d947ac 100644 --- a/packages/react/src/components/Menu.tsx +++ b/packages/react/src/components/Menu.tsx @@ -1,14 +1,22 @@ import { MenuExporters, MenuDefaults } from "@unlayer/exporters"; -import type { MenuValues, MenuItem, SizeInput } from "../types"; +import type { MenuValues, MenuItem, SizeInput, TextStyleProps } from "../types"; import { createItemComponent, type ItemComponentProps } from "../utils/create-component"; import { mapSemanticProps, type SemanticProps } from "../utils/semantic-props"; -type MenuSemanticProps = Omit, "padding"> & { - /** Menu items shorthand */ - items?: MenuItem[]; - /** Inner padding — a number (→ px) or CSS string. */ - padding?: SizeInput; -}; +// Menu carries fontFamily/fontWeight/fontSize/letterSpacing (but not color or +// lineHeight) — relax those to the same agent-friendly inputs Heading/Paragraph +// use, so a string fontFamily or a number/em size type-checks (normalized at +// render time). It has no `color` field (uses linkColor/textColor). +type MenuSemanticProps = Omit< + SemanticProps, + "padding" | "fontFamily" | "fontWeight" | "fontSize" | "letterSpacing" +> & + Omit & { + /** Menu items shorthand */ + items?: MenuItem[]; + /** Inner padding — a number (→ px) or CSS string. */ + padding?: SizeInput; + }; export interface MenuProps extends ItemComponentProps {} diff --git a/packages/react/src/dx-types.test-d.tsx b/packages/react/src/dx-types.test-d.tsx index 951a196..a04264c 100644 --- a/packages/react/src/dx-types.test-d.tsx +++ b/packages/react/src/dx-types.test-d.tsx @@ -53,6 +53,13 @@ export const _table_padding_num: TableProps["padding"] = 12; export const _table_border_factored: TableProps["border"] = HAIRLINE; export const _divider_border_factored: DividerProps["border"] = HAIRLINE; +// Menu's text inputs are relaxed to match Heading/Paragraph (string fontFamily, +// number/em sizes) — it has fontFamily/fontWeight/fontSize/letterSpacing. +export const _menu_fontFamily_string: MenuProps["fontFamily"] = "Arial"; +export const _menu_fontSize_num: MenuProps["fontSize"] = 14; +export const _menu_letterSpacing_em: MenuProps["letterSpacing"] = "0.08em"; +export const _menu_fontWeight_num: MenuProps["fontWeight"] = 700; + // ── the rest of the natural DX surface (broader contract) ─────────────────── export const _fontSize_number: HeadingProps["fontSize"] = 28; export const _fontSize_string: HeadingProps["fontSize"] = "28px"; diff --git a/packages/react/src/utils/extract-head.ts b/packages/react/src/utils/extract-head.ts index 730f368..06556d8 100644 --- a/packages/react/src/utils/extract-head.ts +++ b/packages/react/src/utils/extract-head.ts @@ -19,9 +19,8 @@ import { BODY_DEFAULTS, ROW_DEFAULTS, COLUMN_DEFAULTS } from "./container-defaul type HeadArgs = [Record, Record, Record]; /** - * The head contributions a component can emit — optional css/js/tags builders. - * The exporters' `heads` registry is untyped (Record), so describe - * the shape this file calls. + * Local type for a component's head contributions — the optional css/js/tags + * builders this file invokes to collect the CSS/JS/tags. */ type ComponentHead = { css?: (...args: HeadArgs) => string | undefined; diff --git a/packages/react/src/utils/render-to-json.test.tsx b/packages/react/src/utils/render-to-json.test.tsx index 20364a2..f91e44a 100644 --- a/packages/react/src/utils/render-to-json.test.tsx +++ b/packages/react/src/utils/render-to-json.test.tsx @@ -310,6 +310,33 @@ describe("renderToJson", () => { ).toThrow("Root element must be "); }); + it("unwraps a user wrapper component down to its root (parity with renderToHtml)", () => { + const MyEmail = () => ( + + + + Wrapped + + + + ); + const design = renderToJson(); + expect(design.body.rows.length).toBe(1); + expect(design.body.rows[0].columns.length).toBe(1); + }); + + it("still throws when a wrapper does not resolve to a valid root", () => { + const NotARoot = () => nope; + expect(() => renderToJson()).toThrow("Root element must be "); + }); + + it("rethrows an actionable error when a wrapper throws while unwrapping", () => { + const Boom = (): React.ReactElement => { + throw new Error("invalid hook call"); + }; + expect(() => renderToJson()).toThrow("could not unwrap"); + }); + it("generates _meta at all levels", () => { const design = renderToJson( diff --git a/packages/react/src/utils/render-to-json.ts b/packages/react/src/utils/render-to-json.ts index 5b1e428..05852e6 100644 --- a/packages/react/src/utils/render-to-json.ts +++ b/packages/react/src/utils/render-to-json.ts @@ -38,6 +38,47 @@ function getDisplayName(element: React.ReactElement): string | undefined { return type?.displayName || type?.name; } +/** The root components renderToJson understands. */ +const VALID_ROOTS = new Set(["Body", "Email", "Page", "Document"]); + +/** + * Unwrap a user wrapper component down to the underlying root element. + * `renderToHtml` renders wrappers through React; `renderToJson` walks the element + * tree, so a custom component that *returns* /// + * (e.g. `renderToJson()`) must be invoked first. Only plain function + * components are unwrapped — anything else (class / forwardRef / memo) falls + * through to the clear root-type error. + */ +function unwrapRoot(element: React.ReactElement): React.ReactElement { + let current = element; + for (let depth = 0; depth < 10; depth++) { + const name = getDisplayName(current); + if (name && VALID_ROOTS.has(name)) break; + const type = current.type as any; + const isPlainFunctionComponent = + typeof type === "function" && !type.prototype?.isReactComponent; + if (!isPlainFunctionComponent) break; + // Invoking the wrapper can throw (e.g. it uses React hooks, which aren't + // valid when called outside React's render). Turn that into an actionable + // message instead of a bare "Invalid hook call". + let produced: unknown; + try { + produced = type({ ...(current.props as Record) }); + } catch (cause) { + const detail = cause instanceof Error ? cause.message : String(cause); + throw new Error( + `[Unlayer] renderToJson: could not unwrap <${name || "wrapper"}>. A wrapper must ` + + `be a plain component that synchronously returns a root (, , ` + + `, or ) and uses no React hooks. Pass the root element directly — ` + + `e.g. renderToJson(). (${detail})` + ); + } + if (!React.isValidElement(produced)) break; + current = produced; + } + return current; +} + /** Collect valid React element children from a node. */ function collectChildren(node: React.ReactNode): React.ReactElement[] { const result: React.ReactElement[] = []; @@ -408,10 +449,12 @@ export function renderRowToJson(element: React.ReactElement): DesignRow { } export function renderToJson(element: React.ReactElement): DesignJSON { + // Accept a user wrapper component (e.g. ) by unwrapping to its root, + // matching renderToHtml which renders wrappers through React. + element = unwrapRoot(element); const displayName = getDisplayName(element); - const validRoots = new Set(["Body", "Email", "Page", "Document"]); - if (!displayName || !validRoots.has(displayName)) { + if (!displayName || !VALID_ROOTS.has(displayName)) { throw new Error( `[Unlayer] renderToJson: Root element must be , , , or , ` + `but got <${displayName || "unknown"}>. ` +