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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 47 additions & 9 deletions docs/STYLING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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" }, ""),
Expand All @@ -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:
Expand All @@ -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/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/lib/puck/fields/responsive/kinds/<kind>/`.
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

Expand Down Expand Up @@ -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`:
Expand Down Expand Up @@ -187,31 +219,37 @@ import { padding, bgColor, type Spacing, type Color } from "@/lib/puck/tokens";
type CardProps = {
content: Slot;
padding: ResponsiveValue<Spacing>;
columns: ResponsiveValue<number>;
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" }, ""),
});
```

### 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<CardProps> = {
label: "Card",
...props,
render: ({ content: Content, padding: pad, bgColor: bg, title }) => (
<div className={cn(bgColor.classes[bg], resolveResponsive(pad, padding.classes))}>
render: ({ content: Content, padding: pad, columns, bgColor: bg, title }) => (
<div
className={cn(bgColor.classes[bg], resolveResponsive(pad, padding.classes))}
style={{ columns: resolveAt(columns, "base") }}
>
{title && <h3>{title}</h3>}
{Content && <Content />}
</div>
Expand Down
9 changes: 0 additions & 9 deletions src/app/[...puckPath]/client.tsx

This file was deleted.

37 changes: 0 additions & 37 deletions src/app/[...puckPath]/page.tsx

This file was deleted.

43 changes: 43 additions & 0 deletions src/app/[[...puckPath]]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { cache } from "react";
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { PuckRender } from "@/components/puck/puck-render.server";
import { getDocumentByPath } from "../../lib/documents/queries";

export async function generateMetadata({
params,
}: {
params: Promise<{ puckPath?: string[] }>;
}): Promise<Metadata> {
return {
title: (await getPublishedDocument(params))?.root.props?.title,
};
}

export default async function Page({
params,
}: {
params: Promise<{ puckPath?: string[] }>;
}) {
const data = await getPublishedDocument(params);

if (!data) {
return notFound();
}

return <PuckRender data={data} />;
}

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("/")}`;
}

// 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);
4 changes: 2 additions & 2 deletions src/app/editor/[id]/[slug]/EditorPage.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
8 changes: 4 additions & 4 deletions src/app/editor/[id]/[slug]/PreviewPage.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { notFound } from "next/navigation";
import { Client } from "../../../[...puckPath]/client";
import { PuckRender } from "@/components/puck/puck-render.server";
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,
Expand All @@ -29,5 +29,5 @@ export default async function PreviewPage({
const data = await getVersionContent(targetVersionId);
if (!data) notFound();

return <Client data={data} />;
return <PuckRender data={data} />;
}
40 changes: 6 additions & 34 deletions src/app/editor/[id]/[slug]/params.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/app/editor/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 }>;
Expand Down
17 changes: 0 additions & 17 deletions src/app/page.tsx

This file was deleted.

File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ const columnSlotDefaults = Object.fromEntries(
) as Record<SlotKey, Slot>;

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<ColumnsProps> = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<GridProps> = {
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -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<RichTextProps> = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" }),
Expand Down
Loading
Loading