@delmaredigital/payload-puck 0.6.28 → 0.6.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,8 +3,6 @@
3
3
  A PayloadCMS plugin for integrating [Puck](https://puckeditor.com) visual page builder. Build pages visually with drag-and-drop components while leveraging Payload's content management capabilities.
4
4
 
5
5
  <p align="center">
6
- <a href="https://demo.delmaredigital.com"><img src="https://img.shields.io/badge/Live_Demo-Try_It_Now-2ea44f?style=for-the-badge&logo=vercel&logoColor=white" alt="Live Demo - Try It Now"></a>
7
- &nbsp;&nbsp;
8
6
  <a href="https://github.com/delmaredigital/dd-starter"><img src="https://img.shields.io/badge/Starter_Template-Use_This-blue?style=for-the-badge&logo=github&logoColor=white" alt="Starter Template - Use This"></a>
9
7
  </p>
10
8
 
@@ -225,10 +225,13 @@ export const ColumnsConfig = {
225
225
  // Self-contained CSS grid — does NOT depend on the consumer's Tailwind
226
226
  // generating utility classes. Single column on mobile (stacked); switches to
227
227
  // the multi-column template at >=768px via the scoped media query below.
228
+ // NOTE: grid-template-columns must NOT be set inline — an inline style would
229
+ // outrank the @media rule below (inline beats stylesheet, media queries
230
+ // included), pinning the layout to one column. Both the mobile base and the
231
+ // responsive override live in the scoped <style> so the media query can win.
228
232
  const colsTemplate = resolveColumnsTemplate(safeCount, distribution);
229
233
  const gridStyles = {
230
234
  display: 'grid',
231
- gridTemplateColumns: '1fr',
232
235
  gap,
233
236
  ...dimensionsResult.baseStyles,
234
237
  '--cols-template': colsTemplate
@@ -260,6 +263,7 @@ export const ColumnsConfig = {
260
263
  }),
261
264
  /*#__PURE__*/ _jsx("style", {
262
265
  children: `
266
+ .${contentClass} { grid-template-columns: 1fr; }
263
267
  @media (min-width: 768px) {
264
268
  .${contentClass} {
265
269
  grid-template-columns: var(--cols-template);
@@ -106,10 +106,13 @@ export const ColumnsConfig = {
106
106
  // generating utility classes. Single column on mobile (stacked); switches to
107
107
  // the multi-column template at >=768px via the scoped media query below.
108
108
  // grid-template-columns is dynamic, so drive it from a CSS var.
109
+ // NOTE: grid-template-columns must NOT be set inline — an inline style would
110
+ // outrank the @media rule below (inline beats stylesheet, media queries
111
+ // included), pinning the layout to one column. Both the mobile base and the
112
+ // responsive override live in the scoped <style> so the media query can win.
109
113
  const colsTemplate = resolveColumnsTemplate(safeCount, distribution);
110
114
  const gridStyles = {
111
115
  display: 'grid',
112
- gridTemplateColumns: '1fr',
113
116
  gap,
114
117
  ...dimensionsResult.baseStyles,
115
118
  '--cols-template': colsTemplate
@@ -141,6 +144,7 @@ export const ColumnsConfig = {
141
144
  }),
142
145
  /*#__PURE__*/ _jsx("style", {
143
146
  children: `
147
+ .${contentClass} { grid-template-columns: 1fr; }
144
148
  @media (min-width: 768px) {
145
149
  .${contentClass} {
146
150
  grid-template-columns: var(--cols-template);
@@ -183,9 +183,12 @@ export const GridConfig = {
183
183
  // generating utility classes. Single column on mobile (children stacked);
184
184
  // switches to the multi-column track at >=768px via the scoped media query.
185
185
  // grid-template-columns is dynamic, so drive the column count from a CSS var.
186
+ // NOTE: grid-template-columns must NOT be set inline — an inline style would
187
+ // outrank the @media rule below (inline beats stylesheet, media queries
188
+ // included), pinning the layout to one column. Both the mobile base and the
189
+ // responsive override live in the scoped <style> so the media query can win.
186
190
  const gridStyles = {
187
191
  display: 'grid',
188
- gridTemplateColumns: '1fr',
189
192
  gap,
190
193
  ...dimensionsResult.baseStyles,
191
194
  '--grid-cols': numColumns
@@ -208,6 +211,7 @@ export const GridConfig = {
208
211
  }),
209
212
  /*#__PURE__*/ _jsx("style", {
210
213
  children: `
214
+ .${contentClass} { grid-template-columns: 1fr; }
211
215
  @media (min-width: 768px) {
212
216
  .${contentClass} {
213
217
  grid-template-columns: repeat(var(--grid-cols), 1fr);
@@ -92,9 +92,12 @@ export const GridConfig = {
92
92
  // generating utility classes. Single column on mobile (children stacked);
93
93
  // switches to the multi-column track at >=768px via the scoped media query.
94
94
  // grid-template-columns is dynamic, so drive the column count from a CSS var.
95
+ // NOTE: grid-template-columns must NOT be set inline — an inline style would
96
+ // outrank the @media rule below (inline beats stylesheet, media queries
97
+ // included), pinning the layout to one column. Both the mobile base and the
98
+ // responsive override live in the scoped <style> so the media query can win.
95
99
  const gridStyles = {
96
100
  display: 'grid',
97
- gridTemplateColumns: '1fr',
98
101
  gap,
99
102
  ...dimensionsResult.baseStyles,
100
103
  '--grid-cols': numColumns
@@ -119,6 +122,7 @@ export const GridConfig = {
119
122
  }),
120
123
  /*#__PURE__*/ _jsx("style", {
121
124
  children: `
125
+ .${contentClass} { grid-template-columns: 1fr; }
122
126
  @media (min-width: 768px) {
123
127
  .${contentClass} {
124
128
  grid-template-columns: repeat(var(--grid-cols), 1fr);
@@ -135,8 +135,10 @@ import { useAiPrompts } from '../ai/hooks/useAiPrompts.js';
135
135
  const fromBase = baseStylesheets || [];
136
136
  const fromLayout = currentLayout?.editorStylesheets || [];
137
137
  return [
138
- ...fromBase,
139
- ...fromLayout
138
+ ...new Set([
139
+ ...fromBase,
140
+ ...fromLayout
141
+ ])
140
142
  ];
141
143
  }, [
142
144
  baseStylesheets,
@@ -78,14 +78,18 @@ export interface IframeWrapperProps {
78
78
  */
79
79
  defaultLayout?: string;
80
80
  /**
81
- * Stylesheet URLs to inject into the iframe.
81
+ * Stylesheet URLs to render inside the iframe.
82
82
  * These are merged from PuckConfigProvider and layout-specific settings.
83
83
  * Use this to provide frontend CSS (Tailwind, CSS variables, etc.) that
84
84
  * header/footer components need for proper styling.
85
+ *
86
+ * Rendered as ordinary React children (not host-document resources), so
87
+ * this CSS only ever affects the iframe's own document, never the
88
+ * Payload admin page it's embedded in.
85
89
  */
86
90
  editorStylesheets?: string[];
87
91
  /**
88
- * Raw CSS to inject into the iframe.
92
+ * Raw CSS to render inside the iframe.
89
93
  * Merged from PuckConfigProvider and layout-specific settings.
90
94
  * Useful for CSS variables or style overrides.
91
95
  */
@@ -1,6 +1,6 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { memo, useEffect, useMemo, useState, createContext, useContext } from 'react';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { memo, useEffect, useMemo, useState, useCallback, createContext, useContext } from 'react';
4
4
  import { createUsePuck } from '@puckeditor/core';
5
5
  import { backgroundValueToCSS } from '../../fields/shared.js';
6
6
  // Create usePuck hook for accessing editor state
@@ -63,8 +63,6 @@ const usePuck = createUsePuck();
63
63
  * ```
64
64
  */ export const IframeWrapper = /*#__PURE__*/ memo(function IframeWrapper({ children, document: iframeDoc, layouts, layoutStyles, layoutKey = 'pageLayout', defaultLayout = 'default', editorStylesheets, editorCss, previewDarkModeOverride }) {
65
65
  const appState = usePuck((s)=>s.appState);
66
- // Track stylesheet loading state to force re-render when styles are ready
67
- const [stylesLoaded, setStylesLoaded] = useState(false);
68
66
  // Check if we're in interactive mode (links should be clickable)
69
67
  const isInteractive = appState.ui.previewMode === 'interactive';
70
68
  // Read layout value and page-level overrides from root props
@@ -106,6 +104,44 @@ const usePuck = createUsePuck();
106
104
  const layoutConfig = layoutConfigMap[layoutValue] || layoutConfigMap[defaultLayout] || DEFAULT_LAYOUT_CONFIG;
107
105
  // Calculate isDark for context provider (same logic as in useEffect)
108
106
  const isDark = previewDarkModeOverride ?? layoutConfig.isDark;
107
+ // Resolve relative stylesheet URLs to absolute URLs. Puck's iframe uses
108
+ // srcDoc, whose relative-URL resolution can't be relied on to match the
109
+ // host origin, so resolve explicitly against window.location.origin.
110
+ const resolvedStylesheets = useMemo(()=>{
111
+ if (!editorStylesheets || editorStylesheets.length === 0) return [];
112
+ const origin = typeof window !== 'undefined' ? window.location.origin : '';
113
+ return editorStylesheets.map((href)=>href.startsWith('/') ? `${origin}${href}` : href);
114
+ }, [
115
+ editorStylesheets
116
+ ]);
117
+ // Track which stylesheet URLs have finished loading (or errored, which we
118
+ // also count as "settled" so a broken URL can't block rendering forever).
119
+ // This accumulates across the component's lifetime rather than resetting
120
+ // per-layout: if a layout switches back to a previously-loaded stylesheet,
121
+ // it's already known-loaded and doesn't need to be waited on again.
122
+ const [loadedHrefs, setLoadedHrefs] = useState(()=>new Set());
123
+ const markLoaded = useCallback((href)=>{
124
+ setLoadedHrefs((prev)=>prev.has(href) ? prev : new Set(prev).add(href));
125
+ }, []);
126
+ // Gate rendering children until every currently-relevant stylesheet has
127
+ // settled, to avoid a flash of unstyled content. Vacuously true when there
128
+ // are no stylesheets to wait for.
129
+ const stylesReady = resolvedStylesheets.every((href)=>loadedHrefs.has(href));
130
+ // Safety net: a <link>'s onLoad can fail to fire for a resource that's
131
+ // already complete in the browser cache before React attaches the
132
+ // handler (a known browser quirk, and the exact reason the pre-refactor
133
+ // implementation carried this same fallback). Without it, a missed event
134
+ // leaves `stylesReady` false forever, permanently blanking the iframe --
135
+ // worse than the FOUC this whole mechanism exists to prevent. `markLoaded`
136
+ // is idempotent, so this is a harmless no-op if the real event already
137
+ // fired first.
138
+ useEffect(()=>{
139
+ const timers = resolvedStylesheets.map((href)=>setTimeout(()=>markLoaded(href), 2000));
140
+ return ()=>timers.forEach(clearTimeout);
141
+ }, [
142
+ resolvedStylesheets,
143
+ markLoaded
144
+ ]);
109
145
  useEffect(()=>{
110
146
  if (!iframeDoc) return;
111
147
  const body = iframeDoc.body;
@@ -138,97 +174,6 @@ const usePuck = createUsePuck();
138
174
  html.setAttribute('data-theme', 'light');
139
175
  body.style.color = '#1f2937'; // gray-800
140
176
  }
141
- // Inject external stylesheets (Tailwind CSS, CSS variables, etc.)
142
- // These provide the styles needed for header/footer components
143
- if (editorStylesheets && editorStylesheets.length > 0) {
144
- let pendingLoads = 0;
145
- let loadedCount = 0;
146
- const checkAllLoaded = ()=>{
147
- loadedCount++;
148
- if (loadedCount >= pendingLoads) {
149
- // All stylesheets loaded - force browser to recalculate styles
150
- // This is necessary because the DOM was already rendered before CSS loaded
151
- setStylesLoaded(true);
152
- // Force a browser repaint after styles load
153
- // Use multiple techniques to ensure CSS is applied to existing elements
154
- requestAnimationFrame(()=>{
155
- if (!html || !body) return;
156
- // Technique 1: Re-apply theme classes (mimics what dark mode toggle does)
157
- const isDark = previewDarkModeOverride ?? layoutConfig.isDark;
158
- if (isDark) {
159
- html.classList.remove('dark');
160
- void html.offsetHeight; // Force reflow
161
- html.classList.add('dark');
162
- } else {
163
- html.classList.remove('light');
164
- void html.offsetHeight; // Force reflow
165
- html.classList.add('light');
166
- }
167
- // Technique 2: Toggle visibility to force repaint
168
- body.style.visibility = 'hidden';
169
- void body.offsetHeight;
170
- body.style.visibility = '';
171
- });
172
- }
173
- };
174
- // Get origin for resolving relative URLs
175
- // Puck's iframe may use srcdoc which doesn't have a proper base URL,
176
- // so relative paths like '/api/puck/styles' won't resolve correctly
177
- const origin = typeof window !== 'undefined' ? window.location.origin : '';
178
- // Track which stylesheets have been counted to avoid double-counting
179
- const loadedIndexes = new Set();
180
- const markLoaded = (index)=>{
181
- if (loadedIndexes.has(index)) return;
182
- loadedIndexes.add(index);
183
- checkAllLoaded();
184
- };
185
- editorStylesheets.forEach((href, index)=>{
186
- const linkId = `puck-editor-stylesheet-${index}`;
187
- const existingLink = iframeDoc.getElementById(linkId);
188
- if (!existingLink) {
189
- pendingLoads++;
190
- const link = iframeDoc.createElement('link');
191
- link.id = linkId;
192
- link.rel = 'stylesheet';
193
- // Resolve relative URLs to absolute URLs for iframe compatibility
194
- link.href = href.startsWith('/') ? `${origin}${href}` : href;
195
- // Track when stylesheet loads
196
- link.onload = ()=>markLoaded(index);
197
- link.onerror = ()=>markLoaded(index); // Count errors too to avoid hanging
198
- iframeDoc.head.appendChild(link);
199
- // Fallback: if onload doesn't fire within 2 seconds, force trigger
200
- // This handles edge cases with cached resources or browser quirks
201
- setTimeout(()=>{
202
- if (!loadedIndexes.has(index)) {
203
- markLoaded(index);
204
- }
205
- }, 2000);
206
- } else if (!stylesLoaded) {
207
- // Link exists - assume it's already loaded
208
- pendingLoads++;
209
- // Immediately mark as loaded since it's already in the DOM
210
- requestAnimationFrame(()=>markLoaded(index));
211
- }
212
- });
213
- // If no new stylesheets to load, mark as loaded
214
- if (pendingLoads === 0 && !stylesLoaded) {
215
- setStylesLoaded(true);
216
- }
217
- } else if (!stylesLoaded) {
218
- // No stylesheets to load
219
- setStylesLoaded(true);
220
- }
221
- // Inject custom CSS (CSS variables, overrides, etc.)
222
- if (editorCss) {
223
- const CUSTOM_CSS_ID = 'puck-editor-custom-css';
224
- let style = iframeDoc.getElementById(CUSTOM_CSS_ID);
225
- if (!style) {
226
- style = iframeDoc.createElement('style');
227
- style.id = CUSTOM_CSS_ID;
228
- iframeDoc.head.appendChild(style);
229
- }
230
- style.textContent = editorCss;
231
- }
232
177
  // Inject richtext-output styles into the iframe for proper heading/list rendering
233
178
  const RICHTEXT_STYLES_ID = 'puck-richtext-output-styles';
234
179
  if (!iframeDoc.getElementById(RICHTEXT_STYLES_ID)) {
@@ -349,9 +294,6 @@ const usePuck = createUsePuck();
349
294
  iframeDoc,
350
295
  layoutConfig,
351
296
  pageBackground,
352
- editorStylesheets,
353
- editorCss,
354
- stylesLoaded,
355
297
  previewDarkModeOverride
356
298
  ]);
357
299
  // Get header/footer components from layout config
@@ -363,6 +305,26 @@ const usePuck = createUsePuck();
363
305
  // 'hide' = always hide
364
306
  const shouldShowHeader = showHeaderOverride === 'hide' ? false : showHeaderOverride === 'show' ? !!LayoutHeader : !!LayoutHeader;
365
307
  const shouldShowFooter = showFooterOverride === 'hide' ? false : showFooterOverride === 'show' ? !!LayoutFooter : !!LayoutFooter;
308
+ // Stylesheet <link>/<style> elements, rendered as ordinary React children.
309
+ // Because these render inside the iframe's own portaled tree (not the host
310
+ // document), this CSS only ever applies inside the iframe -- it can never
311
+ // reach the Payload admin page. Being ordinary (non-resource) elements,
312
+ // React's normal reconciliation adds/removes/updates them correctly on
313
+ // every render, including layout switches that change which stylesheets
314
+ // apply -- no special keying tricks needed.
315
+ const styleElements = /*#__PURE__*/ _jsxs(_Fragment, {
316
+ children: [
317
+ resolvedStylesheets.map((href)=>/*#__PURE__*/ _jsx("link", {
318
+ rel: "stylesheet",
319
+ href: href,
320
+ onLoad: ()=>markLoaded(href),
321
+ onError: ()=>markLoaded(href)
322
+ }, href)),
323
+ editorCss ? /*#__PURE__*/ _jsx("style", {
324
+ children: editorCss
325
+ }) : null
326
+ ]
327
+ });
366
328
  // If we have header or footer to show, wrap in flex container to ensure proper layout
367
329
  if (shouldShowHeader || shouldShowFooter) {
368
330
  // Calculate content padding for sticky headers (only if header is actually shown)
@@ -378,38 +340,41 @@ const usePuck = createUsePuck();
378
340
  const headerFooterStyle = isInteractive ? {} : {
379
341
  pointerEvents: 'none'
380
342
  };
381
- // Use key to force re-render when styles finish loading
382
- // This ensures Tailwind classes are applied after the stylesheet loads
383
- return /*#__PURE__*/ _jsx(PuckPreviewThemeContext.Provider, {
343
+ return /*#__PURE__*/ _jsxs(PuckPreviewThemeContext.Provider, {
384
344
  value: isDark,
385
- children: /*#__PURE__*/ _jsxs("div", {
386
- style: {
387
- display: 'flex',
388
- flexDirection: 'column',
389
- minHeight: '100vh'
390
- },
391
- children: [
392
- shouldShowHeader && LayoutHeader && /*#__PURE__*/ _jsx("div", {
393
- style: headerFooterStyle,
394
- children: /*#__PURE__*/ _jsx(LayoutHeader, {})
395
- }),
396
- /*#__PURE__*/ _jsx("div", {
397
- style: contentStyle,
398
- children: children
399
- }),
400
- shouldShowFooter && LayoutFooter && /*#__PURE__*/ _jsx("div", {
401
- style: headerFooterStyle,
402
- children: /*#__PURE__*/ _jsx(LayoutFooter, {})
403
- })
404
- ]
405
- }, stylesLoaded ? 'styles-loaded' : 'styles-loading')
345
+ children: [
346
+ styleElements,
347
+ stylesReady && /*#__PURE__*/ _jsxs("div", {
348
+ style: {
349
+ display: 'flex',
350
+ flexDirection: 'column',
351
+ minHeight: '100vh'
352
+ },
353
+ children: [
354
+ shouldShowHeader && LayoutHeader && /*#__PURE__*/ _jsx("div", {
355
+ style: headerFooterStyle,
356
+ children: /*#__PURE__*/ _jsx(LayoutHeader, {})
357
+ }),
358
+ /*#__PURE__*/ _jsx("div", {
359
+ style: contentStyle,
360
+ children: children
361
+ }),
362
+ shouldShowFooter && LayoutFooter && /*#__PURE__*/ _jsx("div", {
363
+ style: headerFooterStyle,
364
+ children: /*#__PURE__*/ _jsx(LayoutFooter, {})
365
+ })
366
+ ]
367
+ })
368
+ ]
406
369
  });
407
370
  }
408
- // Use key to force re-render when styles finish loading
409
- return /*#__PURE__*/ _jsx(PuckPreviewThemeContext.Provider, {
371
+ return /*#__PURE__*/ _jsxs(PuckPreviewThemeContext.Provider, {
410
372
  value: isDark,
411
- children: /*#__PURE__*/ _jsx("div", {
412
- children: children
413
- }, stylesLoaded ? 'styles-loaded' : 'styles-loading')
373
+ children: [
374
+ styleElements,
375
+ stylesReady && /*#__PURE__*/ _jsx("div", {
376
+ children: children
377
+ })
378
+ ]
414
379
  });
415
380
  });
@@ -76,7 +76,7 @@ const cssCache = new Map();
76
76
  * @param cssFilePath - Path to CSS file relative to project root
77
77
  * @returns PayloadHandler that serves compiled CSS
78
78
  */ export function createStylesHandler(cssFilePath) {
79
- return async ()=>{
79
+ return async (req)=>{
80
80
  try {
81
81
  const fullPath = join(process.cwd(), cssFilePath);
82
82
  // Check if file exists
@@ -92,13 +92,30 @@ const cssCache = new Map();
92
92
  // Get file modification time for cache invalidation
93
93
  const stats = statSync(fullPath);
94
94
  const mtime = stats.mtimeMs;
95
+ // ETag derived from mtime - changes whenever the source file changes,
96
+ // whether from a dev-mode edit or a version upgrade recompiling output.
97
+ const etag = `"${mtime}"`;
98
+ // If the browser's cached copy is still fresh, tell it so without
99
+ // doing any file read/compilation work.
100
+ const ifNoneMatch = req.headers?.get('if-none-match');
101
+ if (ifNoneMatch === etag) {
102
+ return new Response(null, {
103
+ status: 304,
104
+ headers: {
105
+ ETag: etag,
106
+ 'Cache-Control': 'no-cache'
107
+ }
108
+ });
109
+ }
95
110
  // Check cache
96
111
  const cached = cssCache.get(cssFilePath);
97
112
  if (cached && cached.mtime === mtime) {
98
113
  return new Response(cached.css, {
99
114
  headers: {
100
115
  'Content-Type': 'text/css',
101
- 'Cache-Control': 'public, max-age=31536000, immutable',
116
+ 'Cache-Control': 'no-cache',
117
+ ETag: etag,
118
+ 'Last-Modified': new Date(mtime).toUTCString(),
102
119
  'X-Puck-Cache': 'hit'
103
120
  }
104
121
  });
@@ -114,7 +131,9 @@ const cssCache = new Map();
114
131
  return new Response(compiledCss, {
115
132
  headers: {
116
133
  'Content-Type': 'text/css',
117
- 'Cache-Control': 'public, max-age=31536000, immutable',
134
+ 'Cache-Control': 'no-cache',
135
+ ETag: etag,
136
+ 'Last-Modified': new Date(mtime).toUTCString(),
118
137
  'X-Puck-Cache': 'miss'
119
138
  }
120
139
  });
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.6.28";
1
+ export declare const VERSION = "0.6.30";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by scripts/generate-version.js - do not edit manually
2
- export const VERSION = '0.6.28';
2
+ export const VERSION = '0.6.30';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delmaredigital/payload-puck",
3
- "version": "0.6.28",
3
+ "version": "0.6.30",
4
4
  "description": "Puck visual page builder plugin for Payload CMS",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -173,7 +173,7 @@
173
173
  "devDependencies": {
174
174
  "@payloadcms/next": "^3.84.1",
175
175
  "@payloadcms/ui": "^3.84.1",
176
- "@puckeditor/core": "^0.21.2",
176
+ "@puckeditor/core": "^0.22.0",
177
177
  "@swc/cli": "^0.6.0",
178
178
  "@swc/core": "^1.15.18",
179
179
  "@types/node": "^24.12.0",