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.
- pagebuilder/__init__.py +0 -0
- pagebuilder/components/DiffSummary.tsx +107 -0
- pagebuilder/components/ScheduledBadge.tsx +34 -0
- pagebuilder/components/StatusBadge.tsx +21 -0
- pagebuilder/components/blockRegistry.test.ts +56 -0
- pagebuilder/components/blockRegistry.ts +80 -0
- pagebuilder/components/blocks/Button.tsx +142 -0
- pagebuilder/components/blocks/Columns.tsx +64 -0
- pagebuilder/components/blocks/Heading.tsx +92 -0
- pagebuilder/components/blocks/Image.tsx +125 -0
- pagebuilder/components/blocks/MediaPicker.tsx +178 -0
- pagebuilder/components/blocks/Spacer.tsx +31 -0
- pagebuilder/components/blocks/Text.tsx +80 -0
- pagebuilder/components/editor/PageEditorToolbar.tsx +169 -0
- pagebuilder/components/editor/RevisionHistoryPanel.tsx +95 -0
- pagebuilder/components/editor/SchedulePanel.tsx +85 -0
- pagebuilder/components/editor/SeoSettingsPanel.tsx +119 -0
- pagebuilder/components/layoutPuckConfig.tsx +109 -0
- pagebuilder/components/media/MediaFilters.tsx +83 -0
- pagebuilder/components/media/MediaFolderSidebar.tsx +97 -0
- pagebuilder/components/media/MediaGrid.tsx +76 -0
- pagebuilder/components/media/MediaHeader.tsx +37 -0
- pagebuilder/components/media/MediaUploadQueue.tsx +103 -0
- pagebuilder/components/media/types.ts +20 -0
- pagebuilder/components/migrateContent.test.ts +146 -0
- pagebuilder/components/migrateContent.ts +135 -0
- pagebuilder/components/puckConfig.test.tsx +118 -0
- pagebuilder/components/puckConfig.tsx +121 -0
- pagebuilder/components/widgetCatalog.test.tsx +123 -0
- pagebuilder/components/widgetCatalog.ts +208 -0
- pagebuilder/components/widgets/_internal/accent-text.tsx +60 -0
- pagebuilder/components/widgets/_internal/disclosure-chevron.tsx +14 -0
- pagebuilder/components/widgets/_internal/eyebrow-split-section.tsx +71 -0
- pagebuilder/components/widgets/_internal/link-arrow.tsx +23 -0
- pagebuilder/components/widgets/_internal/rich-text.tsx +243 -0
- pagebuilder/components/widgets/_internal/solid-icons.tsx +73 -0
- pagebuilder/components/widgets/_shared/background-media.tsx +74 -0
- pagebuilder/components/widgets/_shared/content-media.tsx +43 -0
- pagebuilder/components/widgets/_shared/cta-button.tsx +69 -0
- pagebuilder/components/widgets/_shared/empty-placeholder.tsx +15 -0
- pagebuilder/components/widgets/_shared/eyebrow-text.tsx +27 -0
- pagebuilder/components/widgets/_shared/heading.tsx +88 -0
- pagebuilder/components/widgets/_shared/index.ts +39 -0
- pagebuilder/components/widgets/_shared/mask.ts +54 -0
- pagebuilder/components/widgets/_shared/section.tsx +60 -0
- pagebuilder/components/widgets/_shared/social-icon.tsx +71 -0
- pagebuilder/components/widgets/_shared/text-link.tsx +47 -0
- pagebuilder/components/widgets/accordion-widget.tsx +64 -0
- pagebuilder/components/widgets/alert-widget.tsx +47 -0
- pagebuilder/components/widgets/app-store-badges-widget.tsx +91 -0
- pagebuilder/components/widgets/article-cards-render.tsx +132 -0
- pagebuilder/components/widgets/article-cards-widget.tsx +72 -0
- pagebuilder/components/widgets/call-to-action-render.tsx +285 -0
- pagebuilder/components/widgets/call-to-action-styles.ts +28 -0
- pagebuilder/components/widgets/call-to-action-widget.tsx +83 -0
- pagebuilder/components/widgets/carousel-widget.tsx +97 -0
- pagebuilder/components/widgets/centered-hero-widget.tsx +54 -0
- pagebuilder/components/widgets/colored-action-list-widget.tsx +117 -0
- pagebuilder/components/widgets/column-widget.tsx +63 -0
- pagebuilder/components/widgets/contact-cards-widget.tsx +172 -0
- pagebuilder/components/widgets/contact-form-render.tsx +293 -0
- pagebuilder/components/widgets/contact-form-widget.tsx +69 -0
- pagebuilder/components/widgets/container-widget.tsx +93 -0
- pagebuilder/components/widgets/definition-list-widget.tsx +144 -0
- pagebuilder/components/widgets/divider-widget.tsx +56 -0
- pagebuilder/components/widgets/eyebrow-section-widget.tsx +56 -0
- pagebuilder/components/widgets/faq-render.tsx +268 -0
- pagebuilder/components/widgets/faq-widget.tsx +64 -0
- pagebuilder/components/widgets/feature-card-widget.tsx +98 -0
- pagebuilder/components/widgets/feature-cards-render.tsx +258 -0
- pagebuilder/components/widgets/feature-cards-styles.ts +107 -0
- pagebuilder/components/widgets/feature-cards-widget.tsx +133 -0
- pagebuilder/components/widgets/feature-grid-widget.tsx +118 -0
- pagebuilder/components/widgets/feature-showcase-widget.tsx +118 -0
- pagebuilder/components/widgets/gallery-widget.tsx +139 -0
- pagebuilder/components/widgets/grid-widget.tsx +59 -0
- pagebuilder/components/widgets/hero-widget.tsx +292 -0
- pagebuilder/components/widgets/html-widget.tsx +42 -0
- pagebuilder/components/widgets/iframe-widget.tsx +44 -0
- pagebuilder/components/widgets/leaderboard-widget.tsx +203 -0
- pagebuilder/components/widgets/list-widget.tsx +56 -0
- pagebuilder/components/widgets/logo-cloud-widget.tsx +127 -0
- pagebuilder/components/widgets/markdown-widget.tsx +148 -0
- pagebuilder/components/widgets/media-object-layout.ts +156 -0
- pagebuilder/components/widgets/media-object-render.tsx +284 -0
- pagebuilder/components/widgets/media-object-text.tsx +202 -0
- pagebuilder/components/widgets/media-object-widget.tsx +176 -0
- pagebuilder/components/widgets/newsletter-widget.tsx +217 -0
- pagebuilder/components/widgets/page-header-widget.tsx +118 -0
- pagebuilder/components/widgets/prose-section-widget.tsx +99 -0
- pagebuilder/components/widgets/quote-widget.tsx +51 -0
- pagebuilder/components/widgets/row-widget.tsx +74 -0
- pagebuilder/components/widgets/section-intro-widget.tsx +143 -0
- pagebuilder/components/widgets/signup-banner-widget.tsx +50 -0
- pagebuilder/components/widgets/simple-action-list-widget.tsx +91 -0
- pagebuilder/components/widgets/site-footer-widget.tsx +97 -0
- pagebuilder/components/widgets/site-header-nav.tsx +153 -0
- pagebuilder/components/widgets/site-header-widget.tsx +182 -0
- pagebuilder/components/widgets/social-banner-widget.tsx +83 -0
- pagebuilder/components/widgets/stats-widget.tsx +200 -0
- pagebuilder/components/widgets/steps-widget.tsx +211 -0
- pagebuilder/components/widgets/story-cards-widget.tsx +182 -0
- pagebuilder/components/widgets/table-widget.tsx +86 -0
- pagebuilder/components/widgets/tabs-widget.tsx +95 -0
- pagebuilder/components/widgets/tags-widget.tsx +85 -0
- pagebuilder/components/widgets/testimonial-widget.tsx +102 -0
- pagebuilder/components/widgets/timeline-layout.ts +73 -0
- pagebuilder/components/widgets/timeline-render.tsx +294 -0
- pagebuilder/components/widgets/timeline-widget.tsx +106 -0
- pagebuilder/components/widgets/under-construction-widget.tsx +40 -0
- pagebuilder/components/widgets/video-widget.tsx +103 -0
- pagebuilder/components/widgets/welcome-widget.tsx +38 -0
- pagebuilder/contracts/__init__.py +57 -0
- pagebuilder/contracts/events.py +25 -0
- pagebuilder/contracts/schemas.py +239 -0
- pagebuilder/conversion/blocks-to-html.test.ts +105 -0
- pagebuilder/conversion/blocks-to-html.ts +102 -0
- pagebuilder/conversion/index.ts +2 -0
- pagebuilder/conversion/types.ts +5 -0
- pagebuilder/deps.py +26 -0
- pagebuilder/diff.py +129 -0
- pagebuilder/endpoints/__init__.py +0 -0
- pagebuilder/endpoints/api/__init__.py +21 -0
- pagebuilder/endpoints/api/_deps.py +21 -0
- pagebuilder/endpoints/api/layout.py +78 -0
- pagebuilder/endpoints/api/pages.py +85 -0
- pagebuilder/endpoints/api/revisions.py +79 -0
- pagebuilder/endpoints/api/uploads.py +92 -0
- pagebuilder/endpoints/api/workflow.py +124 -0
- pagebuilder/endpoints/seo.py +95 -0
- pagebuilder/endpoints/views.py +228 -0
- pagebuilder/fields/checkbox-field.tsx +22 -0
- pagebuilder/fields/file-picker/file-gallery.tsx +152 -0
- pagebuilder/fields/file-picker/image-picker-field.tsx +214 -0
- pagebuilder/fields/file-picker/index.ts +6 -0
- pagebuilder/fields/file-picker/types.ts +15 -0
- pagebuilder/fields/index.ts +9 -0
- pagebuilder/fields/media-adapter.ts +47 -0
- pagebuilder/hooks/useAutosave.ts +134 -0
- pagebuilder/hooks/useEditorForm.ts +193 -0
- pagebuilder/hooks/useMediaUploads.ts +96 -0
- pagebuilder/hooks/usePageRevisions.ts +59 -0
- pagebuilder/hooks/usePageSchedule.ts +72 -0
- pagebuilder/hooks/usePageWorkflow.ts +173 -0
- pagebuilder/layout_service.py +101 -0
- pagebuilder/media_images.py +185 -0
- pagebuilder/media_queries.py +92 -0
- pagebuilder/media_service.py +222 -0
- pagebuilder/models.py +209 -0
- pagebuilder/module.py +279 -0
- pagebuilder/package.json +18 -0
- pagebuilder/pages/LayoutEditor.tsx +183 -0
- pagebuilder/pages/MediaLibrary.tsx +249 -0
- pagebuilder/pages/PageEditor.tsx +181 -0
- pagebuilder/pages/PageList.tsx +129 -0
- pagebuilder/pages/PendingReview.tsx +128 -0
- pagebuilder/pages/PublicPage.tsx +126 -0
- pagebuilder/permissions.py +44 -0
- pagebuilder/py.typed +0 -0
- pagebuilder/security.py +230 -0
- pagebuilder/service/__init__.py +141 -0
- pagebuilder/service/_common.py +24 -0
- pagebuilder/service/_revisions.py +92 -0
- pagebuilder/service/_workflow.py +207 -0
- pagebuilder/services.py +19 -0
- pagebuilder/settings.py +161 -0
- pagebuilder/static/widgets-base.css +57 -0
- pagebuilder/translation/apply.ts +54 -0
- pagebuilder/translation/extract.ts +141 -0
- pagebuilder/translation/index.ts +20 -0
- pagebuilder/translation/translatable-fields.ts +65 -0
- pagebuilder/translation/translation.test.ts +167 -0
- pagebuilder/utils/api.ts +12 -0
- pagebuilder/utils/datetime.ts +25 -0
- pagebuilder/utils/decorative-image.ts +15 -0
- pagebuilder/utils/editorSnapshot.ts +39 -0
- pagebuilder/utils/markdown.ts +160 -0
- pagebuilder/utils/mediaApi.ts +112 -0
- pagebuilder/utils/mediaFormat.ts +19 -0
- pagebuilder/utils/pagesApi.ts +111 -0
- pagebuilder/utils/parse-list.ts +7 -0
- pagebuilder/utils/request.ts +52 -0
- pagebuilder/utils/slugify.ts +9 -0
- pagebuilder/utils/types.ts +171 -0
- pagebuilder/utils/widgetUtils.ts +11 -0
- simple_module_pagebuilder-0.0.1.dist-info/METADATA +152 -0
- simple_module_pagebuilder-0.0.1.dist-info/RECORD +189 -0
- simple_module_pagebuilder-0.0.1.dist-info/WHEEL +4 -0
- 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
|
+
];
|