+ 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: {