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
7 changes: 7 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ jobs:
- name: Build packages
run: pnpm build

# ── DX type contract ────────────────────────────────────────
# tsc gate over the agent-friendly prop types (src/dx-types.test-d.tsx).
# The build only type-checks the index import graph, so this catches a
# relaxed input type being reverted (e.g. the Column `border` regression).
- name: Type contract
run: pnpm --filter @unlayer/react-elements typecheck

# ── Bundle size budget ──────────────────────────────────────
# Fail if the react-elements ESM bundle exceeds 60KB.
# Current size: ~49KB (14+ components). Budget gives room for
Expand Down
1 change: 1 addition & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
},
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"test": "vitest",
Expand Down
6 changes: 4 additions & 2 deletions packages/react/src/components/Body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { mapSemanticProps, type SemanticProps } from "../utils/semantic-props";
import type { SizeInput } from "../types";
import { BODY_DEFAULTS } from "../utils/container-defaults";

export type BodyProps = Omit<SemanticProps<BodyValues>, "padding"> & {
export type BodyProps = Omit<SemanticProps<BodyValues>, "padding" | "borderRadius"> & {
children?: React.ReactNode;
mode?: RenderMode;
className?: string;
Expand All @@ -19,6 +19,8 @@ export type BodyProps = Omit<SemanticProps<BodyValues>, "padding"> & {
previewText?: string;
/** Padding — a CSS string ("0 48px", "20px") or a number (px). */
padding?: SizeInput;
/** Corner radius — a number (→ px) or CSS string ("8px"). */
borderRadius?: SizeInput;
};

const DEFAULT_VALUES = BODY_DEFAULTS;
Expand Down Expand Up @@ -137,7 +139,7 @@ const Body: React.FC<BodyProps> = (props) => {
// Outlook table, container, and grid CSS disagree. Mapped values win.
const values = mergeValues<BodyValues>(
DEFAULT_VALUES,
mapSemanticProps<BodyValues>(semanticProps, DEFAULT_VALUES, "Body")
mapSemanticProps<BodyValues>(semanticProps as SemanticProps<BodyValues>, DEFAULT_VALUES, "Body")
);

// Ensure _meta
Expand Down
10 changes: 8 additions & 2 deletions packages/react/src/components/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ButtonExporters, ButtonDefaults } from "@unlayer/exporters";
import type { ButtonValues, TextStyleProps, SizeInput } from "../types";
import type { ButtonValues, TextStyleProps, SizeInput, BorderInput } from "../types";
import { createItemComponent, type ItemComponentProps } from "../utils/create-component";
import { mapSemanticProps, type SemanticProps } from "../utils/semantic-props";

Expand All @@ -9,11 +9,17 @@ import { mapSemanticProps, type SemanticProps } from "../utils/semantic-props";
*/
type ButtonSemanticProps = Omit<
SemanticProps<ButtonValues>,
keyof TextStyleProps | "width"
keyof TextStyleProps | "width" | "padding" | "borderRadius" | "border"
> &
TextStyleProps & {
/** Display width — a number/px pins the button; "100%" makes it full-width. */
width?: SizeInput;
/** Inner padding — a number (→ px) or CSS string ("14px 28px"). */
padding?: SizeInput;
/** Corner radius — a number (→ px) or CSS string ("8px", "500px"). */
borderRadius?: SizeInput;
/** Per-side border object (width fields accept a number/px string). */
border?: BorderInput;
};

/**
Expand Down
9 changes: 7 additions & 2 deletions packages/react/src/components/Column.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { RenderMode, UnlayerConfig, ColumnValues } from "@unlayer-internal/
import { ColumnExporters, ContentExporters } from "@unlayer/exporters";
import { UNLAYER_RENDER_KEY } from "../utils/create-component";
import { mapSemanticProps, type SemanticProps } from "../utils/semantic-props";
import type { SizeInput } from "../types";
import type { SizeInput, BorderInput } from "../types";
import { COLUMN_DEFAULTS } from "../utils/container-defaults";

/** Unlayer's default content-block padding when a block sets none. */
Expand Down Expand Up @@ -56,7 +56,7 @@ function renderContentToHtml(innerHTML: string, values: any, bodyValues: any, mo
// Component
// ============================================

export type ColumnProps = Omit<SemanticProps<ColumnValues>, "padding"> & {
export type ColumnProps = Omit<SemanticProps<ColumnValues>, "padding" | "border" | "borderRadius"> & {
children?: React.ReactNode;
// Internal props (provided by Row)
index?: number;
Expand All @@ -68,6 +68,11 @@ export type ColumnProps = Omit<SemanticProps<ColumnValues>, "padding"> & {
style?: React.CSSProperties;
/** Padding — a CSS string ("0 24px", "10px") or a number (px). */
padding?: SizeInput;
/** Corner radius — a number (→ px) or CSS string ("8px"). */
borderRadius?: SizeInput;
/** Per-side border object (great for hairline dividers). Width fields accept
* a number/px string; reuse it as a factored-out const without `as const`. */
border?: BorderInput;
/** @internal - Unlayer config threaded from UnlayerProvider via Body/Row */
_config?: UnlayerConfig;
};
Expand Down
13 changes: 9 additions & 4 deletions packages/react/src/components/Divider.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { DividerExporters, DividerDefaults } from "@unlayer/exporters";
import type { DividerValues } from "../types";
import type { DividerValues, BorderInput } from "../types";
import { createItemComponent, type ItemComponentProps } from "../utils/create-component";
import { mapSemanticProps, type SemanticProps } from "../utils/semantic-props";

export interface DividerProps extends ItemComponentProps<SemanticProps<DividerValues>> {}
type DividerSemanticProps = Omit<SemanticProps<DividerValues>, "border"> & {
/** Per-side border object (width fields accept a number/px string). */
border?: BorderInput;
};

export interface DividerProps extends ItemComponentProps<DividerSemanticProps> {}

// Defaults from the editor schema
const DEFAULT_VALUES = {
Expand All @@ -26,10 +31,10 @@ const DEFAULT_VALUES = {
* }} />
* ```
*/
const Divider = createItemComponent<DividerValues, SemanticProps<DividerValues>>({
const Divider = createItemComponent<DividerValues, DividerSemanticProps>({
name: "Divider",
defaultValues: DEFAULT_VALUES,
propMapper: (props) => mapSemanticProps(props, DEFAULT_VALUES, "Divider"),
propMapper: (props) => mapSemanticProps(props as SemanticProps<DividerValues>, DEFAULT_VALUES, "Divider"),
displayName: "Divider",
exporters: DividerExporters,
});
Expand Down
10 changes: 5 additions & 5 deletions packages/react/src/components/Menu.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { MenuExporters, MenuDefaults } from "@unlayer/exporters";
import type { MenuValues, MenuItem } from "../types";
import type { MenuValues, MenuItem, SizeInput } from "../types";
import { createItemComponent, type ItemComponentProps } from "../utils/create-component";
import { mapSemanticProps, type SemanticProps } from "../utils/semantic-props";

type MenuSemanticProps = SemanticProps<MenuValues> & {
type MenuSemanticProps = Omit<SemanticProps<MenuValues>, "padding"> & {
/** Menu items shorthand */
items?: MenuItem[];
/** Inner padding — a number (→ px) or CSS string. */
padding?: SizeInput;
};

export interface MenuProps extends ItemComponentProps<SemanticProps<MenuValues>> {
items?: MenuItem[];
}
export interface MenuProps extends ItemComponentProps<MenuSemanticProps> {}

// Defaults from the editor schema
const DEFAULT_MENU: NonNullable<MenuValues["menu"]> = MenuDefaults.menu ?? { items: [] };
Expand Down
15 changes: 12 additions & 3 deletions packages/react/src/components/Table.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
import { TableExporters, TableDefaults } from "@unlayer/exporters";
import type { TableValues } from "../types";
import type { TableValues, SizeInput, BorderInput } from "../types";
import { createItemComponent, type ItemComponentProps } from "../utils/create-component";
import { mapSemanticProps, type SemanticProps } from "../utils/semantic-props";

type TableSemanticProps = SemanticProps<TableValues> & {
type TableSemanticProps = Omit<SemanticProps<TableValues>, "padding" | "border"> & {
/** Column headers as string[] */
headers?: string[];
/** Row data as 2D string array */
data?: string[][];
/** Inner padding — a number (→ px) or CSS string. */
padding?: SizeInput;
/** Per-side border object (width fields accept a number/px string). */
border?: BorderInput;
};

export interface TableProps extends Omit<ItemComponentProps<SemanticProps<TableValues>>, "headers" | "data"> {
export interface TableProps
extends Omit<ItemComponentProps<Omit<SemanticProps<TableValues>, "padding" | "border">>, "headers" | "data"> {
headers?: string[];
data?: string[][];
/** Inner padding — a number (→ px) or CSS string. */
padding?: SizeInput;
/** Per-side border object (width fields accept a number/px string). */
border?: BorderInput;
}
Comment on lines +6 to 25

// Defaults from the editor schema, plus table data structure
Expand Down
73 changes: 73 additions & 0 deletions packages/react/src/dx-types.test-d.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Type-level regression guard for the agent-friendly DX surface.
*
* These are NOT vitest tests — they are compile-time assertions checked by
* `pnpm typecheck` (tsc --noEmit -p tsconfig.typecheck.json), which CI runs.
* The file imports only TYPES, so it stays out of the render graph (no
* storybook / @unlayer/exporters resolution noise) and type-checks in isolation.
*
* Each `const _x: SomeType = value` asserts a natural authoring form compiles;
* each `@ts-expect-error` asserts garbage is still rejected (tsc fails if the
* directive becomes unused — i.e. the bad form started compiling). If a relaxed
* input type is reverted, the matching assertion below stops compiling.
*/
// Import the ACTUAL component prop types (what `<X>` accepts in JSX), not just
// the exported aliases — these are the types that must stay agent-friendly.
import type { ColumnProps } from "./components/Column";
import type { ButtonProps } from "./components/Button";
import type { MenuProps } from "./components/Menu";
import type { TableProps } from "./components/Table";
import type { DividerProps } from "./components/Divider";
import type { HeadingProps } from "./components/Heading";
import type { ParagraphProps } from "./components/Paragraph";
import type { ImageProps } from "./components/Image";
import type { BorderInput } from "./types";

// ── border: THE regression this guard exists for ────────────────────────────
// A reusable hairline object factored into a `const` (no `as const`) must satisfy
// the Column `border` type. Before BorderInput, the per-side *Width was pinned to
// `${number}px`, so the widened `string` failed strict tsc — see the fix.
const HAIRLINE = {
borderBottomWidth: "1px",
borderBottomStyle: "solid",
borderBottomColor: "#E3E8EE",
};
export const _border_factored_const: BorderInput = HAIRLINE;
export const _border_on_column: ColumnProps["border"] = HAIRLINE;
export const _border_numeric_width: BorderInput = {
borderTopWidth: 2,
borderTopStyle: "solid",
borderTopColor: "#222222",
};
// @ts-expect-error a border is an object of per-side props, never a bare CSS string
export const _border_reject_string: ColumnProps["border"] = "1px solid #ccc";

// ── box-model dimensions: bare number (→ px) + factored border, across the
// ACTUAL component types (the canonical schema pins these to `${number}px`).
export const _col_radius_num: ColumnProps["borderRadius"] = 8;
export const _btn_radius_num: ButtonProps["borderRadius"] = 8;
export const _btn_padding_num: ButtonProps["padding"] = 14;
export const _btn_border_factored: ButtonProps["border"] = HAIRLINE;
export const _menu_padding_num: MenuProps["padding"] = 10;
export const _table_padding_num: TableProps["padding"] = 12;
export const _table_border_factored: TableProps["border"] = HAIRLINE;
export const _divider_border_factored: DividerProps["border"] = HAIRLINE;

// ── the rest of the natural DX surface (broader contract) ───────────────────
export const _fontSize_number: HeadingProps["fontSize"] = 28;
export const _fontSize_string: HeadingProps["fontSize"] = "28px";
export const _fontWeight_number: HeadingProps["fontWeight"] = 700;
export const _fontWeight_numeric_string: HeadingProps["fontWeight"] = "700";
export const _fontWeight_keyword: HeadingProps["fontWeight"] = "bold";
export const _fontFamily_string: HeadingProps["fontFamily"] = "Arial";
export const _fontFamily_object: HeadingProps["fontFamily"] = {
label: "Arial",
value: "arial,sans-serif",
};
export const _lineHeight_number: ParagraphProps["lineHeight"] = 1.4;
export const _button_full_width: ButtonProps["width"] = "100%";
export const _button_px: ButtonProps["width"] = 200;
export const _image_percent: ImageProps["maxWidth"] = "50%";

// @ts-expect-error fontWeight does not accept arbitrary words
export const _fontWeight_reject: HeadingProps["fontWeight"] = "heavy";
25 changes: 11 additions & 14 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,6 @@ import { htmlToTextJson } from "@unlayer-internal/shared-elements";

// 🎯 Export clean public types (hiding internal implementation details)
export type {
// Clean component prop types
ButtonProps,
DividerProps,
HeadingProps,
HtmlProps,
ImageProps,
MenuProps,
ParagraphProps,
SocialProps,
TableProps,
VideoProps,
// Value types for configuration
ButtonValues,
DividerValues,
Expand Down Expand Up @@ -76,10 +65,18 @@ export type {
DesignContent,
} from "@unlayer-internal/shared-elements";

// Export Row props separately since it has a custom interface
// Component prop types — each lives with its component (single source of truth).
export type { ButtonProps } from "./components/Button";
export type { DividerProps } from "./components/Divider";
export type { HeadingProps } from "./components/Heading";
export type { HtmlProps } from "./components/Html";
export type { ImageProps } from "./components/Image";
export type { MenuProps } from "./components/Menu";
export type { ParagraphProps } from "./components/Paragraph";
export type { SocialProps } from "./components/Social";
export type { TableProps } from "./components/Table";
export type { VideoProps } from "./components/Video";
export type { RowProps } from "./components/Row";

// Export semantic wrapper prop types
export type { EmailProps } from "./components/Email";
export type { PageProps } from "./components/Page";
export type { DocumentProps } from "./components/Document";
Expand Down
Loading
Loading