From b776ee4b30af593f5eed73995bddc0cf3c4f4d43 Mon Sep 17 00:00:00 2001 From: wlenig <30681316+wlenig@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:04:34 -0400 Subject: [PATCH 1/6] WEB-135 Remove puck payload from client boundary, refactor responsive fields to be server-safe --- src/app/[...puckPath]/client.tsx | 9 -- src/app/[...puckPath]/page.tsx | 7 +- src/app/editor/[id]/[slug]/PreviewPage.tsx | 5 +- src/app/page.tsx | 6 +- .../puck/fields/responsive-field-client.tsx | 98 +++++++++++++++++++ .../puck/fields/responsive-field.tsx | 84 +++------------- .../puck/fields/responsive-token-field.tsx | 31 ------ src/lib/puck/define-props.ts | 3 +- 8 files changed, 124 insertions(+), 119 deletions(-) delete mode 100644 src/app/[...puckPath]/client.tsx create mode 100644 src/components/puck/fields/responsive-field-client.tsx delete mode 100644 src/components/puck/fields/responsive-token-field.tsx diff --git a/src/app/[...puckPath]/client.tsx b/src/app/[...puckPath]/client.tsx deleted file mode 100644 index bec54a70..00000000 --- a/src/app/[...puckPath]/client.tsx +++ /dev/null @@ -1,9 +0,0 @@ -"use client"; - -import type { Data } from "@puckeditor/core"; -import { Render } from "@puckeditor/core"; -import config from "../../puck.config"; - -export function Client({ data }: { data: Data }) { - return ; -} diff --git a/src/app/[...puckPath]/page.tsx b/src/app/[...puckPath]/page.tsx index c7dce62e..184ae2e0 100644 --- a/src/app/[...puckPath]/page.tsx +++ b/src/app/[...puckPath]/page.tsx @@ -1,7 +1,8 @@ -import { Client } from "./client"; import { notFound } from "next/navigation"; import { Metadata } from "next"; import { getDocumentByPath } from "../../lib/documents/queries"; +import { Render } from "@puckeditor/core"; +import { config } from "@/puck.config"; export async function generateMetadata({ params, @@ -29,9 +30,7 @@ export default async function Page({ return notFound(); } - return ; + return ; } -// Force Next.js to produce static pages: https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic -// Delete this if you need dynamic rendering, such as access to headers or cookies export const dynamic = "force-static"; diff --git a/src/app/editor/[id]/[slug]/PreviewPage.tsx b/src/app/editor/[id]/[slug]/PreviewPage.tsx index b4c3fff3..2c9a4d2d 100644 --- a/src/app/editor/[id]/[slug]/PreviewPage.tsx +++ b/src/app/editor/[id]/[slug]/PreviewPage.tsx @@ -1,8 +1,9 @@ import { notFound } from "next/navigation"; -import { Client } from "../../../[...puckPath]/client"; import { getVersionContent } from "../../../../lib/documents/queries"; import { resolvePreviewVersionId } from "./version-selection"; import { loadDocument } from "./params"; +import { Render } from "@puckeditor/core"; +import { config } from "@/puck.config"; export default async function PreviewPage({ documentId, @@ -29,5 +30,5 @@ export default async function PreviewPage({ const data = await getVersionContent(targetVersionId); if (!data) notFound(); - return ; + return ; } diff --git a/src/app/page.tsx b/src/app/page.tsx index f68c40b8..f0c54d95 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,7 +1,7 @@ -import { Client } from "./[...puckPath]/client"; import { notFound } from "next/navigation"; import { getDocumentByPath } from "../lib/documents/queries"; - +import { Render } from "@puckeditor/core"; +import { config } from "@/puck.config"; export default async function Page() { const path = "/"; @@ -11,7 +11,7 @@ export default async function Page() { return notFound(); } - return ; + return ; } export const dynamic = "force-static"; diff --git a/src/components/puck/fields/responsive-field-client.tsx b/src/components/puck/fields/responsive-field-client.tsx new file mode 100644 index 00000000..c973ddce --- /dev/null +++ b/src/components/puck/fields/responsive-field-client.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { AutoField, FieldLabel } from "@puckeditor/core"; +import { + responsiveBreakpoints, + setAt, + type ResponsiveBreakpoint, + type ResponsiveValue, +} from "@/lib/puck/responsive"; +import type { TokenOption } from "@/lib/puck/tokens"; +import type { ResponsiveFieldDescriptor } from "./responsive-field"; + +const breakpointLabels: Record = { + base: "Base", + md: "Tablet", + lg: "Desktop", +}; + +// Non-base breakpoints can clear their override and inherit from the smaller breakpoint. +const inheritOption: TokenOption = { label: "–", value: "" }; + +type ResponsiveFieldClientProps = { + label: string; + descriptor: ResponsiveFieldDescriptor; + value: ResponsiveValue; + onChange: (value: ResponsiveValue) => void; + readOnly?: boolean; +}; + +function renderFieldControl({ + descriptor, + isBase, + value, + onChange, + readOnly, +}: { + descriptor: ResponsiveFieldDescriptor; + isBase: boolean; + value: T | undefined; + onChange: (value: T | undefined) => void; + readOnly?: boolean; +}) { + switch (descriptor.kind) { + case "select": { + const fieldOptions = isBase ? descriptor.options : [inheritOption, ...descriptor.options]; + const isInheriting = !isBase && value === undefined; + + return ( +
+ { + onChange(selected === "" ? undefined : (selected as T)); + }} + value={value ?? ""} + readOnly={readOnly} + /> +
+ ); + } + } +} + +export function ResponsiveFieldClient({ + label, + descriptor, + value, + onChange, + readOnly, +}: ResponsiveFieldClientProps) { + return ( + +
+ {responsiveBreakpoints.map((bp) => { + const isBase = bp === "base"; + const currentValue = value[bp]; + + return ( +
+ + {breakpointLabels[bp]} + + {renderFieldControl({ + descriptor, + isBase, + value: currentValue, + onChange: (nextValue) => { + onChange(setAt(value, bp, nextValue)); + }, + readOnly, + })} +
+ ); + })} +
+
+ ); +} diff --git a/src/components/puck/fields/responsive-field.tsx b/src/components/puck/fields/responsive-field.tsx index 0af4a734..14537fef 100644 --- a/src/components/puck/fields/responsive-field.tsx +++ b/src/components/puck/fields/responsive-field.tsx @@ -1,80 +1,28 @@ -import { FieldLabel, type CustomField } from "@puckeditor/core"; -import { - responsiveBreakpoints, - setAt, - type ResponsiveBreakpoint, - type ResponsiveValue, -} from "@/lib/puck/responsive"; -import type { ReactNode } from "react"; +import type { CustomField } from "@puckeditor/core"; +import type { ResponsiveValue } from "@/lib/puck/responsive"; +import type { TokenOption } from "@/lib/puck/tokens"; +import { ResponsiveFieldClient } from "./responsive-field-client"; -const breakpointLabels: Record = { - base: "Base", - md: "Tablet", - lg: "Desktop", +export type ResponsiveFieldDescriptor = { + kind: "select"; + options: TokenOption[]; }; -export type ResponsiveEditorAdapter = { - render: (props: { - value: T | undefined; - onChange: (value: T | undefined) => void; - readOnly?: boolean; - isBase: boolean; - }) => ReactNode; -}; - -function ResponsiveField({ - adapter, - value, - onChange, - readOnly, -}: { - adapter: ResponsiveEditorAdapter; - value: ResponsiveValue; - onChange: (value: ResponsiveValue) => void; - readOnly?: boolean; -}) { - return ( -
- {responsiveBreakpoints.map((bp) => { - const isBase = bp === "base"; - const currentValue = value[bp]; - - return ( -
- - {breakpointLabels[bp]} - - {adapter.render({ - value: currentValue, - onChange: (nextValue) => { - onChange(setAt(value, bp, nextValue)); - }, - readOnly, - isBase, - })} -
- ); - })} -
- ); -} - -export function responsiveField( - adapter: ResponsiveEditorAdapter, +export function responsiveField( + descriptor: ResponsiveFieldDescriptor, label: string, ): CustomField> { return { type: "custom", label, render: ({ value, onChange, readOnly }) => ( - - - + ), }; } diff --git a/src/components/puck/fields/responsive-token-field.tsx b/src/components/puck/fields/responsive-token-field.tsx deleted file mode 100644 index 404ecfc4..00000000 --- a/src/components/puck/fields/responsive-token-field.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { AutoField } from "@puckeditor/core"; -import type { TokenOption } from "@/lib/puck/tokens"; -import type { ResponsiveEditorAdapter } from "./responsive-field"; - -// Shown in non-base breakpoints to let the user clear an override and inherit from the smaller breakpoint. -const inheritOption: TokenOption = { label: "–", value: "" }; - -export function selectAdapter( - options: TokenOption[], -): ResponsiveEditorAdapter { - return { - render: ({ value, onChange, readOnly, isBase }) => { - const fieldOptions = isBase ? options : [inheritOption, ...options]; - const currentValue = value ?? ""; - const isInheriting = !isBase && value === undefined; - - return ( -
- { - onChange(selected === "" ? undefined : (selected as T)); - }} - value={currentValue} - readOnly={readOnly} - /> -
- ); - }, - }; -} diff --git a/src/lib/puck/define-props.ts b/src/lib/puck/define-props.ts index f3af0a8e..dc8b461e 100644 --- a/src/lib/puck/define-props.ts +++ b/src/lib/puck/define-props.ts @@ -2,7 +2,6 @@ import type { CustomField, Slot } from "@puckeditor/core"; import type { Token, TokenOption } from "@/lib/puck/tokens"; import type { ResponsiveValue } from "@/lib/puck/responsive"; import { responsiveField } from "@/components/puck/fields/responsive-field"; -import { selectAdapter } from "@/components/puck/fields/responsive-token-field"; // -- Prop spec shape ---------------------------------------------------------- @@ -24,7 +23,7 @@ export const responsive = { : { base: (opts.default ?? token.defaultValue) } as ResponsiveValue; return { - field: responsiveField(selectAdapter(token.options), opts.label), + field: responsiveField({ kind: "select", options: token.options }, opts.label), defaultValue, }; }, From 12d2c5770f69b8c9aa0c8bc7b9dfac6b41e3e881 Mon Sep 17 00:00:00 2001 From: wlenig <30681316+wlenig@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:10:35 -0400 Subject: [PATCH 2/6] WEB-135 Remove redundant root page.tsx, update catch all to be optional and capture root --- .../{[...puckPath] => [[...puckPath]]}/page.tsx | 0 src/app/page.tsx | 17 ----------------- 2 files changed, 17 deletions(-) rename src/app/{[...puckPath] => [[...puckPath]]}/page.tsx (100%) delete mode 100644 src/app/page.tsx diff --git a/src/app/[...puckPath]/page.tsx b/src/app/[[...puckPath]]/page.tsx similarity index 100% rename from src/app/[...puckPath]/page.tsx rename to src/app/[[...puckPath]]/page.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx deleted file mode 100644 index f0c54d95..00000000 --- a/src/app/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { notFound } from "next/navigation"; -import { getDocumentByPath } from "../lib/documents/queries"; -import { Render } from "@puckeditor/core"; -import { config } from "@/puck.config"; - -export default async function Page() { - const path = "/"; - const data = await getDocumentByPath(path); - - if (!data) { - return notFound(); - } - - return ; -} - -export const dynamic = "force-static"; From 1eb521a45d23fe632d4925d90858bb2972d64f2e Mon Sep 17 00:00:00 2001 From: wlenig <30681316+wlenig@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:03:01 -0400 Subject: [PATCH 3/6] WEB-135 Refactor responsive Puck fields and shared published rendering paths --- docs/STYLING.md | 56 +++++++++-- src/app/[[...puckPath]]/page.tsx | 22 ++--- src/app/[[...puckPath]]/published-document.ts | 14 +++ src/app/editor/[id]/[slug]/PreviewPage.tsx | 5 +- src/components/puck/columns.tsx | 4 +- src/components/puck/container.tsx | 4 +- .../puck/fields/responsive-field-client.tsx | 99 ++----------------- .../fields/responsive-field-controls.test.ts | 50 ++++++++++ .../puck/fields/responsive-field-frame.tsx | 64 ++++++++++++ .../puck/fields/responsive-field.tsx | 48 ++++++--- .../responsive-kinds/client-registry.tsx | 23 +++++ .../puck/fields/responsive-kinds/index.ts | 22 +++++ .../fields/responsive-kinds/number/client.tsx | 39 ++++++++ .../fields/responsive-kinds/number/shared.ts | 14 +++ .../fields/responsive-kinds/select/client.tsx | 37 +++++++ .../fields/responsive-kinds/select/shared.ts | 14 +++ .../puck/fields/responsive-number-control.tsx | 82 +++++++++++++++ .../puck/fields/responsive-select-control.tsx | 46 +++++++++ src/components/puck/grid.tsx | 6 +- src/components/puck/render.tsx | 6 ++ src/components/puck/rich-text.tsx | 4 +- src/components/puck/section.tsx | 6 +- src/lib/puck/define-props.test.ts | 61 ++++++------ src/lib/puck/define-props.ts | 65 +++++++++--- 24 files changed, 609 insertions(+), 182 deletions(-) create mode 100644 src/app/[[...puckPath]]/published-document.ts create mode 100644 src/components/puck/fields/responsive-field-controls.test.ts create mode 100644 src/components/puck/fields/responsive-field-frame.tsx create mode 100644 src/components/puck/fields/responsive-kinds/client-registry.tsx create mode 100644 src/components/puck/fields/responsive-kinds/index.ts create mode 100644 src/components/puck/fields/responsive-kinds/number/client.tsx create mode 100644 src/components/puck/fields/responsive-kinds/number/shared.ts create mode 100644 src/components/puck/fields/responsive-kinds/select/client.tsx create mode 100644 src/components/puck/fields/responsive-kinds/select/shared.ts create mode 100644 src/components/puck/fields/responsive-number-control.tsx create mode 100644 src/components/puck/fields/responsive-select-control.tsx create mode 100644 src/components/puck/render.tsx diff --git a/docs/STYLING.md b/docs/STYLING.md index 40849abe..76efda17 100644 --- a/docs/STYLING.md +++ b/docs/STYLING.md @@ -82,7 +82,7 @@ These helpers are for component logic — see [Rendering](#4-rendering) for how `defineProps` wires tokens to Puck editor fields (`src/lib/puck/define-props.ts`). It returns `{ fields, defaultProps }` to spread onto a `ComponentConfig`. -There are four field builders: +There are two builder namespaces: ```ts const props = defineProps({ @@ -94,7 +94,16 @@ const props = defineProps({ layout: field.radio(layout, { label: "Layout" }), // Responsive — per-breakpoint picker, also token-backed - padding: responsive.token(padding, { label: "Padding" }), + padding: responsive.select(padding, { label: "Padding" }), + + // Responsive number — per-breakpoint numeric input + columns: responsive.number({ + label: "Columns", + default: { base: 1, md: 2 }, + min: 1, + max: 6, + step: 1, + }), // Raw — any Puck field descriptor, for things tokens don't cover url: field.raw({ type: "text", label: "URL" }, ""), @@ -104,8 +113,10 @@ const props = defineProps({ **Defaults.** Every builder falls back to the token's first key if no `default` is given. You can override with a scalar or a full responsive object: ```ts -responsive.token(columnCount, { label: "Columns", default: "3" }) -responsive.token(columnCount, { label: "Columns", default: { base: "1", md: "3" } }) +responsive.select(columnCount, { label: "Columns", default: "3" }) +responsive.select(columnCount, { label: "Columns", default: { base: "1", md: "3" } }) +responsive.number({ label: "Rows", default: 2, min: 1, max: 6, step: 1 }) +responsive.number({ label: "Rows", default: { base: 1, md: 3 } }) ``` **Slots** accept an optional allow/disallow list to restrict which child components can be dropped in: @@ -115,7 +126,26 @@ field.slot({ allow: ["Card", "Button"] }) field.slot({ disallow: ["Section"] }) ``` -The same token definition works for both static and responsive use — only the field builder differs. +The same token definition works for both static and responsive use. Numeric responsive fields do not need a token. + +### Responsive field architecture + +Responsive field descriptors are built on the server and rendered on the client, so the implementation is split intentionally: + +- Shared kind definitions live in `src/components/puck/fields/responsive-kinds/*/shared.ts`. +- Client renderers live in `src/components/puck/fields/responsive-kinds/*/client.tsx`. +- `src/components/puck/fields/responsive-field.tsx` is the server-safe wrapper that turns a serializable descriptor into a Puck custom field. +- `src/components/puck/fields/responsive-field-client.tsx` is the client-only registry that maps `descriptor.kind` to the right renderer. +- `src/components/puck/fields/responsive-field-frame.tsx` owns the shared breakpoint UI; kind-specific controls only deal with their own value format. + +This keeps the descriptor shape and the renderer associated by kind without pushing client code across the server boundary. + +To add a new responsive field kind: + +1. Add `shared.ts` and `client.tsx` under `src/components/puck/fields/responsive-kinds//`. +2. Export the descriptor factory and type from `src/components/puck/fields/responsive-kinds/index.ts`. +3. Register the client component in `src/components/puck/fields/responsive-kinds/client-registry.tsx`. +4. Optionally add a convenience builder in `src/lib/puck/define-props.ts`. ### 4. Rendering @@ -143,6 +173,8 @@ const classes = cn( ); ``` +Responsive numeric props stay as data. Use `resolveAt(...)` or your own component logic when they need to influence layout or behavior; they do not go through `resolveResponsive(...)`. + ## Tailwind source hints Because `resolveResponsive` builds prefixed class names like `md:p-4` at runtime, Tailwind's scanner never sees them in source. Every responsive token needs a corresponding `@source inline(...)` directive in `src/app/styles.css`: @@ -187,13 +219,15 @@ import { padding, bgColor, type Spacing, type Color } from "@/lib/puck/tokens"; type CardProps = { content: Slot; padding: ResponsiveValue; + columns: ResponsiveValue; bgColor: Color; title: string; }; const props = defineProps({ content: field.slot(), - padding: responsive.token(padding, { label: "Padding", default: "md" }), + padding: responsive.select(padding, { label: "Padding", default: "md" }), + columns: responsive.number({ label: "Columns", default: { base: 1, md: 2 }, min: 1, max: 4, step: 1 }), bgColor: field.select(bgColor, { label: "Background" }), title: field.raw({ type: "text", label: "Title" }, ""), }); @@ -201,17 +235,21 @@ const props = defineProps({ ### Step 3 — Export the component config -Spread `props` onto the config and destructure in `render`. Use `resolveResponsive` for responsive props and direct `token.classes[value]` lookups for static ones: +Spread `props` onto the config and destructure in `render`. Use `resolveResponsive` for responsive token props, `resolveAt` or component logic for responsive numeric props, and direct `token.classes[value]` lookups for static ones: ```ts +import { resolveAt } from "@/lib/puck/responsive"; import { resolveResponsive } from "@/lib/puck/responsive-tailwind"; import { cn } from "@/lib/utils"; export const Card: ComponentConfig = { label: "Card", ...props, - render: ({ content: Content, padding: pad, bgColor: bg, title }) => ( -
+ render: ({ content: Content, padding: pad, columns, bgColor: bg, title }) => ( +
{title &&

{title}

} {Content && }
diff --git a/src/app/[[...puckPath]]/page.tsx b/src/app/[[...puckPath]]/page.tsx index 184ae2e0..57109d80 100644 --- a/src/app/[[...puckPath]]/page.tsx +++ b/src/app/[[...puckPath]]/page.tsx @@ -1,36 +1,30 @@ -import { notFound } from "next/navigation"; import { Metadata } from "next"; -import { getDocumentByPath } from "../../lib/documents/queries"; -import { Render } from "@puckeditor/core"; -import { config } from "@/puck.config"; +import { notFound } from "next/navigation"; +import { PuckRender } from "@/components/puck/render"; +import { getPublishedDocument } from "./published-document"; export async function generateMetadata({ params, }: { - params: Promise<{ puckPath: string[] }>; + params: Promise<{ puckPath?: string[] }>; }): Promise { - const { puckPath = [] } = await params; - const path = `/${puckPath.join("/")}`; - return { - title: (await getDocumentByPath(path))?.root.props?.title, + title: (await getPublishedDocument(params))?.root.props?.title, }; } export default async function Page({ params, }: { - params: Promise<{ puckPath: string[] }>; + params: Promise<{ puckPath?: string[] }>; }) { - const { puckPath = [] } = await params; - const path = `/${puckPath.join("/")}`; - const data = await getDocumentByPath(path); + const data = await getPublishedDocument(params); if (!data) { return notFound(); } - return ; + return ; } export const dynamic = "force-static"; diff --git a/src/app/[[...puckPath]]/published-document.ts b/src/app/[[...puckPath]]/published-document.ts new file mode 100644 index 00000000..81e2f4f9 --- /dev/null +++ b/src/app/[[...puckPath]]/published-document.ts @@ -0,0 +1,14 @@ +import { cache } from "react"; +import { getDocumentByPath } from "../../lib/documents/queries"; + +type PublishedRouteParams = Promise<{ puckPath?: string[] }>; + +export async function getPublishedDocument(params: PublishedRouteParams) { + return getPublishedDocumentByPath(getPublishedPath(await params)); +} + +function getPublishedPath({ puckPath = [] }: { puckPath?: string[] }) { + return `/${puckPath.join("/")}`; +} + +const getPublishedDocumentByPath = cache(getDocumentByPath); diff --git a/src/app/editor/[id]/[slug]/PreviewPage.tsx b/src/app/editor/[id]/[slug]/PreviewPage.tsx index 2c9a4d2d..45850191 100644 --- a/src/app/editor/[id]/[slug]/PreviewPage.tsx +++ b/src/app/editor/[id]/[slug]/PreviewPage.tsx @@ -1,9 +1,8 @@ import { notFound } from "next/navigation"; +import { PuckRender } from "@/components/puck/render"; import { getVersionContent } from "../../../../lib/documents/queries"; import { resolvePreviewVersionId } from "./version-selection"; import { loadDocument } from "./params"; -import { Render } from "@puckeditor/core"; -import { config } from "@/puck.config"; export default async function PreviewPage({ documentId, @@ -30,5 +29,5 @@ export default async function PreviewPage({ const data = await getVersionContent(targetVersionId); if (!data) notFound(); - return ; + return ; } diff --git a/src/components/puck/columns.tsx b/src/components/puck/columns.tsx index 53a15994..52db9753 100644 --- a/src/components/puck/columns.tsx +++ b/src/components/puck/columns.tsx @@ -27,8 +27,8 @@ const columnSlotDefaults = Object.fromEntries( ) as Record; const props = defineProps({ - columns: responsive.token(columnCount, { label: "Columns", default: { base: "1", md: "2" } }), - gap: responsive.token(gap, { label: "Gap", default: "md" }), + columns: responsive.select(columnCount, { label: "Columns", default: { base: "1", md: "2" } }), + gap: responsive.select(gap, { label: "Gap", default: "md" }), }); export const Columns: ComponentConfig = { diff --git a/src/components/puck/container.tsx b/src/components/puck/container.tsx index ec449ced..5247c8ad 100644 --- a/src/components/puck/container.tsx +++ b/src/components/puck/container.tsx @@ -28,8 +28,8 @@ type ContainerProps = ContainerStyle & { const props = defineProps({ content: field.slot(), layout: field.radio(layout, { label: "Layout" }), - padding: responsive.token(padding, { label: "Padding", default: "md" }), - gap: responsive.token(gap, { label: "Gap", default: "md" }), + padding: responsive.select(padding, { label: "Padding", default: "md" }), + gap: responsive.select(gap, { label: "Gap", default: "md" }), align: field.radio(crossAxisAlign, { label: "Align items" }), justify: field.radio(justify, { label: "Justify" }), width: field.select(width, { label: "Max width" }), diff --git a/src/components/puck/fields/responsive-field-client.tsx b/src/components/puck/fields/responsive-field-client.tsx index c973ddce..98340836 100644 --- a/src/components/puck/fields/responsive-field-client.tsx +++ b/src/components/puck/fields/responsive-field-client.tsx @@ -1,98 +1,15 @@ "use client"; -import { AutoField, FieldLabel } from "@puckeditor/core"; +import type { ComponentType } from "react"; import { - responsiveBreakpoints, - setAt, - type ResponsiveBreakpoint, - type ResponsiveValue, -} from "@/lib/puck/responsive"; -import type { TokenOption } from "@/lib/puck/tokens"; -import type { ResponsiveFieldDescriptor } from "./responsive-field"; + responsiveFieldClientRegistry, + type ResponsiveFieldClientProps, +} from "./responsive-kinds/client-registry"; -const breakpointLabels: Record = { - base: "Base", - md: "Tablet", - lg: "Desktop", -}; +export type { ResponsiveFieldClientProps } from "./responsive-kinds/client-registry"; -// Non-base breakpoints can clear their override and inherit from the smaller breakpoint. -const inheritOption: TokenOption = { label: "–", value: "" }; +export function ResponsiveFieldClient(props: ResponsiveFieldClientProps) { + const FieldComponent = responsiveFieldClientRegistry[props.descriptor.kind] as ComponentType; -type ResponsiveFieldClientProps = { - label: string; - descriptor: ResponsiveFieldDescriptor; - value: ResponsiveValue; - onChange: (value: ResponsiveValue) => void; - readOnly?: boolean; -}; - -function renderFieldControl({ - descriptor, - isBase, - value, - onChange, - readOnly, -}: { - descriptor: ResponsiveFieldDescriptor; - isBase: boolean; - value: T | undefined; - onChange: (value: T | undefined) => void; - readOnly?: boolean; -}) { - switch (descriptor.kind) { - case "select": { - const fieldOptions = isBase ? descriptor.options : [inheritOption, ...descriptor.options]; - const isInheriting = !isBase && value === undefined; - - return ( -
- { - onChange(selected === "" ? undefined : (selected as T)); - }} - value={value ?? ""} - readOnly={readOnly} - /> -
- ); - } - } -} - -export function ResponsiveFieldClient({ - label, - descriptor, - value, - onChange, - readOnly, -}: ResponsiveFieldClientProps) { - return ( - -
- {responsiveBreakpoints.map((bp) => { - const isBase = bp === "base"; - const currentValue = value[bp]; - - return ( -
- - {breakpointLabels[bp]} - - {renderFieldControl({ - descriptor, - isBase, - value: currentValue, - onChange: (nextValue) => { - onChange(setAt(value, bp, nextValue)); - }, - readOnly, - })} -
- ); - })} -
-
- ); + return ; } diff --git a/src/components/puck/fields/responsive-field-controls.test.ts b/src/components/puck/fields/responsive-field-controls.test.ts new file mode 100644 index 00000000..a03e8a3d --- /dev/null +++ b/src/components/puck/fields/responsive-field-controls.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + fromResponsiveSelectValue, + toResponsiveSelectValue, +} from "./responsive-select-control"; +import { + formatResponsiveNumberValue, + getResponsiveNumberChange, +} from "./responsive-number-control"; + +describe("responsive select control helpers", () => { + it("translates inherited select values at the UI boundary", () => { + expect(toResponsiveSelectValue(undefined)).toBe(""); + expect(fromResponsiveSelectValue("")).toBeUndefined(); + expect(fromResponsiveSelectValue("md")).toBe("md"); + }); +}); + +describe("responsive number control helpers", () => { + it("formats values for the number input", () => { + expect(formatResponsiveNumberValue(undefined)).toBe(""); + expect(formatResponsiveNumberValue(3)).toBe("3"); + }); + + it("clears inherited overrides, preserves base values, and validates numeric input", () => { + expect(getResponsiveNumberChange("", { isBase: false })).toEqual({ + kind: "set", + value: undefined, + }); + expect(getResponsiveNumberChange("", { isBase: true })).toEqual({ + kind: "ignore", + }); + expect( + getResponsiveNumberChange("4", { isBase: true, min: 1, max: 6 }), + ).toEqual({ + kind: "set", + value: 4, + }); + expect( + getResponsiveNumberChange("0", { isBase: true, min: 1, max: 6 }), + ).toEqual({ + kind: "ignore", + }); + expect( + getResponsiveNumberChange("9", { isBase: false, min: 1, max: 6 }), + ).toEqual({ + kind: "ignore", + }); + }); +}); diff --git a/src/components/puck/fields/responsive-field-frame.tsx b/src/components/puck/fields/responsive-field-frame.tsx new file mode 100644 index 00000000..097feed3 --- /dev/null +++ b/src/components/puck/fields/responsive-field-frame.tsx @@ -0,0 +1,64 @@ +import { FieldLabel } from "@puckeditor/core"; +import { + responsiveBreakpoints, + setAt, + type ResponsiveBreakpoint, + type ResponsiveValue, +} from "@/lib/puck/responsive"; +import type { ReactNode } from "react"; + +const breakpointLabels: Record = { + base: "Base", + md: "Tablet", + lg: "Desktop", +}; + +export type ResponsiveFieldControlProps = { + breakpoint: ResponsiveBreakpoint; + isBase: boolean; + value: T | undefined; + onChange: (value: T | undefined) => void; + readOnly?: boolean; +}; + +export function ResponsiveFieldFrame({ + label, + value, + onChange, + readOnly, + renderControl, +}: { + label: string; + value: ResponsiveValue; + onChange: (value: ResponsiveValue) => void; + readOnly?: boolean; + renderControl: (props: ResponsiveFieldControlProps) => ReactNode; +}) { + return ( + +
+ {responsiveBreakpoints.map((bp) => { + const isBase = bp === "base"; + const currentValue = value[bp]; + + return ( +
+ + {breakpointLabels[bp]} + + {renderControl({ + breakpoint: bp, + isBase, + value: currentValue, + onChange: (nextValue) => { + onChange(setAt(value, bp, nextValue)); + }, + readOnly, + })} +
+ ); + })} +
+
+ ); +} diff --git a/src/components/puck/fields/responsive-field.tsx b/src/components/puck/fields/responsive-field.tsx index 14537fef..1da46c87 100644 --- a/src/components/puck/fields/responsive-field.tsx +++ b/src/components/puck/fields/responsive-field.tsx @@ -1,26 +1,50 @@ import type { CustomField } from "@puckeditor/core"; import type { ResponsiveValue } from "@/lib/puck/responsive"; -import type { TokenOption } from "@/lib/puck/tokens"; import { ResponsiveFieldClient } from "./responsive-field-client"; +import { + type ResponsiveNumberFieldDescriptor, + type ResponsiveSelectFieldDescriptor, +} from "./responsive-kinds"; -export type ResponsiveFieldDescriptor = { - kind: "select"; - options: TokenOption[]; -}; +export { responsiveFieldDescriptor } from "./responsive-kinds"; +export type { + ResponsiveNumberFieldDescriptor, + ResponsiveSelectFieldDescriptor, +} from "./responsive-kinds"; export function responsiveField( - descriptor: ResponsiveFieldDescriptor, + descriptor: ResponsiveSelectFieldDescriptor, label: string, -): CustomField> { +): CustomField>; +export function responsiveField( + descriptor: ResponsiveNumberFieldDescriptor, + label: string, +): CustomField>; +export function responsiveField( + descriptor: ResponsiveSelectFieldDescriptor | ResponsiveNumberFieldDescriptor, + label: string, +): CustomField { return { - type: "custom", + type: "custom" as const, label, - render: ({ value, onChange, readOnly }) => ( + render: ({ + value, + onChange, + readOnly, + }: { + value: ResponsiveValue | ResponsiveValue; + onChange: + | ((value: ResponsiveValue) => void) + | ((value: ResponsiveValue) => void); + readOnly?: boolean; + }) => ( + // Puck's custom field render callback does not preserve the descriptor/value coupling, + // so the client dispatcher re-establishes it from descriptor.kind. ), diff --git a/src/components/puck/fields/responsive-kinds/client-registry.tsx b/src/components/puck/fields/responsive-kinds/client-registry.tsx new file mode 100644 index 00000000..96462f81 --- /dev/null +++ b/src/components/puck/fields/responsive-kinds/client-registry.tsx @@ -0,0 +1,23 @@ +"use client"; + +import type { ComponentType } from "react"; +import { + ResponsiveNumberField, + type ResponsiveNumberFieldProps, +} from "./number/client"; +import { responsiveNumberFieldKind } from "./number/shared"; +import { + ResponsiveSelectField, + type ResponsiveSelectFieldProps, +} from "./select/client"; +import { responsiveSelectFieldKind } from "./select/shared"; +import type { ResponsiveFieldKind } from "./index"; + +export type ResponsiveFieldClientProps = + | ResponsiveSelectFieldProps + | ResponsiveNumberFieldProps; + +export const responsiveFieldClientRegistry = { + [responsiveSelectFieldKind]: ResponsiveSelectField, + [responsiveNumberFieldKind]: ResponsiveNumberField, +} satisfies Record>; diff --git a/src/components/puck/fields/responsive-kinds/index.ts b/src/components/puck/fields/responsive-kinds/index.ts new file mode 100644 index 00000000..08de67bd --- /dev/null +++ b/src/components/puck/fields/responsive-kinds/index.ts @@ -0,0 +1,22 @@ +import { + createResponsiveNumberFieldDescriptor, + responsiveNumberFieldKind, + type ResponsiveNumberFieldDescriptor, +} from "./number/shared"; +import { + createResponsiveSelectFieldDescriptor, + responsiveSelectFieldKind, + type ResponsiveSelectFieldDescriptor, +} from "./select/shared"; + +export type { ResponsiveNumberFieldDescriptor } from "./number/shared"; +export type { ResponsiveSelectFieldDescriptor } from "./select/shared"; + +export type ResponsiveFieldKind = + | typeof responsiveSelectFieldKind + | typeof responsiveNumberFieldKind; + +export const responsiveFieldDescriptor = { + select: createResponsiveSelectFieldDescriptor, + number: createResponsiveNumberFieldDescriptor, +}; diff --git a/src/components/puck/fields/responsive-kinds/number/client.tsx b/src/components/puck/fields/responsive-kinds/number/client.tsx new file mode 100644 index 00000000..0f9809da --- /dev/null +++ b/src/components/puck/fields/responsive-kinds/number/client.tsx @@ -0,0 +1,39 @@ +"use client"; + +import type { ResponsiveValue } from "@/lib/puck/responsive"; +import { ResponsiveFieldFrame } from "../../responsive-field-frame"; +import { ResponsiveNumberControl } from "../../responsive-number-control"; +import type { ResponsiveNumberFieldDescriptor } from "./shared"; + +export type ResponsiveNumberFieldProps = { + label: string; + descriptor: ResponsiveNumberFieldDescriptor; + value: ResponsiveValue; + onChange: (value: ResponsiveValue) => void; + readOnly?: boolean; +}; + +export function ResponsiveNumberField({ + label, + descriptor, + value, + onChange, + readOnly, +}: ResponsiveNumberFieldProps) { + return ( + ( + + )} + /> + ); +} diff --git a/src/components/puck/fields/responsive-kinds/number/shared.ts b/src/components/puck/fields/responsive-kinds/number/shared.ts new file mode 100644 index 00000000..77f25252 --- /dev/null +++ b/src/components/puck/fields/responsive-kinds/number/shared.ts @@ -0,0 +1,14 @@ +export const responsiveNumberFieldKind = "number"; + +export type ResponsiveNumberFieldDescriptor = { + kind: typeof responsiveNumberFieldKind; + min?: number; + max?: number; + step?: number; +}; + +export function createResponsiveNumberFieldDescriptor( + options: Omit = {}, +): ResponsiveNumberFieldDescriptor { + return { kind: responsiveNumberFieldKind, ...options }; +} diff --git a/src/components/puck/fields/responsive-kinds/select/client.tsx b/src/components/puck/fields/responsive-kinds/select/client.tsx new file mode 100644 index 00000000..d4e0c77c --- /dev/null +++ b/src/components/puck/fields/responsive-kinds/select/client.tsx @@ -0,0 +1,37 @@ +"use client"; + +import type { ResponsiveValue } from "@/lib/puck/responsive"; +import { ResponsiveFieldFrame } from "../../responsive-field-frame"; +import { ResponsiveSelectControl } from "../../responsive-select-control"; +import type { ResponsiveSelectFieldDescriptor } from "./shared"; + +export type ResponsiveSelectFieldProps = { + label: string; + descriptor: ResponsiveSelectFieldDescriptor; + value: ResponsiveValue; + onChange: (value: ResponsiveValue) => void; + readOnly?: boolean; +}; + +export function ResponsiveSelectField({ + label, + descriptor, + value, + onChange, + readOnly, +}: ResponsiveSelectFieldProps) { + return ( + ( + + )} + /> + ); +} diff --git a/src/components/puck/fields/responsive-kinds/select/shared.ts b/src/components/puck/fields/responsive-kinds/select/shared.ts new file mode 100644 index 00000000..56daeb5b --- /dev/null +++ b/src/components/puck/fields/responsive-kinds/select/shared.ts @@ -0,0 +1,14 @@ +import type { TokenOption } from "@/lib/puck/tokens"; + +export const responsiveSelectFieldKind = "select"; + +export type ResponsiveSelectFieldDescriptor = { + kind: typeof responsiveSelectFieldKind; + options: TokenOption[]; +}; + +export function createResponsiveSelectFieldDescriptor( + options: TokenOption[], +): ResponsiveSelectFieldDescriptor { + return { kind: responsiveSelectFieldKind, options }; +} diff --git a/src/components/puck/fields/responsive-number-control.tsx b/src/components/puck/fields/responsive-number-control.tsx new file mode 100644 index 00000000..c0667408 --- /dev/null +++ b/src/components/puck/fields/responsive-number-control.tsx @@ -0,0 +1,82 @@ +import { Input } from "@/components/ui/input"; +import type { ResponsiveFieldControlProps } from "./responsive-field-frame"; + +export type ResponsiveNumberChange = + | { kind: "ignore" } + | { kind: "set"; value: number | undefined }; + +export function formatResponsiveNumberValue(value: number | undefined): string { + return value === undefined ? "" : String(value); +} + +export function getResponsiveNumberChange( + rawValue: string, + { + isBase, + min, + max, + }: { + isBase: boolean; + min?: number; + max?: number; + }, +): ResponsiveNumberChange { + if (rawValue === "") { + return isBase ? { kind: "ignore" } : { kind: "set", value: undefined }; + } + + const nextValue = Number(rawValue); + if (Number.isNaN(nextValue)) { + return { kind: "ignore" }; + } + + if (typeof min !== "undefined" && nextValue < min) { + return { kind: "ignore" }; + } + + if (typeof max !== "undefined" && nextValue > max) { + return { kind: "ignore" }; + } + + return { kind: "set", value: nextValue }; +} + +export function ResponsiveNumberControl({ + isBase, + value, + onChange, + readOnly, + min, + max, + step, +}: ResponsiveFieldControlProps & { + min?: number; + max?: number; + step?: number; +}) { + const isInheriting = !isBase && value === undefined; + + return ( +
+ { + const change = getResponsiveNumberChange(event.currentTarget.value, { + isBase, + min, + max, + }); + + if (change.kind === "set") { + onChange(change.value); + } + }} + readOnly={readOnly} + min={min} + max={max} + step={step} + /> +
+ ); +} diff --git a/src/components/puck/fields/responsive-select-control.tsx b/src/components/puck/fields/responsive-select-control.tsx new file mode 100644 index 00000000..2dd93eb8 --- /dev/null +++ b/src/components/puck/fields/responsive-select-control.tsx @@ -0,0 +1,46 @@ +import { AutoField } from "@puckeditor/core"; +import type { TokenOption } from "@/lib/puck/tokens"; +import type { ResponsiveFieldControlProps } from "./responsive-field-frame"; + +// Non-base breakpoints can clear their override and inherit from the smaller breakpoint. +const inheritOption: TokenOption<""> = { label: "–", value: "" }; + +function withInheritOption( + options: TokenOption[], +): TokenOption[] { + return [inheritOption, ...options]; +} + +export function toResponsiveSelectValue(value: T | undefined): T | "" { + return value ?? ""; +} + +export function fromResponsiveSelectValue(selected: T | ""): T | undefined { + return selected === "" ? undefined : selected; +} + +export function ResponsiveSelectControl({ + isBase, + value, + onChange, + readOnly, + options, +}: ResponsiveFieldControlProps & { + options: TokenOption[]; +}) { + const fieldOptions = isBase ? options : withInheritOption(options); + const isInheriting = !isBase && value === undefined; + + return ( +
+ { + onChange(fromResponsiveSelectValue(selected)); + }} + value={toResponsiveSelectValue(value)} + readOnly={readOnly} + /> +
+ ); +} diff --git a/src/components/puck/grid.tsx b/src/components/puck/grid.tsx index e11c31a6..40b22b7f 100644 --- a/src/components/puck/grid.tsx +++ b/src/components/puck/grid.tsx @@ -13,9 +13,9 @@ type GridProps = { const props = defineProps({ content: field.slot(), - columns: responsive.token(columnCount, { label: "Columns", default: "3" }), - rows: responsive.token(gridRows, { label: "Rows", default: "auto" }), - gap: responsive.token(gap, { label: "Gap", default: "md" }), + columns: responsive.select(columnCount, { label: "Columns", default: "3" }), + rows: responsive.select(gridRows, { label: "Rows", default: "auto" }), + gap: responsive.select(gap, { label: "Gap", default: "md" }), }); export const Grid: ComponentConfig = { diff --git a/src/components/puck/render.tsx b/src/components/puck/render.tsx new file mode 100644 index 00000000..60b4e641 --- /dev/null +++ b/src/components/puck/render.tsx @@ -0,0 +1,6 @@ +import { Render, type Data } from "@puckeditor/core"; +import { config } from "@/puck.config"; + +export function PuckRender({ data }: { data: Data }) { + return ; +} diff --git a/src/components/puck/rich-text.tsx b/src/components/puck/rich-text.tsx index fd7d86d1..06854090 100644 --- a/src/components/puck/rich-text.tsx +++ b/src/components/puck/rich-text.tsx @@ -33,8 +33,8 @@ const props = defineProps({ textColor: field.select(textColor, { label: "Text color", default: "foreground" }), align: field.radio(textAlign, { label: "Text align", default: "left" }), lineSpacing: field.select(lineSpacing, { label: "Line spacing", default: "default" }), - columns: responsive.token(textColumns, { label: "Columns", default: "1" }), - columnGap: responsive.token(columnGap, { label: "Column gap", default: "md" }), + columns: responsive.select(textColumns, { label: "Columns", default: "1" }), + columnGap: responsive.select(columnGap, { label: "Column gap", default: "md" }), }); export const RichTextComponent: ComponentConfig = { diff --git a/src/components/puck/section.tsx b/src/components/puck/section.tsx index 8431c543..a15eb69b 100644 --- a/src/components/puck/section.tsx +++ b/src/components/puck/section.tsx @@ -29,9 +29,9 @@ type SectionProps = { const props = defineProps({ content: field.slot(), anchorId: field.raw({ type: "text", label: "Anchor ID" }, ""), - paddingX: responsive.token(paddingX, { label: "Horizontal padding", default: "md" }), - paddingY: responsive.token(paddingY, { label: "Vertical padding", default: "lg" }), - gap: responsive.token(gapToken, { label: "Gap", default: "md" }), + paddingX: responsive.select(paddingX, { label: "Horizontal padding", default: "md" }), + paddingY: responsive.select(paddingY, { label: "Vertical padding", default: "lg" }), + gap: responsive.select(gapToken, { label: "Gap", default: "md" }), width: field.select(width, { label: "Max width", default: "screen-lg" }), bgColor: field.select(bgColor, { label: "Background" }), textColor: field.select(textColor, { label: "Text color", default: "foreground" }), diff --git a/src/lib/puck/define-props.test.ts b/src/lib/puck/define-props.test.ts index 86e0d818..d97edfbf 100644 --- a/src/lib/puck/define-props.test.ts +++ b/src/lib/puck/define-props.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, expectTypeOf, it } from "vitest"; import { defineProps, field, responsive } from "./define-props"; import { defineToken } from "@/lib/puck/tokens"; +import { responsiveFieldDescriptor } from "@/components/puck/fields/responsive-field"; const size = defineToken({ sm: "Small", md: "Medium", lg: "Large" }); const colorToken = defineToken({ @@ -9,61 +10,61 @@ const colorToken = defineToken({ }); describe("prop builders", () => { - it("wrap token defaults unless a full responsive value is provided", () => { - expect(responsive.token(size, { label: "Size" })).toMatchObject({ - field: { type: "custom" }, - defaultValue: { base: "sm" }, + it("builds typed responsive descriptors", () => { + const selectDescriptor = responsiveFieldDescriptor.select(colorToken.options); + const numberDescriptor = responsiveFieldDescriptor.number({ + min: 1, + max: 6, + step: 1, }); - expect( - responsive.token(size, { label: "Size", default: "md" }).defaultValue, - ).toEqual({ base: "md" }); - expect( - responsive.token(size, { - label: "Size", - default: { base: "sm", md: "lg" }, - }).defaultValue, - ).toEqual({ base: "sm", md: "lg" }); - }); - it("build static field descriptors and respect explicit defaults", () => { - expect(field.select(size, { label: "Size" })).toEqual({ - field: { type: "select", label: "Size", options: size.options }, - defaultValue: "sm", - }); - expect(field.radio(colorToken, { label: "Color", default: "blue" })).toEqual({ - field: { type: "radio", label: "Color", options: colorToken.options }, - defaultValue: "blue", + expect(selectDescriptor).toEqual({ + kind: "select", + options: colorToken.options, }); - expect(field.raw({ type: "text" }, "hello")).toEqual({ - field: { type: "text" }, - defaultValue: "hello", + expect(numberDescriptor).toEqual({ + kind: "number", + min: 1, + max: 6, + step: 1, }); + expectTypeOf(selectDescriptor.options).toEqualTypeOf(); }); -}); -describe("defineProps", () => { - it("splits mixed prop specs into fields and defaultProps", () => { + it("normalizes defaults and splits fields from defaultProps", () => { const props = defineProps({ content: field.slot(), size: field.select(size, { label: "Size", default: "md" }), - gap: responsive.token(size, { + gap: responsive.select(size, { label: "Gap", default: { base: "sm", md: "lg" }, }), + rows: responsive.number({ + label: "Rows", + default: { base: 1, md: 2 }, + min: 1, + max: 6, + step: 1, + }), color: field.radio(colorToken, { label: "Color" }), + title: field.raw({ type: "text", label: "Title" }, "Hello"), }); expect(props.fields).toEqual({ content: { type: "slot" }, size: { type: "select", label: "Size", options: size.options }, gap: expect.objectContaining({ type: "custom" }), + rows: expect.objectContaining({ type: "custom" }), color: { type: "radio", label: "Color", options: colorToken.options }, + title: { type: "text", label: "Title" }, }); expect(props.defaultProps).toEqual({ content: [], size: "md", gap: { base: "sm", md: "lg" }, + rows: { base: 1, md: 2 }, color: "red", + title: "Hello", }); }); }); diff --git a/src/lib/puck/define-props.ts b/src/lib/puck/define-props.ts index dc8b461e..6e1e088c 100644 --- a/src/lib/puck/define-props.ts +++ b/src/lib/puck/define-props.ts @@ -1,7 +1,12 @@ import type { CustomField, Slot } from "@puckeditor/core"; import type { Token, TokenOption } from "@/lib/puck/tokens"; import type { ResponsiveValue } from "@/lib/puck/responsive"; -import { responsiveField } from "@/components/puck/fields/responsive-field"; +import { + responsiveField, + responsiveFieldDescriptor, + type ResponsiveNumberFieldDescriptor, + type ResponsiveSelectFieldDescriptor, +} from "@/components/puck/fields/responsive-field"; // -- Prop spec shape ---------------------------------------------------------- @@ -10,25 +15,63 @@ type PropSpec = { field: F; defaultValue: V }; type FieldsOf = { [K in keyof S]: S[K] extends PropSpec ? F : never }; type DefaultsOf = { [K in keyof S]: S[K] extends PropSpec ? V : never }; -// -- Builder: responsive tokens ----------------------------------------------- +// -- Builder: responsive fields ----------------------------------------------- export const responsive = { - token( + select( token: Token, opts: { label: string; default?: NoInfer> }, ): PropSpec>, ResponsiveValue> { - const defaultValue: ResponsiveValue = - opts.default !== undefined && typeof opts.default === "object" - ? opts.default as ResponsiveValue - : { base: (opts.default ?? token.defaultValue) } as ResponsiveValue; + return buildResponsiveFieldProp( + responsiveFieldDescriptor.select(token.options), + opts.label, + opts.default ?? token.defaultValue, + ); + }, - return { - field: responsiveField({ kind: "select", options: token.options }, opts.label), - defaultValue, - }; + number( + opts: { + label: string; + default: NoInfer>; + } & Omit, + ): PropSpec>, ResponsiveValue> { + return buildResponsiveFieldProp( + responsiveFieldDescriptor.number({ min: opts.min, max: opts.max, step: opts.step }), + opts.label, + opts.default, + ); }, }; +function buildResponsiveFieldProp( + descriptor: ResponsiveSelectFieldDescriptor, + label: string, + defaultValue: NoInfer>, +): PropSpec>, ResponsiveValue>; +function buildResponsiveFieldProp( + descriptor: ResponsiveNumberFieldDescriptor, + label: string, + defaultValue: NoInfer>, +): PropSpec>, ResponsiveValue>; +function buildResponsiveFieldProp( + descriptor: ResponsiveSelectFieldDescriptor | ResponsiveNumberFieldDescriptor, + label: string, + defaultValue: NoInfer> | NoInfer>, +): PropSpec { + return { + field: responsiveField(descriptor as any, label), + defaultValue: resolveResponsiveDefaultValue(defaultValue as any), + }; +} + +function resolveResponsiveDefaultValue( + value: NoInfer>, +): ResponsiveValue { + return value !== undefined && typeof value === "object" + ? value as ResponsiveValue + : { base: value } as ResponsiveValue; +} + // -- Builder: static fields --------------------------------------------------- type FieldDescriptor = { type: "select" | "radio"; label: string; options: TokenOption[] }; From 6195d77dee3522e0d6155ae04c65ed9acbef0a15 Mon Sep 17 00:00:00 2001 From: wlenig <30681316+wlenig@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:19:04 -0400 Subject: [PATCH 4/6] WEB-135 Mark server render helper as "server-only", add explicit error handling for unknown field components --- src/components/puck/fields/responsive-field-client.tsx | 6 +++++- src/components/puck/render.tsx | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/puck/fields/responsive-field-client.tsx b/src/components/puck/fields/responsive-field-client.tsx index 98340836..1b115b0a 100644 --- a/src/components/puck/fields/responsive-field-client.tsx +++ b/src/components/puck/fields/responsive-field-client.tsx @@ -9,7 +9,11 @@ import { export type { ResponsiveFieldClientProps } from "./responsive-kinds/client-registry"; export function ResponsiveFieldClient(props: ResponsiveFieldClientProps) { - const FieldComponent = responsiveFieldClientRegistry[props.descriptor.kind] as ComponentType; + const FieldComponent = responsiveFieldClientRegistry[props.descriptor.kind]; + + if (!FieldComponent) { + throw new Error(`Unknown responsive field kind: ${props.descriptor.kind}`); + } return ; } diff --git a/src/components/puck/render.tsx b/src/components/puck/render.tsx index 60b4e641..2dbd5a7e 100644 --- a/src/components/puck/render.tsx +++ b/src/components/puck/render.tsx @@ -1,3 +1,5 @@ +import "server-only"; + import { Render, type Data } from "@puckeditor/core"; import { config } from "@/puck.config"; From b494104d39f0cb5f0df896fefca955d16af94594 Mon Sep 17 00:00:00 2001 From: wlenig <30681316+wlenig@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:36:13 -0400 Subject: [PATCH 5/6] WEB-135 Extract document parsing logic --- src/app/[[...puckPath]]/page.tsx | 13 +++++- src/app/[[...puckPath]]/published-document.ts | 14 ------- src/app/editor/[id]/[slug]/EditorPage.tsx | 4 +- src/app/editor/[id]/[slug]/PreviewPage.tsx | 4 +- src/app/editor/[id]/[slug]/params.tsx | 40 +++---------------- src/app/editor/[id]/page.tsx | 2 +- .../puck/fields/responsive-field-client.tsx | 2 +- src/lib/documents/editor-route.ts | 34 ++++++++++++++++ .../documents}/version-selection.test.ts | 0 .../documents}/version-selection.ts | 0 10 files changed, 58 insertions(+), 55 deletions(-) delete mode 100644 src/app/[[...puckPath]]/published-document.ts create mode 100644 src/lib/documents/editor-route.ts rename src/{app/editor/[id]/[slug] => lib/documents}/version-selection.test.ts (100%) rename src/{app/editor/[id]/[slug] => lib/documents}/version-selection.ts (100%) diff --git a/src/app/[[...puckPath]]/page.tsx b/src/app/[[...puckPath]]/page.tsx index 57109d80..a6c02110 100644 --- a/src/app/[[...puckPath]]/page.tsx +++ b/src/app/[[...puckPath]]/page.tsx @@ -1,7 +1,8 @@ +import { cache } from "react"; import { Metadata } from "next"; import { notFound } from "next/navigation"; import { PuckRender } from "@/components/puck/render"; -import { getPublishedDocument } from "./published-document"; +import { getDocumentByPath } from "../../lib/documents/queries"; export async function generateMetadata({ params, @@ -28,3 +29,13 @@ export default async function Page({ } export const dynamic = "force-static"; + +async function getPublishedDocument(params: Promise<{ puckPath?: string[] }>) { + return getPublishedDocumentByPath(getPublishedPath(await params)); +} + +function getPublishedPath({ puckPath = [] }: { puckPath?: string[] }) { + return `/${puckPath.join("/")}`; +} + +const getPublishedDocumentByPath = cache(getDocumentByPath); diff --git a/src/app/[[...puckPath]]/published-document.ts b/src/app/[[...puckPath]]/published-document.ts deleted file mode 100644 index 81e2f4f9..00000000 --- a/src/app/[[...puckPath]]/published-document.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { cache } from "react"; -import { getDocumentByPath } from "../../lib/documents/queries"; - -type PublishedRouteParams = Promise<{ puckPath?: string[] }>; - -export async function getPublishedDocument(params: PublishedRouteParams) { - return getPublishedDocumentByPath(getPublishedPath(await params)); -} - -function getPublishedPath({ puckPath = [] }: { puckPath?: string[] }) { - return `/${puckPath.join("/")}`; -} - -const getPublishedDocumentByPath = cache(getDocumentByPath); diff --git a/src/app/editor/[id]/[slug]/EditorPage.tsx b/src/app/editor/[id]/[slug]/EditorPage.tsx index f75ad5f3..6aa99d78 100644 --- a/src/app/editor/[id]/[slug]/EditorPage.tsx +++ b/src/app/editor/[id]/[slug]/EditorPage.tsx @@ -1,10 +1,10 @@ import "@puckeditor/core/puck.css"; import { notFound } from "next/navigation"; import { Client } from "./client"; +import { loadDocument } from "../../../../lib/documents/editor-route"; import { getVersionContent } from "../../../../lib/documents/queries"; +import { resolveEditorVersionId } from "../../../../lib/documents/version-selection"; import { createEmptyPuckData } from "../../../../lib/puck/utils"; -import { resolveEditorVersionId } from "./version-selection"; -import { loadDocument } from "./params"; export default async function EditorPage({ documentId, diff --git a/src/app/editor/[id]/[slug]/PreviewPage.tsx b/src/app/editor/[id]/[slug]/PreviewPage.tsx index 45850191..ece1a3f7 100644 --- a/src/app/editor/[id]/[slug]/PreviewPage.tsx +++ b/src/app/editor/[id]/[slug]/PreviewPage.tsx @@ -1,8 +1,8 @@ import { notFound } from "next/navigation"; import { PuckRender } from "@/components/puck/render"; +import { loadDocument } from "../../../../lib/documents/editor-route"; import { getVersionContent } from "../../../../lib/documents/queries"; -import { resolvePreviewVersionId } from "./version-selection"; -import { loadDocument } from "./params"; +import { resolvePreviewVersionId } from "../../../../lib/documents/version-selection"; export default async function PreviewPage({ documentId, diff --git a/src/app/editor/[id]/[slug]/params.tsx b/src/app/editor/[id]/[slug]/params.tsx index 463687fd..1eb136a5 100644 --- a/src/app/editor/[id]/[slug]/params.tsx +++ b/src/app/editor/[id]/[slug]/params.tsx @@ -1,38 +1,10 @@ import type { Metadata } from "next"; -import { notFound, redirect } from "next/navigation"; -import { getDocumentById, getDocumentName } from "../../../../lib/documents/queries"; -import { getEditorSlug, getEditorUrl } from "../../../../lib/editor-url"; - -export function parseDocumentId(id: string): number | null { - const parsed = parseInt(id, 10); - return isNaN(parsed) ? null : parsed; -} - -export function parseVersionId(versionId: string): number | null { - const parsed = parseInt(versionId, 10); - return isNaN(parsed) ? null : parsed; -} - -/** - * Fetches a document by ID and validates the URL slug, redirecting if it - * doesn't match. Calls `notFound()` when the document doesn't exist. - */ -export async function loadDocument( - documentId: number, - slug: string, - options?: { versionId?: number; redirectSuffix?: string }, -) { - const document = await getDocumentById(documentId); - if (!document) notFound(); - - const expectedSlug = getEditorSlug(document.name); - if (slug !== expectedSlug) { - const base = getEditorUrl(documentId, document.name, options?.versionId); - redirect(`${base}${options?.redirectSuffix ?? ""}`); - } - - return document; -} +import { notFound } from "next/navigation"; +import { + parseDocumentId, + parseVersionId, +} from "../../../../lib/documents/editor-route"; +import { getDocumentName } from "../../../../lib/documents/queries"; /** * Creates the standard `generateMetadata` and default-export `Page` for a diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index dd2c95c8..3b6de38b 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -1,8 +1,8 @@ import { redirect } from "next/navigation"; import { notFound } from "next/navigation"; +import { parseDocumentId } from "../../../lib/documents/editor-route"; import { getDocumentName } from "../../../lib/documents/queries"; import { getEditorUrl } from "../../../lib/editor-url"; -import { parseDocumentId } from "./[slug]/params"; interface PageProps { params: Promise<{ id: string }>; diff --git a/src/components/puck/fields/responsive-field-client.tsx b/src/components/puck/fields/responsive-field-client.tsx index 1b115b0a..ec92a710 100644 --- a/src/components/puck/fields/responsive-field-client.tsx +++ b/src/components/puck/fields/responsive-field-client.tsx @@ -9,7 +9,7 @@ import { export type { ResponsiveFieldClientProps } from "./responsive-kinds/client-registry"; export function ResponsiveFieldClient(props: ResponsiveFieldClientProps) { - const FieldComponent = responsiveFieldClientRegistry[props.descriptor.kind]; + const FieldComponent = responsiveFieldClientRegistry[props.descriptor.kind] as ComponentType | undefined; if (!FieldComponent) { throw new Error(`Unknown responsive field kind: ${props.descriptor.kind}`); diff --git a/src/lib/documents/editor-route.ts b/src/lib/documents/editor-route.ts new file mode 100644 index 00000000..9ceb908c --- /dev/null +++ b/src/lib/documents/editor-route.ts @@ -0,0 +1,34 @@ +import { notFound, redirect } from "next/navigation"; +import { getEditorSlug, getEditorUrl } from "../editor-url"; +import { getDocumentById } from "./queries"; + +export function parseDocumentId(id: string): number | null { + const parsed = parseInt(id, 10); + return isNaN(parsed) ? null : parsed; +} + +export function parseVersionId(versionId: string): number | null { + const parsed = parseInt(versionId, 10); + return isNaN(parsed) ? null : parsed; +} + +/** + * Fetches a document by ID and validates the URL slug, redirecting if it + * doesn't match. Calls `notFound()` when the document doesn't exist. + */ +export async function loadDocument( + documentId: number, + slug: string, + options?: { versionId?: number; redirectSuffix?: string }, +) { + const document = await getDocumentById(documentId); + if (!document) notFound(); + + const expectedSlug = getEditorSlug(document.name); + if (slug !== expectedSlug) { + const base = getEditorUrl(documentId, document.name, options?.versionId); + redirect(`${base}${options?.redirectSuffix ?? ""}`); + } + + return document; +} diff --git a/src/app/editor/[id]/[slug]/version-selection.test.ts b/src/lib/documents/version-selection.test.ts similarity index 100% rename from src/app/editor/[id]/[slug]/version-selection.test.ts rename to src/lib/documents/version-selection.test.ts diff --git a/src/app/editor/[id]/[slug]/version-selection.ts b/src/lib/documents/version-selection.ts similarity index 100% rename from src/app/editor/[id]/[slug]/version-selection.ts rename to src/lib/documents/version-selection.ts From 18e6e30a4e8fc324cdf1d56223df0941f4152c35 Mon Sep 17 00:00:00 2001 From: wlenig <30681316+wlenig@users.noreply.github.com> Date: Tue, 31 Mar 2026 01:35:05 -0400 Subject: [PATCH 6/6] WEB-135 Re-organize components --- docs/STYLING.md | 16 ++++++++-------- src/app/[[...puckPath]]/page.tsx | 4 +++- src/app/editor/[id]/[slug]/PreviewPage.tsx | 2 +- src/components/puck/{ => blocks}/button.tsx | 0 src/components/puck/{ => blocks}/columns.tsx | 0 src/components/puck/{ => blocks}/container.tsx | 0 src/components/puck/{ => blocks}/grid.tsx | 0 src/components/puck/{ => blocks}/media.tsx | 0 src/components/puck/{ => blocks}/rich-text.tsx | 0 src/components/puck/{ => blocks}/section.tsx | 0 .../puck/{render.tsx => puck-render.server.tsx} | 0 src/lib/puck/define-props.test.ts | 2 +- src/lib/puck/define-props.ts | 2 +- .../puck/fields/responsive/client.tsx} | 4 ++-- .../puck/fields/responsive/controls.test.ts} | 4 ++-- .../puck/fields/responsive/frame.tsx} | 0 .../puck/fields/responsive/index.tsx} | 8 ++++---- .../puck/fields/responsive/kinds}/index.ts | 0 .../fields/responsive/kinds}/number/client.tsx | 4 ++-- .../fields/responsive/kinds}/number/shared.ts | 0 .../puck/fields/responsive/kinds/registry.tsx} | 0 .../fields/responsive/kinds}/select/client.tsx | 4 ++-- .../fields/responsive/kinds}/select/shared.ts | 0 .../puck/fields/responsive/number-control.tsx} | 2 +- .../puck/fields/responsive/select-control.tsx} | 2 +- src/puck.config.tsx | 14 +++++++------- 26 files changed, 35 insertions(+), 33 deletions(-) rename src/components/puck/{ => blocks}/button.tsx (100%) rename src/components/puck/{ => blocks}/columns.tsx (100%) rename src/components/puck/{ => blocks}/container.tsx (100%) rename src/components/puck/{ => blocks}/grid.tsx (100%) rename src/components/puck/{ => blocks}/media.tsx (100%) rename src/components/puck/{ => blocks}/rich-text.tsx (100%) rename src/components/puck/{ => blocks}/section.tsx (100%) rename src/components/puck/{render.tsx => puck-render.server.tsx} (100%) rename src/{components/puck/fields/responsive-field-client.tsx => lib/puck/fields/responsive/client.tsx} (78%) rename src/{components/puck/fields/responsive-field-controls.test.ts => lib/puck/fields/responsive/controls.test.ts} (95%) rename src/{components/puck/fields/responsive-field-frame.tsx => lib/puck/fields/responsive/frame.tsx} (100%) rename src/{components/puck/fields/responsive-field.tsx => lib/puck/fields/responsive/index.tsx} (88%) rename src/{components/puck/fields/responsive-kinds => lib/puck/fields/responsive/kinds}/index.ts (100%) rename src/{components/puck/fields/responsive-kinds => lib/puck/fields/responsive/kinds}/number/client.tsx (85%) rename src/{components/puck/fields/responsive-kinds => lib/puck/fields/responsive/kinds}/number/shared.ts (100%) rename src/{components/puck/fields/responsive-kinds/client-registry.tsx => lib/puck/fields/responsive/kinds/registry.tsx} (100%) rename src/{components/puck/fields/responsive-kinds => lib/puck/fields/responsive/kinds}/select/client.tsx (85%) rename src/{components/puck/fields/responsive-kinds => lib/puck/fields/responsive/kinds}/select/shared.ts (100%) rename src/{components/puck/fields/responsive-number-control.tsx => lib/puck/fields/responsive/number-control.tsx} (95%) rename src/{components/puck/fields/responsive-select-control.tsx => lib/puck/fields/responsive/select-control.tsx} (94%) diff --git a/docs/STYLING.md b/docs/STYLING.md index 76efda17..9aaa81b6 100644 --- a/docs/STYLING.md +++ b/docs/STYLING.md @@ -132,19 +132,19 @@ The same token definition works for both static and responsive use. Numeric resp Responsive field descriptors are built on the server and rendered on the client, so the implementation is split intentionally: -- Shared kind definitions live in `src/components/puck/fields/responsive-kinds/*/shared.ts`. -- Client renderers live in `src/components/puck/fields/responsive-kinds/*/client.tsx`. -- `src/components/puck/fields/responsive-field.tsx` is the server-safe wrapper that turns a serializable descriptor into a Puck custom field. -- `src/components/puck/fields/responsive-field-client.tsx` is the client-only registry that maps `descriptor.kind` to the right renderer. -- `src/components/puck/fields/responsive-field-frame.tsx` owns the shared breakpoint UI; kind-specific controls only deal with their own value format. +- Shared kind definitions live in `src/lib/puck/fields/responsive/kinds/*/shared.ts`. +- Client renderers live in `src/lib/puck/fields/responsive/kinds/*/client.tsx`. +- `src/lib/puck/fields/responsive/index.tsx` is the server-safe wrapper that turns a serializable descriptor into a Puck custom field. +- `src/lib/puck/fields/responsive/client.tsx` is the client-only registry that maps `descriptor.kind` to the right renderer. +- `src/lib/puck/fields/responsive/frame.tsx` owns the shared breakpoint UI; kind-specific controls only deal with their own value format. This keeps the descriptor shape and the renderer associated by kind without pushing client code across the server boundary. To add a new responsive field kind: -1. Add `shared.ts` and `client.tsx` under `src/components/puck/fields/responsive-kinds//`. -2. Export the descriptor factory and type from `src/components/puck/fields/responsive-kinds/index.ts`. -3. Register the client component in `src/components/puck/fields/responsive-kinds/client-registry.tsx`. +1. Add `shared.ts` and `client.tsx` under `src/lib/puck/fields/responsive/kinds//`. +2. Export the descriptor factory and type from `src/lib/puck/fields/responsive/kinds/index.ts`. +3. Register the client component in `src/lib/puck/fields/responsive/kinds/registry.tsx`. 4. Optionally add a convenience builder in `src/lib/puck/define-props.ts`. ### 4. Rendering diff --git a/src/app/[[...puckPath]]/page.tsx b/src/app/[[...puckPath]]/page.tsx index a6c02110..34dcb128 100644 --- a/src/app/[[...puckPath]]/page.tsx +++ b/src/app/[[...puckPath]]/page.tsx @@ -1,7 +1,7 @@ import { cache } from "react"; import { Metadata } from "next"; import { notFound } from "next/navigation"; -import { PuckRender } from "@/components/puck/render"; +import { PuckRender } from "@/components/puck/puck-render.server"; import { getDocumentByPath } from "../../lib/documents/queries"; export async function generateMetadata({ @@ -38,4 +38,6 @@ function getPublishedPath({ puckPath = [] }: { puckPath?: string[] }) { return `/${puckPath.join("/")}`; } +// Deduplicate the path lookup within a single render/request so metadata and page +// generation can share the same document fetch without another DB round-trip. const getPublishedDocumentByPath = cache(getDocumentByPath); diff --git a/src/app/editor/[id]/[slug]/PreviewPage.tsx b/src/app/editor/[id]/[slug]/PreviewPage.tsx index ece1a3f7..a3e7f98a 100644 --- a/src/app/editor/[id]/[slug]/PreviewPage.tsx +++ b/src/app/editor/[id]/[slug]/PreviewPage.tsx @@ -1,5 +1,5 @@ import { notFound } from "next/navigation"; -import { PuckRender } from "@/components/puck/render"; +import { PuckRender } from "@/components/puck/puck-render.server"; import { loadDocument } from "../../../../lib/documents/editor-route"; import { getVersionContent } from "../../../../lib/documents/queries"; import { resolvePreviewVersionId } from "../../../../lib/documents/version-selection"; diff --git a/src/components/puck/button.tsx b/src/components/puck/blocks/button.tsx similarity index 100% rename from src/components/puck/button.tsx rename to src/components/puck/blocks/button.tsx diff --git a/src/components/puck/columns.tsx b/src/components/puck/blocks/columns.tsx similarity index 100% rename from src/components/puck/columns.tsx rename to src/components/puck/blocks/columns.tsx diff --git a/src/components/puck/container.tsx b/src/components/puck/blocks/container.tsx similarity index 100% rename from src/components/puck/container.tsx rename to src/components/puck/blocks/container.tsx diff --git a/src/components/puck/grid.tsx b/src/components/puck/blocks/grid.tsx similarity index 100% rename from src/components/puck/grid.tsx rename to src/components/puck/blocks/grid.tsx diff --git a/src/components/puck/media.tsx b/src/components/puck/blocks/media.tsx similarity index 100% rename from src/components/puck/media.tsx rename to src/components/puck/blocks/media.tsx diff --git a/src/components/puck/rich-text.tsx b/src/components/puck/blocks/rich-text.tsx similarity index 100% rename from src/components/puck/rich-text.tsx rename to src/components/puck/blocks/rich-text.tsx diff --git a/src/components/puck/section.tsx b/src/components/puck/blocks/section.tsx similarity index 100% rename from src/components/puck/section.tsx rename to src/components/puck/blocks/section.tsx diff --git a/src/components/puck/render.tsx b/src/components/puck/puck-render.server.tsx similarity index 100% rename from src/components/puck/render.tsx rename to src/components/puck/puck-render.server.tsx diff --git a/src/lib/puck/define-props.test.ts b/src/lib/puck/define-props.test.ts index d97edfbf..9c193565 100644 --- a/src/lib/puck/define-props.test.ts +++ b/src/lib/puck/define-props.test.ts @@ -1,7 +1,7 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import { defineProps, field, responsive } from "./define-props"; import { defineToken } from "@/lib/puck/tokens"; -import { responsiveFieldDescriptor } from "@/components/puck/fields/responsive-field"; +import { responsiveFieldDescriptor } from "@/lib/puck/fields/responsive"; const size = defineToken({ sm: "Small", md: "Medium", lg: "Large" }); const colorToken = defineToken({ diff --git a/src/lib/puck/define-props.ts b/src/lib/puck/define-props.ts index 6e1e088c..b44fb2c2 100644 --- a/src/lib/puck/define-props.ts +++ b/src/lib/puck/define-props.ts @@ -6,7 +6,7 @@ import { responsiveFieldDescriptor, type ResponsiveNumberFieldDescriptor, type ResponsiveSelectFieldDescriptor, -} from "@/components/puck/fields/responsive-field"; +} from "@/lib/puck/fields/responsive"; // -- Prop spec shape ---------------------------------------------------------- diff --git a/src/components/puck/fields/responsive-field-client.tsx b/src/lib/puck/fields/responsive/client.tsx similarity index 78% rename from src/components/puck/fields/responsive-field-client.tsx rename to src/lib/puck/fields/responsive/client.tsx index ec92a710..309f245b 100644 --- a/src/components/puck/fields/responsive-field-client.tsx +++ b/src/lib/puck/fields/responsive/client.tsx @@ -4,9 +4,9 @@ import type { ComponentType } from "react"; import { responsiveFieldClientRegistry, type ResponsiveFieldClientProps, -} from "./responsive-kinds/client-registry"; +} from "./kinds/registry"; -export type { ResponsiveFieldClientProps } from "./responsive-kinds/client-registry"; +export type { ResponsiveFieldClientProps } from "./kinds/registry"; export function ResponsiveFieldClient(props: ResponsiveFieldClientProps) { const FieldComponent = responsiveFieldClientRegistry[props.descriptor.kind] as ComponentType | undefined; diff --git a/src/components/puck/fields/responsive-field-controls.test.ts b/src/lib/puck/fields/responsive/controls.test.ts similarity index 95% rename from src/components/puck/fields/responsive-field-controls.test.ts rename to src/lib/puck/fields/responsive/controls.test.ts index a03e8a3d..763151c2 100644 --- a/src/components/puck/fields/responsive-field-controls.test.ts +++ b/src/lib/puck/fields/responsive/controls.test.ts @@ -2,11 +2,11 @@ import { describe, expect, it } from "vitest"; import { fromResponsiveSelectValue, toResponsiveSelectValue, -} from "./responsive-select-control"; +} from "./select-control"; import { formatResponsiveNumberValue, getResponsiveNumberChange, -} from "./responsive-number-control"; +} from "./number-control"; describe("responsive select control helpers", () => { it("translates inherited select values at the UI boundary", () => { diff --git a/src/components/puck/fields/responsive-field-frame.tsx b/src/lib/puck/fields/responsive/frame.tsx similarity index 100% rename from src/components/puck/fields/responsive-field-frame.tsx rename to src/lib/puck/fields/responsive/frame.tsx diff --git a/src/components/puck/fields/responsive-field.tsx b/src/lib/puck/fields/responsive/index.tsx similarity index 88% rename from src/components/puck/fields/responsive-field.tsx rename to src/lib/puck/fields/responsive/index.tsx index 1da46c87..99c4f9ae 100644 --- a/src/components/puck/fields/responsive-field.tsx +++ b/src/lib/puck/fields/responsive/index.tsx @@ -1,16 +1,16 @@ import type { CustomField } from "@puckeditor/core"; import type { ResponsiveValue } from "@/lib/puck/responsive"; -import { ResponsiveFieldClient } from "./responsive-field-client"; +import { ResponsiveFieldClient } from "./client"; import { type ResponsiveNumberFieldDescriptor, type ResponsiveSelectFieldDescriptor, -} from "./responsive-kinds"; +} from "./kinds"; -export { responsiveFieldDescriptor } from "./responsive-kinds"; +export { responsiveFieldDescriptor } from "./kinds"; export type { ResponsiveNumberFieldDescriptor, ResponsiveSelectFieldDescriptor, -} from "./responsive-kinds"; +} from "./kinds"; export function responsiveField( descriptor: ResponsiveSelectFieldDescriptor, diff --git a/src/components/puck/fields/responsive-kinds/index.ts b/src/lib/puck/fields/responsive/kinds/index.ts similarity index 100% rename from src/components/puck/fields/responsive-kinds/index.ts rename to src/lib/puck/fields/responsive/kinds/index.ts diff --git a/src/components/puck/fields/responsive-kinds/number/client.tsx b/src/lib/puck/fields/responsive/kinds/number/client.tsx similarity index 85% rename from src/components/puck/fields/responsive-kinds/number/client.tsx rename to src/lib/puck/fields/responsive/kinds/number/client.tsx index 0f9809da..57cef431 100644 --- a/src/components/puck/fields/responsive-kinds/number/client.tsx +++ b/src/lib/puck/fields/responsive/kinds/number/client.tsx @@ -1,8 +1,8 @@ "use client"; import type { ResponsiveValue } from "@/lib/puck/responsive"; -import { ResponsiveFieldFrame } from "../../responsive-field-frame"; -import { ResponsiveNumberControl } from "../../responsive-number-control"; +import { ResponsiveFieldFrame } from "../../frame"; +import { ResponsiveNumberControl } from "../../number-control"; import type { ResponsiveNumberFieldDescriptor } from "./shared"; export type ResponsiveNumberFieldProps = { diff --git a/src/components/puck/fields/responsive-kinds/number/shared.ts b/src/lib/puck/fields/responsive/kinds/number/shared.ts similarity index 100% rename from src/components/puck/fields/responsive-kinds/number/shared.ts rename to src/lib/puck/fields/responsive/kinds/number/shared.ts diff --git a/src/components/puck/fields/responsive-kinds/client-registry.tsx b/src/lib/puck/fields/responsive/kinds/registry.tsx similarity index 100% rename from src/components/puck/fields/responsive-kinds/client-registry.tsx rename to src/lib/puck/fields/responsive/kinds/registry.tsx diff --git a/src/components/puck/fields/responsive-kinds/select/client.tsx b/src/lib/puck/fields/responsive/kinds/select/client.tsx similarity index 85% rename from src/components/puck/fields/responsive-kinds/select/client.tsx rename to src/lib/puck/fields/responsive/kinds/select/client.tsx index d4e0c77c..d575071f 100644 --- a/src/components/puck/fields/responsive-kinds/select/client.tsx +++ b/src/lib/puck/fields/responsive/kinds/select/client.tsx @@ -1,8 +1,8 @@ "use client"; import type { ResponsiveValue } from "@/lib/puck/responsive"; -import { ResponsiveFieldFrame } from "../../responsive-field-frame"; -import { ResponsiveSelectControl } from "../../responsive-select-control"; +import { ResponsiveFieldFrame } from "../../frame"; +import { ResponsiveSelectControl } from "../../select-control"; import type { ResponsiveSelectFieldDescriptor } from "./shared"; export type ResponsiveSelectFieldProps = { diff --git a/src/components/puck/fields/responsive-kinds/select/shared.ts b/src/lib/puck/fields/responsive/kinds/select/shared.ts similarity index 100% rename from src/components/puck/fields/responsive-kinds/select/shared.ts rename to src/lib/puck/fields/responsive/kinds/select/shared.ts diff --git a/src/components/puck/fields/responsive-number-control.tsx b/src/lib/puck/fields/responsive/number-control.tsx similarity index 95% rename from src/components/puck/fields/responsive-number-control.tsx rename to src/lib/puck/fields/responsive/number-control.tsx index c0667408..d730989b 100644 --- a/src/components/puck/fields/responsive-number-control.tsx +++ b/src/lib/puck/fields/responsive/number-control.tsx @@ -1,5 +1,5 @@ import { Input } from "@/components/ui/input"; -import type { ResponsiveFieldControlProps } from "./responsive-field-frame"; +import type { ResponsiveFieldControlProps } from "./frame"; export type ResponsiveNumberChange = | { kind: "ignore" } diff --git a/src/components/puck/fields/responsive-select-control.tsx b/src/lib/puck/fields/responsive/select-control.tsx similarity index 94% rename from src/components/puck/fields/responsive-select-control.tsx rename to src/lib/puck/fields/responsive/select-control.tsx index 2dd93eb8..750e274b 100644 --- a/src/components/puck/fields/responsive-select-control.tsx +++ b/src/lib/puck/fields/responsive/select-control.tsx @@ -1,6 +1,6 @@ import { AutoField } from "@puckeditor/core"; import type { TokenOption } from "@/lib/puck/tokens"; -import type { ResponsiveFieldControlProps } from "./responsive-field-frame"; +import type { ResponsiveFieldControlProps } from "./frame"; // Non-base breakpoints can clear their override and inherit from the smaller breakpoint. const inheritOption: TokenOption<""> = { label: "–", value: "" }; diff --git a/src/puck.config.tsx b/src/puck.config.tsx index d32cc6bf..e4f813d0 100644 --- a/src/puck.config.tsx +++ b/src/puck.config.tsx @@ -1,11 +1,11 @@ import type { Config } from "@puckeditor/core"; -import { Container } from "@/components/puck/container"; -import { Columns } from "@/components/puck/columns"; -import { Grid } from "@/components/puck/grid"; -import { RichTextComponent } from "@/components/puck/rich-text"; -import { Media } from "@/components/puck/media"; -import { PuckButton } from "@/components/puck/button"; -import { Section } from "@/components/puck/section"; +import { Container } from "@/components/puck/blocks/container"; +import { Columns } from "@/components/puck/blocks/columns"; +import { Grid } from "@/components/puck/blocks/grid"; +import { RichTextComponent } from "@/components/puck/blocks/rich-text"; +import { Media } from "@/components/puck/blocks/media"; +import { PuckButton } from "@/components/puck/blocks/button"; +import { Section } from "@/components/puck/blocks/section"; export const config = { categories: {