diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6844aa2..f9e4193 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/packages/react/package.json b/packages/react/package.json index 989493e..0daf0b4 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -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", diff --git a/packages/react/src/components/Body.tsx b/packages/react/src/components/Body.tsx index 152854a..a36508a 100644 --- a/packages/react/src/components/Body.tsx +++ b/packages/react/src/components/Body.tsx @@ -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, "padding"> & { +export type BodyProps = Omit, "padding" | "borderRadius"> & { children?: React.ReactNode; mode?: RenderMode; className?: string; @@ -19,6 +19,8 @@ export type BodyProps = Omit, "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; @@ -137,7 +139,7 @@ const Body: React.FC = (props) => { // Outlook table, container, and grid CSS disagree. Mapped values win. const values = mergeValues( DEFAULT_VALUES, - mapSemanticProps(semanticProps, DEFAULT_VALUES, "Body") + mapSemanticProps(semanticProps as SemanticProps, DEFAULT_VALUES, "Body") ); // Ensure _meta diff --git a/packages/react/src/components/Button.tsx b/packages/react/src/components/Button.tsx index 2948672..70cdfd6 100644 --- a/packages/react/src/components/Button.tsx +++ b/packages/react/src/components/Button.tsx @@ -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"; @@ -9,11 +9,17 @@ import { mapSemanticProps, type SemanticProps } from "../utils/semantic-props"; */ type ButtonSemanticProps = Omit< SemanticProps, - 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; }; /** diff --git a/packages/react/src/components/Column.tsx b/packages/react/src/components/Column.tsx index 5b9984d..0075acf 100644 --- a/packages/react/src/components/Column.tsx +++ b/packages/react/src/components/Column.tsx @@ -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. */ @@ -56,7 +56,7 @@ function renderContentToHtml(innerHTML: string, values: any, bodyValues: any, mo // Component // ============================================ -export type ColumnProps = Omit, "padding"> & { +export type ColumnProps = Omit, "padding" | "border" | "borderRadius"> & { children?: React.ReactNode; // Internal props (provided by Row) index?: number; @@ -68,6 +68,11 @@ export type ColumnProps = Omit, "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; }; diff --git a/packages/react/src/components/Divider.tsx b/packages/react/src/components/Divider.tsx index 47e217c..f78d88e 100644 --- a/packages/react/src/components/Divider.tsx +++ b/packages/react/src/components/Divider.tsx @@ -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> {} +type DividerSemanticProps = Omit, "border"> & { + /** Per-side border object (width fields accept a number/px string). */ + border?: BorderInput; +}; + +export interface DividerProps extends ItemComponentProps {} // Defaults from the editor schema const DEFAULT_VALUES = { @@ -26,10 +31,10 @@ const DEFAULT_VALUES = { * }} /> * ``` */ -const Divider = createItemComponent>({ +const Divider = createItemComponent({ name: "Divider", defaultValues: DEFAULT_VALUES, - propMapper: (props) => mapSemanticProps(props, DEFAULT_VALUES, "Divider"), + propMapper: (props) => mapSemanticProps(props as SemanticProps, DEFAULT_VALUES, "Divider"), displayName: "Divider", exporters: DividerExporters, }); diff --git a/packages/react/src/components/Menu.tsx b/packages/react/src/components/Menu.tsx index 3d4bd0b..e1f2af1 100644 --- a/packages/react/src/components/Menu.tsx +++ b/packages/react/src/components/Menu.tsx @@ -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 & { +type MenuSemanticProps = Omit, "padding"> & { /** Menu items shorthand */ items?: MenuItem[]; + /** Inner padding — a number (→ px) or CSS string. */ + padding?: SizeInput; }; -export interface MenuProps extends ItemComponentProps> { - items?: MenuItem[]; -} +export interface MenuProps extends ItemComponentProps {} // Defaults from the editor schema const DEFAULT_MENU: NonNullable = MenuDefaults.menu ?? { items: [] }; diff --git a/packages/react/src/components/Table.tsx b/packages/react/src/components/Table.tsx index 6f4da58..0acc140 100644 --- a/packages/react/src/components/Table.tsx +++ b/packages/react/src/components/Table.tsx @@ -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 & { +type TableSemanticProps = Omit, "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>, "headers" | "data"> { +export interface TableProps + extends Omit, "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; } // Defaults from the editor schema, plus table data structure diff --git a/packages/react/src/dx-types.test-d.tsx b/packages/react/src/dx-types.test-d.tsx new file mode 100644 index 0000000..951a196 --- /dev/null +++ b/packages/react/src/dx-types.test-d.tsx @@ -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 `` 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"; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 3156054..6dfe7e6 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -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, @@ -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"; diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index ab2f994..0a7e839 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -1,14 +1,15 @@ /** * Type Definitions * - * Re-exports shared types from @unlayer-internal/shared-elements - * and defines React-specific component prop types. + * Re-exports the canonical value types from @unlayer-internal/shared-elements + * and defines the agent-friendly input types components build their props from. + * + * Each component's prop type (ButtonProps, HeadingProps, …) lives next to its + * component — these are just the shared building blocks they share. */ -import type { SemanticProps } from "@unlayer-internal/shared-elements"; - // ============================================ -// RE-EXPORT ALL SHARED TYPES +// RE-EXPORT SHARED VALUE TYPES // ============================================ export type { @@ -43,45 +44,20 @@ export type { } from "@unlayer-internal/shared-elements"; // ============================================ -// COMPONENT PROP TYPES +// AGENT-FRIENDLY INPUT TYPES // ============================================ - -// Import value types for prop definitions -import type { - ButtonValues, - ImageValues, - HeadingValues, - DividerValues, - HtmlValues, - MenuValues, - ParagraphValues, - SocialValues, - TableValues, - VideoValues, - SocialIcon, - MenuItem, -} from "@unlayer-internal/shared-elements"; - -/** - * Public props for item components. - * Includes semantic flat props, children, values escape hatch, - * className, style, and mode. Excludes internal threading props. - */ -type ItemProps = SemanticProps & { - className?: string; - style?: React.CSSProperties; - mode?: "web" | "email" | "document"; -}; - -// ── Agent-friendly prop inputs ─────────────────────────────────────────────── // The canonical value types are stricter than the forms authors (human and AI) -// naturally write — and the flattened semantic props are typed `any`, so the -// wrong form type-checks and renders broken. These widen the public surface to -// the natural forms and replace the `any`; mapSemanticProps normalizes them at -// runtime (see normalizeCssProps / Image propMapper). +// naturally write. These widen the public surface to the natural forms; the +// runtime normalizes them at render time. Components import these to build their +// own prop types. + +// Imported locally (not just re-exported) so BorderInput can be derived from the +// canonical border shape. +import type { ColumnValues } from "@unlayer-internal/shared-elements"; /** fontFamily accepts a ready stack object or a bare family-name string. */ export type FontFamilyInput = { label: string; value: string } | string; + /** fontWeight accepts a number, a numeric string, or a CSS keyword. */ export type FontWeightInput = | number @@ -90,12 +66,28 @@ export type FontWeightInput = | "bold" | "lighter" | "bolder"; + /** A CSS size: a number (treated as px) or a string ("24px", "50%", "1.5em"). */ export type SizeInput = number | (string & {}); + +/** + * The `border` object, agent-friendly. The canonical type pins each per-side + * `*Width` to `${number}px`, so a literal like "1px" type-checks inline but a + * factored-out object widens "1px" to `string` and stops compiling — exactly the + * reusable-divider pattern authors reach for. Relax the `*Width` fields to + * SizeInput (the runtime accepts any CSS string), derived from the canonical + * shape so it tracks the schema instead of duplicating it. + */ +export type BorderInput = { + [K in keyof NonNullable]?: K extends `${string}Width` + ? SizeInput + : NonNullable[K]; +}; + /** Heading levels (h1–h6). */ export type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; -/** Shared text/style props, agent-friendly (replace the loose `any` flat keys). */ +/** Shared text/style inputs, agent-friendly. */ export type TextStyleProps = { fontFamily?: FontFamilyInput; fontWeight?: FontWeightInput; @@ -117,80 +109,3 @@ export type ImageSrcInput = maxWidth?: SizeInput; [key: string]: unknown; }; - -/** Button component props */ -export type ButtonProps = Omit< - ItemProps, - keyof TextStyleProps | "width" -> & - TextStyleProps & { - /** Display width — a number/px pins the button; "100%" makes it full-width. */ - width?: SizeInput; - }; -/** Heading component props */ -export type HeadingProps = Omit< - ItemProps, - keyof TextStyleProps | "headingType" -> & - TextStyleProps & { - /** Heading level h1–h6. */ - headingType?: HeadingLevel; - /** Alias for `headingType`. */ - level?: HeadingLevel; - /** Heading text (or use children). */ - text?: string; - }; -/** Divider component props */ -export type DividerProps = ItemProps; -/** HTML component props */ -export type HtmlProps = ItemProps; -/** Paragraph component props */ -export type ParagraphProps = Omit, keyof TextStyleProps> & - TextStyleProps & { - /** Plain-text content (or use `html` for inline formatting, or children). */ - text?: string; - }; - -/** Image component props — supports `alt` shorthand for `altText`. */ -export type ImageProps = Omit< - ItemProps, - "src" | "width" | "maxWidth" -> & { - /** Alt text (alias for altText) */ - alt?: string; - /** Image URL string, or the value object `{ url, width?, maxWidth?, ... }`. */ - src?: ImageSrcInput; - /** Display width — number/px pins the image; "50%" sets a percentage width. */ - width?: SizeInput; - /** Display width as a CSS value ("50%", "300px"). */ - maxWidth?: SizeInput; -}; - -/** Social component props — supports `icons` shorthand array. */ -export type SocialProps = ItemProps & { - /** Social icons shorthand */ - icons?: SocialIcon[]; - /** Icon shape */ - iconType?: "circle" | "rounded" | "squared"; -}; - -/** Menu component props — supports `items` shorthand array. */ -export type MenuProps = ItemProps & { - /** Menu items shorthand */ - items?: MenuItem[]; -}; - -/** Table component props — supports `headers` + `data` shorthands. */ -export type TableProps = ItemProps & { - /** Column headers */ - headers?: string[]; - /** Row data as 2D array */ - data?: string[][]; -}; - -/** Video component props — supports `videoUrl` shorthand. */ -export type VideoProps = ItemProps & { - /** YouTube/Vimeo URL (auto-parsed) */ - videoUrl?: string; -}; - diff --git a/packages/react/src/utils/extract-head.ts b/packages/react/src/utils/extract-head.ts index b17bb12..730f368 100644 --- a/packages/react/src/utils/extract-head.ts +++ b/packages/react/src/utils/extract-head.ts @@ -8,16 +8,27 @@ */ import React from "react"; -import { - heads, - type ComponentHead, -} from "@unlayer/exporters"; +import { heads } from "@unlayer/exporters"; import { mergeValues } from "@unlayer-internal/shared-elements"; import type { RenderMode, HeadConfig } from "@unlayer-internal/shared-elements"; import { mapSemanticProps } from "./semantic-props"; import { UNLAYER_CONFIG_KEY } from "./create-component"; import { BODY_DEFAULTS, ROW_DEFAULTS, COLUMN_DEFAULTS } from "./container-defaults"; +/** Args every head builder receives: (values, bodyValues, meta). */ +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. + */ +type ComponentHead = { + css?: (...args: HeadArgs) => string | undefined; + js?: (...args: HeadArgs) => string | undefined; + tags?: (...args: HeadArgs) => string[] | undefined; +}; + // ============================================ // Inlined helpers // ============================================ diff --git a/packages/react/tsconfig.typecheck.json b/packages/react/tsconfig.typecheck.json new file mode 100644 index 0000000..fb1ebaa --- /dev/null +++ b/packages/react/tsconfig.typecheck.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ES2020", "DOM"], + "types": ["react", "react-dom"], + "noEmit": true + }, + "include": ["src/dx-types.test-d.tsx", "src/dx-contract.test.tsx"] +}