simple-module-pagebuilder 0.0.1__py3-none-any.whl

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.
Files changed (189) hide show
  1. pagebuilder/__init__.py +0 -0
  2. pagebuilder/components/DiffSummary.tsx +107 -0
  3. pagebuilder/components/ScheduledBadge.tsx +34 -0
  4. pagebuilder/components/StatusBadge.tsx +21 -0
  5. pagebuilder/components/blockRegistry.test.ts +56 -0
  6. pagebuilder/components/blockRegistry.ts +80 -0
  7. pagebuilder/components/blocks/Button.tsx +142 -0
  8. pagebuilder/components/blocks/Columns.tsx +64 -0
  9. pagebuilder/components/blocks/Heading.tsx +92 -0
  10. pagebuilder/components/blocks/Image.tsx +125 -0
  11. pagebuilder/components/blocks/MediaPicker.tsx +178 -0
  12. pagebuilder/components/blocks/Spacer.tsx +31 -0
  13. pagebuilder/components/blocks/Text.tsx +80 -0
  14. pagebuilder/components/editor/PageEditorToolbar.tsx +169 -0
  15. pagebuilder/components/editor/RevisionHistoryPanel.tsx +95 -0
  16. pagebuilder/components/editor/SchedulePanel.tsx +85 -0
  17. pagebuilder/components/editor/SeoSettingsPanel.tsx +119 -0
  18. pagebuilder/components/layoutPuckConfig.tsx +109 -0
  19. pagebuilder/components/media/MediaFilters.tsx +83 -0
  20. pagebuilder/components/media/MediaFolderSidebar.tsx +97 -0
  21. pagebuilder/components/media/MediaGrid.tsx +76 -0
  22. pagebuilder/components/media/MediaHeader.tsx +37 -0
  23. pagebuilder/components/media/MediaUploadQueue.tsx +103 -0
  24. pagebuilder/components/media/types.ts +20 -0
  25. pagebuilder/components/migrateContent.test.ts +146 -0
  26. pagebuilder/components/migrateContent.ts +135 -0
  27. pagebuilder/components/puckConfig.test.tsx +118 -0
  28. pagebuilder/components/puckConfig.tsx +121 -0
  29. pagebuilder/components/widgetCatalog.test.tsx +123 -0
  30. pagebuilder/components/widgetCatalog.ts +208 -0
  31. pagebuilder/components/widgets/_internal/accent-text.tsx +60 -0
  32. pagebuilder/components/widgets/_internal/disclosure-chevron.tsx +14 -0
  33. pagebuilder/components/widgets/_internal/eyebrow-split-section.tsx +71 -0
  34. pagebuilder/components/widgets/_internal/link-arrow.tsx +23 -0
  35. pagebuilder/components/widgets/_internal/rich-text.tsx +243 -0
  36. pagebuilder/components/widgets/_internal/solid-icons.tsx +73 -0
  37. pagebuilder/components/widgets/_shared/background-media.tsx +74 -0
  38. pagebuilder/components/widgets/_shared/content-media.tsx +43 -0
  39. pagebuilder/components/widgets/_shared/cta-button.tsx +69 -0
  40. pagebuilder/components/widgets/_shared/empty-placeholder.tsx +15 -0
  41. pagebuilder/components/widgets/_shared/eyebrow-text.tsx +27 -0
  42. pagebuilder/components/widgets/_shared/heading.tsx +88 -0
  43. pagebuilder/components/widgets/_shared/index.ts +39 -0
  44. pagebuilder/components/widgets/_shared/mask.ts +54 -0
  45. pagebuilder/components/widgets/_shared/section.tsx +60 -0
  46. pagebuilder/components/widgets/_shared/social-icon.tsx +71 -0
  47. pagebuilder/components/widgets/_shared/text-link.tsx +47 -0
  48. pagebuilder/components/widgets/accordion-widget.tsx +64 -0
  49. pagebuilder/components/widgets/alert-widget.tsx +47 -0
  50. pagebuilder/components/widgets/app-store-badges-widget.tsx +91 -0
  51. pagebuilder/components/widgets/article-cards-render.tsx +132 -0
  52. pagebuilder/components/widgets/article-cards-widget.tsx +72 -0
  53. pagebuilder/components/widgets/call-to-action-render.tsx +285 -0
  54. pagebuilder/components/widgets/call-to-action-styles.ts +28 -0
  55. pagebuilder/components/widgets/call-to-action-widget.tsx +83 -0
  56. pagebuilder/components/widgets/carousel-widget.tsx +97 -0
  57. pagebuilder/components/widgets/centered-hero-widget.tsx +54 -0
  58. pagebuilder/components/widgets/colored-action-list-widget.tsx +117 -0
  59. pagebuilder/components/widgets/column-widget.tsx +63 -0
  60. pagebuilder/components/widgets/contact-cards-widget.tsx +172 -0
  61. pagebuilder/components/widgets/contact-form-render.tsx +293 -0
  62. pagebuilder/components/widgets/contact-form-widget.tsx +69 -0
  63. pagebuilder/components/widgets/container-widget.tsx +93 -0
  64. pagebuilder/components/widgets/definition-list-widget.tsx +144 -0
  65. pagebuilder/components/widgets/divider-widget.tsx +56 -0
  66. pagebuilder/components/widgets/eyebrow-section-widget.tsx +56 -0
  67. pagebuilder/components/widgets/faq-render.tsx +268 -0
  68. pagebuilder/components/widgets/faq-widget.tsx +64 -0
  69. pagebuilder/components/widgets/feature-card-widget.tsx +98 -0
  70. pagebuilder/components/widgets/feature-cards-render.tsx +258 -0
  71. pagebuilder/components/widgets/feature-cards-styles.ts +107 -0
  72. pagebuilder/components/widgets/feature-cards-widget.tsx +133 -0
  73. pagebuilder/components/widgets/feature-grid-widget.tsx +118 -0
  74. pagebuilder/components/widgets/feature-showcase-widget.tsx +118 -0
  75. pagebuilder/components/widgets/gallery-widget.tsx +139 -0
  76. pagebuilder/components/widgets/grid-widget.tsx +59 -0
  77. pagebuilder/components/widgets/hero-widget.tsx +292 -0
  78. pagebuilder/components/widgets/html-widget.tsx +42 -0
  79. pagebuilder/components/widgets/iframe-widget.tsx +44 -0
  80. pagebuilder/components/widgets/leaderboard-widget.tsx +203 -0
  81. pagebuilder/components/widgets/list-widget.tsx +56 -0
  82. pagebuilder/components/widgets/logo-cloud-widget.tsx +127 -0
  83. pagebuilder/components/widgets/markdown-widget.tsx +148 -0
  84. pagebuilder/components/widgets/media-object-layout.ts +156 -0
  85. pagebuilder/components/widgets/media-object-render.tsx +284 -0
  86. pagebuilder/components/widgets/media-object-text.tsx +202 -0
  87. pagebuilder/components/widgets/media-object-widget.tsx +176 -0
  88. pagebuilder/components/widgets/newsletter-widget.tsx +217 -0
  89. pagebuilder/components/widgets/page-header-widget.tsx +118 -0
  90. pagebuilder/components/widgets/prose-section-widget.tsx +99 -0
  91. pagebuilder/components/widgets/quote-widget.tsx +51 -0
  92. pagebuilder/components/widgets/row-widget.tsx +74 -0
  93. pagebuilder/components/widgets/section-intro-widget.tsx +143 -0
  94. pagebuilder/components/widgets/signup-banner-widget.tsx +50 -0
  95. pagebuilder/components/widgets/simple-action-list-widget.tsx +91 -0
  96. pagebuilder/components/widgets/site-footer-widget.tsx +97 -0
  97. pagebuilder/components/widgets/site-header-nav.tsx +153 -0
  98. pagebuilder/components/widgets/site-header-widget.tsx +182 -0
  99. pagebuilder/components/widgets/social-banner-widget.tsx +83 -0
  100. pagebuilder/components/widgets/stats-widget.tsx +200 -0
  101. pagebuilder/components/widgets/steps-widget.tsx +211 -0
  102. pagebuilder/components/widgets/story-cards-widget.tsx +182 -0
  103. pagebuilder/components/widgets/table-widget.tsx +86 -0
  104. pagebuilder/components/widgets/tabs-widget.tsx +95 -0
  105. pagebuilder/components/widgets/tags-widget.tsx +85 -0
  106. pagebuilder/components/widgets/testimonial-widget.tsx +102 -0
  107. pagebuilder/components/widgets/timeline-layout.ts +73 -0
  108. pagebuilder/components/widgets/timeline-render.tsx +294 -0
  109. pagebuilder/components/widgets/timeline-widget.tsx +106 -0
  110. pagebuilder/components/widgets/under-construction-widget.tsx +40 -0
  111. pagebuilder/components/widgets/video-widget.tsx +103 -0
  112. pagebuilder/components/widgets/welcome-widget.tsx +38 -0
  113. pagebuilder/contracts/__init__.py +57 -0
  114. pagebuilder/contracts/events.py +25 -0
  115. pagebuilder/contracts/schemas.py +239 -0
  116. pagebuilder/conversion/blocks-to-html.test.ts +105 -0
  117. pagebuilder/conversion/blocks-to-html.ts +102 -0
  118. pagebuilder/conversion/index.ts +2 -0
  119. pagebuilder/conversion/types.ts +5 -0
  120. pagebuilder/deps.py +26 -0
  121. pagebuilder/diff.py +129 -0
  122. pagebuilder/endpoints/__init__.py +0 -0
  123. pagebuilder/endpoints/api/__init__.py +21 -0
  124. pagebuilder/endpoints/api/_deps.py +21 -0
  125. pagebuilder/endpoints/api/layout.py +78 -0
  126. pagebuilder/endpoints/api/pages.py +85 -0
  127. pagebuilder/endpoints/api/revisions.py +79 -0
  128. pagebuilder/endpoints/api/uploads.py +92 -0
  129. pagebuilder/endpoints/api/workflow.py +124 -0
  130. pagebuilder/endpoints/seo.py +95 -0
  131. pagebuilder/endpoints/views.py +228 -0
  132. pagebuilder/fields/checkbox-field.tsx +22 -0
  133. pagebuilder/fields/file-picker/file-gallery.tsx +152 -0
  134. pagebuilder/fields/file-picker/image-picker-field.tsx +214 -0
  135. pagebuilder/fields/file-picker/index.ts +6 -0
  136. pagebuilder/fields/file-picker/types.ts +15 -0
  137. pagebuilder/fields/index.ts +9 -0
  138. pagebuilder/fields/media-adapter.ts +47 -0
  139. pagebuilder/hooks/useAutosave.ts +134 -0
  140. pagebuilder/hooks/useEditorForm.ts +193 -0
  141. pagebuilder/hooks/useMediaUploads.ts +96 -0
  142. pagebuilder/hooks/usePageRevisions.ts +59 -0
  143. pagebuilder/hooks/usePageSchedule.ts +72 -0
  144. pagebuilder/hooks/usePageWorkflow.ts +173 -0
  145. pagebuilder/layout_service.py +101 -0
  146. pagebuilder/media_images.py +185 -0
  147. pagebuilder/media_queries.py +92 -0
  148. pagebuilder/media_service.py +222 -0
  149. pagebuilder/models.py +209 -0
  150. pagebuilder/module.py +279 -0
  151. pagebuilder/package.json +18 -0
  152. pagebuilder/pages/LayoutEditor.tsx +183 -0
  153. pagebuilder/pages/MediaLibrary.tsx +249 -0
  154. pagebuilder/pages/PageEditor.tsx +181 -0
  155. pagebuilder/pages/PageList.tsx +129 -0
  156. pagebuilder/pages/PendingReview.tsx +128 -0
  157. pagebuilder/pages/PublicPage.tsx +126 -0
  158. pagebuilder/permissions.py +44 -0
  159. pagebuilder/py.typed +0 -0
  160. pagebuilder/security.py +230 -0
  161. pagebuilder/service/__init__.py +141 -0
  162. pagebuilder/service/_common.py +24 -0
  163. pagebuilder/service/_revisions.py +92 -0
  164. pagebuilder/service/_workflow.py +207 -0
  165. pagebuilder/services.py +19 -0
  166. pagebuilder/settings.py +161 -0
  167. pagebuilder/static/widgets-base.css +57 -0
  168. pagebuilder/translation/apply.ts +54 -0
  169. pagebuilder/translation/extract.ts +141 -0
  170. pagebuilder/translation/index.ts +20 -0
  171. pagebuilder/translation/translatable-fields.ts +65 -0
  172. pagebuilder/translation/translation.test.ts +167 -0
  173. pagebuilder/utils/api.ts +12 -0
  174. pagebuilder/utils/datetime.ts +25 -0
  175. pagebuilder/utils/decorative-image.ts +15 -0
  176. pagebuilder/utils/editorSnapshot.ts +39 -0
  177. pagebuilder/utils/markdown.ts +160 -0
  178. pagebuilder/utils/mediaApi.ts +112 -0
  179. pagebuilder/utils/mediaFormat.ts +19 -0
  180. pagebuilder/utils/pagesApi.ts +111 -0
  181. pagebuilder/utils/parse-list.ts +7 -0
  182. pagebuilder/utils/request.ts +52 -0
  183. pagebuilder/utils/slugify.ts +9 -0
  184. pagebuilder/utils/types.ts +171 -0
  185. pagebuilder/utils/widgetUtils.ts +11 -0
  186. simple_module_pagebuilder-0.0.1.dist-info/METADATA +152 -0
  187. simple_module_pagebuilder-0.0.1.dist-info/RECORD +189 -0
  188. simple_module_pagebuilder-0.0.1.dist-info/WHEEL +4 -0
  189. simple_module_pagebuilder-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,103 @@
1
+ /** Drag-and-drop target and the per-file upload progress list. */
2
+
3
+ import type { DragEvent } from 'react';
4
+
5
+ import type { UploadItem } from './types';
6
+
7
+ interface DropzoneProps {
8
+ uploadFolder: string;
9
+ dragActive: boolean;
10
+ onDragActiveChange: (active: boolean) => void;
11
+ onDrop: (e: DragEvent<HTMLDivElement>) => void;
12
+ }
13
+
14
+ export function MediaDropzone({
15
+ uploadFolder,
16
+ dragActive,
17
+ onDragActiveChange,
18
+ onDrop,
19
+ }: DropzoneProps) {
20
+ return (
21
+ // A drag-and-drop target has no interactive ARIA role that fits. The
22
+ // keyboard-accessible path to the same action is the header Upload button.
23
+ // biome-ignore lint/a11y/noStaticElementInteractions: drop target; keyboard path is the Upload button
24
+ <div
25
+ onDragOver={(e) => {
26
+ e.preventDefault();
27
+ onDragActiveChange(true);
28
+ }}
29
+ onDragLeave={(e) => {
30
+ e.preventDefault();
31
+ onDragActiveChange(false);
32
+ }}
33
+ onDrop={onDrop}
34
+ className={`mb-4 border-2 border-dashed rounded-lg p-6 text-center text-sm transition-colors ${
35
+ dragActive ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-gray-300 text-gray-500'
36
+ }`}
37
+ data-testid="media-dropzone"
38
+ >
39
+ Drop image files here to upload to <strong>{uploadFolder.trim() || 'Unfiled'}</strong>.
40
+ Multiple files are accepted.
41
+ </div>
42
+ );
43
+ }
44
+
45
+ interface QueueProps {
46
+ uploads: UploadItem[];
47
+ onDismiss: (id: string) => void;
48
+ }
49
+
50
+ export function MediaUploadQueue({ uploads, onDismiss }: QueueProps) {
51
+ if (uploads.length === 0) return null;
52
+ return (
53
+ <div className="mb-4 space-y-1" data-testid="upload-list">
54
+ {uploads.map((u) => (
55
+ <div
56
+ key={u.id}
57
+ className="border rounded p-2 text-xs flex items-center gap-3"
58
+ data-testid="upload-row"
59
+ >
60
+ <div className="flex-1 min-w-0">
61
+ <div className="font-medium truncate" title={u.file.name}>
62
+ {u.file.name}
63
+ </div>
64
+ <div className="h-1.5 bg-gray-100 rounded overflow-hidden mt-1">
65
+ <div
66
+ className={`h-full transition-all ${
67
+ u.status === 'error'
68
+ ? 'bg-red-500'
69
+ : u.status === 'done'
70
+ ? 'bg-green-500'
71
+ : 'bg-blue-500'
72
+ }`}
73
+ style={{
74
+ width: u.total
75
+ ? `${Math.min(100, Math.round((u.loaded / u.total) * 100))}%`
76
+ : '0%',
77
+ }}
78
+ />
79
+ </div>
80
+ {u.error && <div className="text-red-600 mt-1">{u.error}</div>}
81
+ </div>
82
+ <div className="w-16 text-right tabular-nums text-gray-500">
83
+ {u.status === 'done'
84
+ ? 'Done'
85
+ : u.status === 'error'
86
+ ? 'Failed'
87
+ : `${Math.round((u.loaded / Math.max(1, u.total)) * 100)}%`}
88
+ </div>
89
+ {(u.status === 'done' || u.status === 'error') && (
90
+ <button
91
+ type="button"
92
+ onClick={() => onDismiss(u.id)}
93
+ className="text-gray-400 hover:text-gray-700"
94
+ aria-label="Dismiss"
95
+ >
96
+ ×
97
+ </button>
98
+ )}
99
+ </div>
100
+ ))}
101
+ </div>
102
+ );
103
+ }
@@ -0,0 +1,20 @@
1
+ /** Shared view-model types for the media library. */
2
+
3
+ export interface ListFilters {
4
+ search: string;
5
+ contentType: string;
6
+ folder: string | null; // null = Any; "" = Unfiled; "x" = exact folder.
7
+ minKB: string;
8
+ maxKB: string;
9
+ }
10
+
11
+ export type UploadStatus = 'pending' | 'uploading' | 'done' | 'error';
12
+
13
+ export interface UploadItem {
14
+ id: string;
15
+ file: File;
16
+ status: UploadStatus;
17
+ loaded: number;
18
+ total: number;
19
+ error: string | null;
20
+ }
@@ -0,0 +1,146 @@
1
+ import type { Data } from '@puckeditor/core';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { migrateContent } from './migrateContent';
5
+ import { getPuckConfig } from './puckConfig';
6
+
7
+ /** A payload in the shape the current build writes. */
8
+ const modern = {
9
+ content: [{ type: 'Heading', props: { id: 'h1', text: 'Modern', level: 'h2', align: 'left' } }],
10
+ root: { props: { title: 'Page', width: 'contained' } },
11
+ } as unknown as Data;
12
+
13
+ describe('migrateContent', () => {
14
+ afterEach(() => vi.restoreAllMocks());
15
+
16
+ it('returns modern data by identity, without walking it', () => {
17
+ // Identity rather than deep equality: the point of the version check is
18
+ // that the common path allocates nothing. A structural copy would pass
19
+ // toEqual and still have paid for both tree walks.
20
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {});
21
+ expect(migrateContent(modern, getPuckConfig())).toBe(modern);
22
+ expect(log).not.toHaveBeenCalledWith(expect.stringContaining('Migrating DropZones'));
23
+ });
24
+
25
+ it('folds Columns zones into the per-column array slots', () => {
26
+ vi.spyOn(console, 'log').mockImplementation(() => {});
27
+ const legacy = {
28
+ content: [{ type: 'Columns', props: { id: 'C1', gap: 'md', columns: [{}, {}] } }],
29
+ root: { props: {} },
30
+ zones: {
31
+ 'C1:col-0': [{ type: 'Heading', props: { id: 'h1', text: 'Left', level: 'h2' } }],
32
+ 'C1:col-1': [{ type: 'Text', props: { id: 't1', text: 'Right' } }],
33
+ },
34
+ } as unknown as Data;
35
+
36
+ const result = migrateContent(legacy, getPuckConfig());
37
+
38
+ expect((result as unknown as { zones?: unknown }).zones).toBeUndefined();
39
+ const columns = result.content?.[0]?.props?.columns as { content: { props: unknown }[] }[];
40
+ expect(columns[0].content[0].props).toMatchObject({ text: 'Left' });
41
+ expect(columns[1].content[0].props).toMatchObject({ text: 'Right' });
42
+ });
43
+
44
+ it('recovers a zone whose column was deleted before the migration', () => {
45
+ // A page saved after a column was removed still carries that column's
46
+ // zone. Sizing the result to the zone indices as well as the stored array
47
+ // means the orphaned block resurfaces in a real column instead of being
48
+ // dropped on the floor — silently losing author content during a migration
49
+ // is the one failure mode here that can't be undone.
50
+ vi.spyOn(console, 'log').mockImplementation(() => {});
51
+ const legacy = {
52
+ content: [{ type: 'Columns', props: { id: 'C1', gap: 'md', columns: [{ width: 1 }] } }],
53
+ root: { props: {} },
54
+ zones: {
55
+ 'C1:col-1': [{ type: 'Heading', props: { id: 'h9', text: 'Orphan', level: 'h2' } }],
56
+ },
57
+ } as unknown as Data;
58
+
59
+ const result = migrateContent(legacy, getPuckConfig());
60
+
61
+ const columns = result.content?.[0]?.props?.columns as { content: { props: unknown }[] }[];
62
+ expect(columns).toHaveLength(2);
63
+ expect(columns[1].content[0].props).toMatchObject({ text: 'Orphan' });
64
+ });
65
+
66
+ it('rescues a zone whose name it does not recognise', () => {
67
+ // Puck deletes every zone it grouped for the block once the migrate fn
68
+ // returns, read or not — so a name this function ignores doesn't raise the
69
+ // "no slot exists" error, it just stops existing. Only `col-<int>` was
70
+ // ever written here, but the whole point of the function is that losing
71
+ // author content can't be silent.
72
+ vi.spyOn(console, 'log').mockImplementation(() => {});
73
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
74
+ const legacy = {
75
+ content: [{ type: 'Columns', props: { id: 'C1', gap: 'md', columns: [{ width: 1 }] } }],
76
+ root: { props: {} },
77
+ zones: {
78
+ 'C1:col-0': [{ type: 'Heading', props: { id: 'h1', text: 'Kept', level: 'h2' } }],
79
+ 'C1:mystery': [{ type: 'Heading', props: { id: 'h2', text: 'Rescued', level: 'h2' } }],
80
+ },
81
+ } as unknown as Data;
82
+
83
+ const result = migrateContent(legacy, getPuckConfig());
84
+
85
+ const columns = result.content?.[0]?.props?.columns as { content: { props: unknown }[] }[];
86
+ const everything = columns.flatMap((c) => c.content).map((b) => b.props);
87
+ expect(everything).toContainEqual(expect.objectContaining({ text: 'Kept' }));
88
+ expect(everything).toContainEqual(expect.objectContaining({ text: 'Rescued' }));
89
+ expect(warn).toHaveBeenCalled();
90
+ });
91
+
92
+ it('keeps every stored column, however many there are', () => {
93
+ // The zone-index ceiling exists to stop an absurd zone *name* sizing the
94
+ // loop. It must not clamp the stored `columns` array — that is real author
95
+ // data, and trading an allocation worry for silent content loss would be
96
+ // the wrong side of the deal.
97
+ vi.spyOn(console, 'log').mockImplementation(() => {});
98
+ const columns = Array.from({ length: 70 }, (_, i) => ({ width: i }));
99
+ const legacy = {
100
+ content: [{ type: 'Columns', props: { id: 'C1', gap: 'md', columns } }],
101
+ root: { props: {} },
102
+ zones: { 'C1:col-0': [{ type: 'Text', props: { id: 't', text: 'x' } }] },
103
+ } as unknown as Data;
104
+
105
+ const result = migrateContent(legacy, getPuckConfig());
106
+
107
+ const out = result.content?.[0]?.props?.columns as { width: number }[];
108
+ expect(out).toHaveLength(70);
109
+ expect(out[69].width).toBe(69);
110
+ });
111
+
112
+ it('leaves an empty zones map alone', () => {
113
+ // Puck writes `zones: {}` on pages that once held a DropZone and no longer
114
+ // do. Treating that as legacy would migrate every such page on every
115
+ // public request forever.
116
+ const emptyZones = { ...modern, zones: {} } as unknown as Data;
117
+ expect(migrateContent(emptyZones, getPuckConfig())).toBe(emptyZones);
118
+ });
119
+
120
+ it('migrates root props not yet nested under root.props', () => {
121
+ vi.spyOn(console, 'log').mockImplementation(() => {});
122
+ const legacyRoot = {
123
+ content: [],
124
+ root: { title: 'Old shape' },
125
+ } as unknown as Data;
126
+ expect(migrateContent(legacyRoot, getPuckConfig())).not.toBe(legacyRoot);
127
+ });
128
+
129
+ it('returns the original data when migration throws', () => {
130
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
131
+ vi.spyOn(console, 'log').mockImplementation(() => {});
132
+ // A zone whose owning component isn't in the tree: migrate() has no slot
133
+ // field to resolve it against.
134
+ const unmigratable = {
135
+ content: [{ type: 'Heading', props: { id: 'h1', text: 'Survives', level: 'h2' } }],
136
+ root: { props: {} },
137
+ zones: { 'ghost-1:nowhere': [{ type: 'Heading', props: { id: 'h2', text: 'Orphan' } }] },
138
+ } as unknown as Data;
139
+
140
+ const result = migrateContent(unmigratable, getPuckConfig());
141
+
142
+ expect(error).toHaveBeenCalled();
143
+ // The guarantee: one bad zone costs you that zone, not the document.
144
+ expect(result.content?.[0]?.props?.text).toBe('Survives');
145
+ });
146
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Bring stored page data up to the shape the current Puck version expects.
3
+ *
4
+ * Every load path — editor, public page, layout header/footer — runs its data
5
+ * through here before handing it to Puck, so a payload written by an older
6
+ * build can't reach `<Puck>`/`<Render>` in a shape they no longer understand.
7
+ */
8
+
9
+ import { type Config, type Content, type Data, migrate } from '@puckeditor/core';
10
+
11
+ /**
12
+ * Pre-slots payloads keep nested content in a top-level `zones` map keyed
13
+ * `"<componentId>:<zoneName>"`, and the very oldest ones store root props
14
+ * directly on `root` rather than under `root.props`. Neither key is in the
15
+ * modern `Data` type, so they're only visible through this widening.
16
+ */
17
+ type LegacyData = Data & {
18
+ zones?: Record<string, unknown>;
19
+ root?: Record<string, unknown>;
20
+ };
21
+
22
+ function hasLegacyShape(data: Data): boolean {
23
+ const legacy = data as LegacyData;
24
+ if (legacy.zones && Object.keys(legacy.zones).length > 0) return true;
25
+ const root = legacy.root;
26
+ // `!root.props` rather than `!("props" in root)`, matching Puck's own check.
27
+ // A stored `root: { props: null, … }` has the key but no usable value, and
28
+ // treating it as modern would hand `<Render>` a null `root.props` instead of
29
+ // letting Puck normalise it.
30
+ return !!root && !root.props && Object.keys(root).length > 0;
31
+ }
32
+
33
+ /** A `Columns` column, pre- and post-migration. */
34
+ type ColumnItem = { width?: number; content?: Content };
35
+
36
+ /**
37
+ * Fold a pre-slots `Columns` block's zones into its array slots.
38
+ *
39
+ * Puck's own zone→slot conversion only matches a zone against a *top-level*
40
+ * slot field of the same name. `Columns` can't have one: the column count is
41
+ * author-controlled, so its slot lives at `columns[i].content` inside an array
42
+ * field. `migrateDynamicZonesForComponent` is the escape hatch for exactly
43
+ * that shape — Puck hands us the block's props plus its zones (keyed by bare
44
+ * prop name, `col-0` / `col-1` / …) and takes whatever props we return.
45
+ *
46
+ * The zone index is authoritative, not the stored `columns` array: a page
47
+ * saved after a column was removed can still carry a zone for it. Sizing the
48
+ * result to cover both means such orphaned content reappears in a real column
49
+ * instead of being silently dropped on migration.
50
+ *
51
+ * Anything left over is appended to the last column rather than abandoned.
52
+ * Puck deletes *every* zone it grouped for this block once we return, whether
53
+ * or not we read it — so a zone this function doesn't claim is not left behind
54
+ * to raise the "no slot exists" error, it simply stops existing. Only
55
+ * `col-<int>` was ever emitted here, so the sweep should find nothing; it costs
56
+ * one pass and removes the one path where losing author content would be
57
+ * silent, which is the failure this function exists to prevent.
58
+ */
59
+ const COLUMN_ZONE = /^col-(\d+)$/;
60
+ /**
61
+ * Ceiling on a column index taken from a *zone name*, which is the only input
62
+ * here with no natural bound — `col-500000` would otherwise size the loop
63
+ * below. It deliberately does not cap the stored `columns` array: that is real
64
+ * author data, however long, and clamping it would trade an allocation worry
65
+ * for silent content loss. An index past the ceiling isn't dropped either; it
66
+ * falls through to the unclaimed sweep.
67
+ */
68
+ const MAX_ZONE_INDEX = 64;
69
+
70
+ function migrateColumnsZones(
71
+ props: { id: string } & Record<string, unknown>,
72
+ zones: Record<string, Content>,
73
+ ): { id: string } & Record<string, unknown> {
74
+ const stored = Array.isArray(props.columns) ? (props.columns as ColumnItem[]) : [];
75
+ const indexed = new Map<number, Content>();
76
+ const unclaimed: Content = [];
77
+ for (const [name, blocks] of Object.entries(zones)) {
78
+ const match = COLUMN_ZONE.exec(name);
79
+ const index = match ? Number(match[1]) : -1;
80
+ if (index >= 0 && index < MAX_ZONE_INDEX) indexed.set(index, blocks);
81
+ else if (Array.isArray(blocks)) unclaimed.push(...blocks);
82
+ }
83
+
84
+ const count = Math.max(stored.length, ...[...indexed.keys()].map((n) => n + 1), 0);
85
+
86
+ const columns: ColumnItem[] = [];
87
+ for (let i = 0; i < count; i += 1) {
88
+ columns.push({
89
+ width: stored[i]?.width ?? 1,
90
+ content: indexed.get(i) ?? stored[i]?.content ?? [],
91
+ });
92
+ }
93
+
94
+ if (unclaimed.length > 0) {
95
+ if (columns.length === 0) columns.push({ width: 1, content: [] });
96
+ const last = columns[columns.length - 1];
97
+ last.content = [...(last.content ?? []), ...unclaimed];
98
+ console.warn(
99
+ `[pagebuilder] ${unclaimed.length} block(s) from an unrecognised Columns zone were ` +
100
+ `appended to the last column of "${props.id}" rather than dropped.`,
101
+ );
102
+ }
103
+ return { ...props, columns };
104
+ }
105
+
106
+ /**
107
+ * Run Puck's `migrate()`, but only when the payload actually carries a legacy
108
+ * shape.
109
+ *
110
+ * The version check is not just an optimisation. `migrate()` walks the tree
111
+ * twice and logs "Migrating DropZones..." unconditionally, and the public
112
+ * viewer renders on every request — so calling it for the modern payloads that
113
+ * are the overwhelming majority would put two needless walks and a console
114
+ * line on the hot path.
115
+ *
116
+ * `migrate()` still throws on a zone nothing claims — a zone whose owning
117
+ * component is no longer in the tree, say. Returning the original data there
118
+ * keeps the rest of the page rendering: the blast radius stays at the one bad
119
+ * zone rather than the whole document.
120
+ */
121
+ export function migrateContent<T extends Data>(data: T, config: Config): T {
122
+ if (!hasLegacyShape(data)) return data;
123
+ try {
124
+ // Generic because this is shape-preserving: the same document comes back,
125
+ // only reshaped internally. `migrate()` is declared against the loose
126
+ // `Data`, so the narrowing it can't express is asserted once here rather
127
+ // than at each of the four call sites.
128
+ return migrate(data, config, {
129
+ migrateDynamicZonesForComponent: { Columns: migrateColumnsZones },
130
+ }) as T;
131
+ } catch (err) {
132
+ console.error('[pagebuilder] Could not migrate legacy content; rendering as-is:', err);
133
+ return data;
134
+ }
135
+ }
@@ -0,0 +1,118 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest';
2
+
3
+ import { type AnyBlock, registerPuckBlocks, resetBlockRegistry } from './blockRegistry';
4
+ import { getLayoutPuckConfig } from './layoutPuckConfig';
5
+ import { getPuckConfig } from './puckConfig';
6
+
7
+ const stub = { render: () => null } as unknown as AnyBlock;
8
+
9
+ describe('getPuckConfig', () => {
10
+ beforeEach(() => resetBlockRegistry());
11
+
12
+ it('includes pagebuilder’s own blocks', () => {
13
+ expect(Object.keys(getPuckConfig().components)).toContain('Heading');
14
+ });
15
+
16
+ it('includes a registered block', () => {
17
+ registerPuckBlocks({ blocks: { Widget: stub } });
18
+ expect(Object.keys(getPuckConfig().components)).toContain('Widget');
19
+ });
20
+
21
+ it('picks up a registration made after the first read', () => {
22
+ // The whole reason the memo keys on the registry version. Caching on first
23
+ // call would drop any block whose module loaded later.
24
+ expect(Object.keys(getPuckConfig().components)).not.toContain('Late');
25
+ registerPuckBlocks({ blocks: { Late: stub } });
26
+ expect(Object.keys(getPuckConfig().components)).toContain('Late');
27
+ });
28
+
29
+ it('files a registered block under its category', () => {
30
+ registerPuckBlocks({ blocks: { Widget: stub }, category: { key: 'feeds', title: 'Feeds' } });
31
+ expect(getPuckConfig().categories?.feeds).toEqual({
32
+ title: 'Feeds',
33
+ components: ['Widget'],
34
+ });
35
+ });
36
+
37
+ it('leaves pagebuilder’s own categories intact', () => {
38
+ registerPuckBlocks({ blocks: { Widget: stub }, category: { key: 'feeds', title: 'Feeds' } });
39
+ expect(getPuckConfig().categories?.sections?.components).toContain('Hero');
40
+ });
41
+ });
42
+
43
+ describe('getLayoutPuckConfig', () => {
44
+ beforeEach(() => resetBlockRegistry());
45
+
46
+ it('offers the site chrome', () => {
47
+ expect(Object.keys(getLayoutPuckConfig().components)).toEqual(
48
+ expect.arrayContaining(['SiteHeader', 'SiteFooter']),
49
+ );
50
+ });
51
+
52
+ it('drops Columns, which the slots have no room for', () => {
53
+ expect(Object.keys(getLayoutPuckConfig().components)).not.toContain('Columns');
54
+ });
55
+
56
+ it('offers only the curated chrome palette, not the whole page catalogue', () => {
57
+ // This used to be built subtractively — the page palette minus a couple of
58
+ // names — so every block added to the page silently appeared here too.
59
+ // When the catalogue went 20 → 56, the header/footer editor started
60
+ // offering Leaderboard, Timeline and ~45 other page sections under Puck's
61
+ // catch-all "Other" group. Site chrome is a deliberately small palette.
62
+ const names = Object.keys(getLayoutPuckConfig().components);
63
+ expect(names).toEqual(
64
+ expect.arrayContaining(['SiteHeader', 'SiteFooter', 'Heading', 'Text', 'Image']),
65
+ );
66
+ expect(names).not.toContain('Leaderboard');
67
+ expect(names).not.toContain('Timeline');
68
+ expect(names).not.toContain('Newsletter');
69
+ // Every built-in must be filed under a category, since an uncategorised
70
+ // block is what lands in Puck's "Other" group. This holds for built-ins
71
+ // only — the registry is empty here, and a module registering with
72
+ // `layout: true` and no category still joins `components` by design.
73
+ const categorised = Object.values(getLayoutPuckConfig().categories ?? {}).flatMap(
74
+ (c) => c.components ?? [],
75
+ );
76
+ expect(names.filter((n) => !categorised.includes(n))).toEqual([]);
77
+ });
78
+
79
+ it('still renders every block that was offered here before the palette was curated', () => {
80
+ // Puck renders nothing for a type its config doesn't know — no error, no
81
+ // placeholder. A footer saved with a ContactForm back when the layout
82
+ // palette was the whole page palette must not silently lose that section.
83
+ //
84
+ // The full list, not a sample: this is the set the site-layout editor
85
+ // offered before the catalogue grew, so any one of them could be sitting in
86
+ // a stored header or footer, and dropping any one is the same silent loss.
87
+ const names = Object.keys(getLayoutPuckConfig().components);
88
+ expect(names).toEqual(
89
+ expect.arrayContaining([
90
+ 'PageHeader',
91
+ 'Hero',
92
+ 'EyebrowSection',
93
+ 'MediaObject',
94
+ 'CallToAction',
95
+ 'FeatureCards',
96
+ 'Faq',
97
+ 'Stats',
98
+ 'ArticleCards',
99
+ 'ContactCards',
100
+ 'ContactForm',
101
+ 'Tags',
102
+ ]),
103
+ );
104
+ // ...but they are not offered: `_legacy` is `visible: false`.
105
+ expect(getLayoutPuckConfig().categories?._legacy?.visible).toBe(false);
106
+ });
107
+
108
+ it('excludes a page-only registration', () => {
109
+ // A news feed belongs on a page, not in the header or footer.
110
+ registerPuckBlocks({ blocks: { Widget: stub } });
111
+ expect(Object.keys(getLayoutPuckConfig().components)).not.toContain('Widget');
112
+ });
113
+
114
+ it('includes a registration that opts into the layout', () => {
115
+ registerPuckBlocks({ blocks: { Banner: stub }, layout: true });
116
+ expect(Object.keys(getLayoutPuckConfig().components)).toContain('Banner');
117
+ });
118
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Shared Puck configuration consumed by both `<Puck>` (editor) and
3
+ * `<Render>` (public viewer). The same component definitions are used
4
+ * in both places so what authors see in the editor matches what
5
+ * visitors see on the published page.
6
+ */
7
+
8
+ import type { Config } from '@puckeditor/core';
9
+
10
+ import {
11
+ type BlockRegistration,
12
+ declareBuiltInBlocks,
13
+ registeredBlocks,
14
+ registryVersion,
15
+ } from './blockRegistry';
16
+
17
+ import { type CatalogProps, catalogCategories, catalogComponents } from './widgetCatalog';
18
+
19
+ export interface PageRootProps {
20
+ title: string;
21
+ /**
22
+ * `contained` keeps the original max-w-4xl column, which every page built
23
+ * before the section widgets existed relies on. `full` removes it: the
24
+ * ported widgets are full-bleed sections that bring their own container,
25
+ * and nesting them in a 4xl column crushes them.
26
+ */
27
+ width: 'contained' | 'full';
28
+ }
29
+
30
+ /**
31
+ * Puck 0.20 replaced the positional generics (`Config<Components, Root>`) with
32
+ * a single params object. The old form still resolves, but only the new one
33
+ * carries the category and field slots, so this is the shape to extend.
34
+ */
35
+ type PageConfig = Config<{ components: CatalogProps; root: PageRootProps }>;
36
+
37
+ export const basePageConfig: PageConfig = {
38
+ root: {
39
+ fields: {
40
+ title: { type: 'text' },
41
+ width: {
42
+ type: 'radio',
43
+ options: [
44
+ { label: 'Contained', value: 'contained' },
45
+ { label: 'Full width', value: 'full' },
46
+ ],
47
+ },
48
+ },
49
+ // The design pack used to be a root prop here. It is a site-wide branding
50
+ // setting now — one site, one look — so PublicPage applies its root class
51
+ // around the whole document instead, which is also what lets the header
52
+ // and footer adopt it.
53
+ defaultProps: { title: 'Untitled page', width: 'contained' },
54
+ render: ({ children, width }) => (
55
+ <div className={width === 'full' ? undefined : 'max-w-4xl mx-auto p-6'}>{children}</div>
56
+ ),
57
+ },
58
+ categories: catalogCategories,
59
+ components: catalogComponents,
60
+ };
61
+
62
+ // Names pagebuilder itself ships. Declared rather than duplicated in the
63
+ // registry so there is only one list to keep in sync, and so a module that
64
+ // tries to register one of these fails loudly instead of shadowing it.
65
+ declareBuiltInBlocks(Object.keys(basePageConfig.components));
66
+
67
+ /** Merge a registration's blocks and category into an accumulating config. */
68
+ function applyRegistration(
69
+ components: Record<string, unknown>,
70
+ categories: Record<string, { title?: string; components: string[] }>,
71
+ registration: BlockRegistration,
72
+ ): void {
73
+ Object.assign(components, registration.blocks);
74
+ if (!registration.category) return;
75
+ const { key, title } = registration.category;
76
+ categories[key] = {
77
+ title,
78
+ components: [...(categories[key]?.components ?? []), ...Object.keys(registration.blocks)],
79
+ };
80
+ }
81
+
82
+ let cachedConfig: PageConfig | null = null;
83
+ let cachedVersion = -1;
84
+
85
+ /**
86
+ * The page palette: pagebuilder's own blocks plus whatever modules registered.
87
+ *
88
+ * Memoised on the registry version rather than unconditionally — caching on
89
+ * first call would silently drop any block registered after the first render.
90
+ * Call it inside a component, never at module scope.
91
+ */
92
+ export function getPuckConfig(): PageConfig {
93
+ if (cachedConfig && cachedVersion === registryVersion()) return cachedConfig;
94
+ const components: Record<string, unknown> = { ...basePageConfig.components };
95
+ const categories = { ...basePageConfig.categories } as Record<
96
+ string,
97
+ { title?: string; components: string[] }
98
+ >;
99
+ for (const registration of registeredBlocks()) {
100
+ applyRegistration(components, categories, registration);
101
+ }
102
+ cachedConfig = { ...basePageConfig, categories, components } as PageConfig;
103
+ cachedVersion = registryVersion();
104
+ return cachedConfig;
105
+ }
106
+
107
+ export const emptyData = {
108
+ content: [],
109
+ root: { props: { title: 'Untitled page', width: 'contained' } },
110
+ };
111
+
112
+ /**
113
+ * Editor preview viewports surfaced as a switcher in the Puck toolbar.
114
+ * The pixel widths match common mobile/tablet/desktop breakpoints rather
115
+ * than specific devices so the preview reflects what visitors see.
116
+ */
117
+ export const editorViewports = [
118
+ { width: 360, height: 640, label: 'Mobile', icon: 'Smartphone' },
119
+ { width: 768, height: 1024, label: 'Tablet', icon: 'Tablet' },
120
+ { width: 1280, height: 800, label: 'Desktop', icon: 'Monitor' },
121
+ ];