Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions packages/react/src/components/Menu.tsx
Original file line number Diff line number Diff line change
@@ -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<SemanticProps<MenuValues>, "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<MenuValues>,
"padding" | "fontFamily" | "fontWeight" | "fontSize" | "letterSpacing"
> &
Omit<TextStyleProps, "color" | "lineHeight"> & {
/** Menu items shorthand */
items?: MenuItem[];
/** Inner padding — a number (→ px) or CSS string. */
padding?: SizeInput;
};

export interface MenuProps extends ItemComponentProps<MenuSemanticProps> {}

Expand Down
7 changes: 7 additions & 0 deletions packages/react/src/dx-types.test-d.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
5 changes: 2 additions & 3 deletions packages/react/src/utils/extract-head.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,8 @@ import { BODY_DEFAULTS, ROW_DEFAULTS, COLUMN_DEFAULTS } from "./container-defaul
type HeadArgs = [Record<string, any>, Record<string, any>, Record<string, any>];

/**
* The head contributions a component can emit — optional css/js/tags builders.
* The exporters' `heads` registry is untyped (Record<string, any>), 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 <head> CSS/JS/tags.
*/
type ComponentHead = {
css?: (...args: HeadArgs) => string | undefined;
Expand Down
27 changes: 27 additions & 0 deletions packages/react/src/utils/render-to-json.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,33 @@ describe("renderToJson", () => {
).toThrow("Root element must be <Body>");
});

it("unwraps a user wrapper component down to its root (parity with renderToHtml)", () => {
const MyEmail = () => (
<Email contentWidth="600px">
<Row layout={ColumnLayouts.OneColumn}>
<Column>
<Heading>Wrapped</Heading>
</Column>
</Row>
</Email>
);
const design = renderToJson(<MyEmail />);
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 = () => <Paragraph>nope</Paragraph>;
expect(() => renderToJson(<NotARoot />)).toThrow("Root element must be <Body>");
});

it("rethrows an actionable error when a wrapper throws while unwrapping", () => {
const Boom = (): React.ReactElement => {
throw new Error("invalid hook call");
};
expect(() => renderToJson(<Boom />)).toThrow("could not unwrap");
});

it("generates _meta at all levels", () => {
const design = renderToJson(
<Body>
Expand Down
47 changes: 45 additions & 2 deletions packages/react/src/utils/render-to-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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* <Email>/<Body>/<Page>/<Document>
* (e.g. `renderToJson(<MyEmail/>)`) 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<string, unknown>) });
} 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 (<Email>, <Page>, ` +
`<Document>, or <Body>) and uses no React hooks. Pass the root element directly — ` +
`e.g. renderToJson(<Email>…</Email>). (${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[] = [];
Expand Down Expand Up @@ -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. <MyEmail/>) 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 <Body>, <Email>, <Page>, or <Document>, ` +
`but got <${displayName || "unknown"}>. ` +
Expand Down
Loading