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
pagebuilder/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import type { RevisionDiff } from '../utils/api';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
diff: RevisionDiff;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function renderValue(value: unknown): string {
|
|
9
|
+
if (value === null || value === undefined) return '∅';
|
|
10
|
+
if (typeof value === 'string') return value || '""';
|
|
11
|
+
return JSON.stringify(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Compact, scannable diff: metadata table (only changed fields) plus
|
|
16
|
+
* three lists of blocks (added / removed / changed). Designed to fit
|
|
17
|
+
* inside the editor's history panel without a separate page.
|
|
18
|
+
*/
|
|
19
|
+
export function DiffSummary({ diff }: Props): React.JSX.Element {
|
|
20
|
+
const { metadata, blocks } = diff;
|
|
21
|
+
const metaKeys = Object.keys(metadata);
|
|
22
|
+
const totalChanges =
|
|
23
|
+
metaKeys.length + blocks.added.length + blocks.removed.length + blocks.changed.length;
|
|
24
|
+
|
|
25
|
+
if (totalChanges === 0) {
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
className="mt-3 rounded border border-gray-200 bg-white p-3 text-xs text-gray-500"
|
|
29
|
+
data-testid="diff-summary"
|
|
30
|
+
>
|
|
31
|
+
No changes between these revisions.
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
className="mt-3 rounded border border-gray-200 bg-white p-3 text-xs space-y-2"
|
|
39
|
+
data-testid="diff-summary"
|
|
40
|
+
>
|
|
41
|
+
{metaKeys.length > 0 && (
|
|
42
|
+
<div>
|
|
43
|
+
<div className="font-semibold text-gray-700">Metadata</div>
|
|
44
|
+
<ul className="ml-3 list-disc">
|
|
45
|
+
{metaKeys.map((key) => (
|
|
46
|
+
<li key={key}>
|
|
47
|
+
<span className="font-mono">{key}</span>:{' '}
|
|
48
|
+
<span className="text-red-600 line-through">
|
|
49
|
+
{renderValue(metadata[key].before)}
|
|
50
|
+
</span>{' '}
|
|
51
|
+
→ <span className="text-green-700">{renderValue(metadata[key].after)}</span>
|
|
52
|
+
</li>
|
|
53
|
+
))}
|
|
54
|
+
</ul>
|
|
55
|
+
</div>
|
|
56
|
+
)}
|
|
57
|
+
{blocks.added.length > 0 && (
|
|
58
|
+
<div>
|
|
59
|
+
<div className="font-semibold text-green-700">Added ({blocks.added.length})</div>
|
|
60
|
+
<ul className="ml-3 list-disc">
|
|
61
|
+
{blocks.added.map((b) => (
|
|
62
|
+
<li key={b.id}>
|
|
63
|
+
<span className="font-mono">{b.type ?? '?'}</span>{' '}
|
|
64
|
+
<span className="text-gray-500">({b.id})</span>
|
|
65
|
+
</li>
|
|
66
|
+
))}
|
|
67
|
+
</ul>
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
{blocks.removed.length > 0 && (
|
|
71
|
+
<div>
|
|
72
|
+
<div className="font-semibold text-red-700">Removed ({blocks.removed.length})</div>
|
|
73
|
+
<ul className="ml-3 list-disc">
|
|
74
|
+
{blocks.removed.map((b) => (
|
|
75
|
+
<li key={b.id}>
|
|
76
|
+
<span className="font-mono">{b.type ?? '?'}</span>{' '}
|
|
77
|
+
<span className="text-gray-500">({b.id})</span>
|
|
78
|
+
</li>
|
|
79
|
+
))}
|
|
80
|
+
</ul>
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
{blocks.changed.length > 0 && (
|
|
84
|
+
<div>
|
|
85
|
+
<div className="font-semibold text-amber-700">Changed ({blocks.changed.length})</div>
|
|
86
|
+
<ul className="ml-3 list-disc">
|
|
87
|
+
{blocks.changed.map((b) => (
|
|
88
|
+
<li key={b.id}>
|
|
89
|
+
<span className="font-mono">{b.type ?? '?'}</span>{' '}
|
|
90
|
+
<span className="text-gray-500">({b.id})</span>
|
|
91
|
+
{b.type_before && (
|
|
92
|
+
<span className="text-gray-500">
|
|
93
|
+
{' '}
|
|
94
|
+
— was <span className="font-mono">{b.type_before}</span>
|
|
95
|
+
</span>
|
|
96
|
+
)}
|
|
97
|
+
{b.fields.length > 0 && (
|
|
98
|
+
<span className="text-gray-700">: {b.fields.join(', ')}</span>
|
|
99
|
+
)}
|
|
100
|
+
</li>
|
|
101
|
+
))}
|
|
102
|
+
</ul>
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { PageStatus } from '../utils/api';
|
|
2
|
+
|
|
3
|
+
interface Props {
|
|
4
|
+
status: PageStatus;
|
|
5
|
+
publishAt: string | null;
|
|
6
|
+
unpublishAt: string | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Inline pill rendered next to ``StatusBadge`` when a page has a
|
|
11
|
+
* pending scheduler-driven flip. Drafts surface the upcoming publish
|
|
12
|
+
* time; published pages surface the upcoming unpublish time. Pages
|
|
13
|
+
* with no schedule for their current state render nothing — so callers
|
|
14
|
+
* can drop this in unconditionally.
|
|
15
|
+
*/
|
|
16
|
+
export function ScheduledBadge({ status, publishAt, unpublishAt }: Props) {
|
|
17
|
+
const target =
|
|
18
|
+
status === 'draft' && publishAt
|
|
19
|
+
? { kind: 'publish' as const, at: publishAt }
|
|
20
|
+
: status === 'published' && unpublishAt
|
|
21
|
+
? { kind: 'unpublish' as const, at: unpublishAt }
|
|
22
|
+
: null;
|
|
23
|
+
if (!target) return null;
|
|
24
|
+
const label = target.kind === 'publish' ? 'Scheduled to publish' : 'Scheduled to unpublish';
|
|
25
|
+
return (
|
|
26
|
+
<span
|
|
27
|
+
className="ml-2 inline-block text-xs font-medium px-2 py-1 rounded bg-blue-100 text-blue-800"
|
|
28
|
+
title={new Date(target.at).toLocaleString()}
|
|
29
|
+
data-testid="scheduled-badge"
|
|
30
|
+
>
|
|
31
|
+
{label} {new Date(target.at).toLocaleString()}
|
|
32
|
+
</span>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Badge } from '@simple-module-py/ui/components/ui/badge';
|
|
2
|
+
|
|
3
|
+
import type { PageStatus } from '../utils/api';
|
|
4
|
+
|
|
5
|
+
// The e2e suite asserts on this text verbatim — getByText('published',
|
|
6
|
+
// { exact: true }) — so these labels stay lowercase and unabbreviated.
|
|
7
|
+
const LABELS: Record<PageStatus, string> = {
|
|
8
|
+
published: 'published',
|
|
9
|
+
submitted_for_review: 'pending review',
|
|
10
|
+
draft: 'draft',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const VARIANTS: Record<PageStatus, 'default' | 'secondary' | 'outline'> = {
|
|
14
|
+
published: 'default',
|
|
15
|
+
submitted_for_review: 'secondary',
|
|
16
|
+
draft: 'outline',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function StatusBadge({ status }: { status: PageStatus }) {
|
|
20
|
+
return <Badge variant={VARIANTS[status]}>{LABELS[status]}</Badge>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyBlock,
|
|
5
|
+
declareBuiltInBlocks,
|
|
6
|
+
registeredBlocks,
|
|
7
|
+
registerPuckBlocks,
|
|
8
|
+
registryVersion,
|
|
9
|
+
resetBlockRegistry,
|
|
10
|
+
} from './blockRegistry';
|
|
11
|
+
|
|
12
|
+
const stub = { render: () => null } as unknown as AnyBlock;
|
|
13
|
+
|
|
14
|
+
describe('block registry', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
declareBuiltInBlocks(['Heading', 'Text']);
|
|
17
|
+
resetBlockRegistry();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('collects a registration', () => {
|
|
21
|
+
registerPuckBlocks({ blocks: { Widget: stub } });
|
|
22
|
+
expect(registeredBlocks().flatMap((r) => Object.keys(r.blocks))).toContain('Widget');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('bumps the version on each registration', () => {
|
|
26
|
+
const before = registryVersion();
|
|
27
|
+
registerPuckBlocks({ blocks: { Widget: stub } });
|
|
28
|
+
expect(registryVersion()).toBeGreaterThan(before);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('refuses to shadow a block pagebuilder already ships', () => {
|
|
32
|
+
// Block names are the `type` stored in page content, so a duplicate would
|
|
33
|
+
// make two blocks each render the other's saved data.
|
|
34
|
+
expect(() => registerPuckBlocks({ blocks: { Heading: stub } })).toThrow(/Heading/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('refuses to register the same name twice', () => {
|
|
38
|
+
registerPuckBlocks({ blocks: { Widget: stub } });
|
|
39
|
+
expect(() => registerPuckBlocks({ blocks: { Widget: stub } })).toThrow(/Widget/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('keeps the built-ins reserved across a reset', () => {
|
|
43
|
+
resetBlockRegistry();
|
|
44
|
+
expect(() => registerPuckBlocks({ blocks: { Text: stub } })).toThrow(/Text/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('carries the category through', () => {
|
|
48
|
+
registerPuckBlocks({ blocks: { Widget: stub }, category: { key: 'feeds', title: 'Feeds' } });
|
|
49
|
+
expect(registeredBlocks()[0].category).toEqual({ key: 'feeds', title: 'Feeds' });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('defaults to page-only, not layout', () => {
|
|
53
|
+
registerPuckBlocks({ blocks: { Widget: stub } });
|
|
54
|
+
expect(registeredBlocks()[0].layout).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lets another module contribute Puck blocks without pagebuilder importing it.
|
|
3
|
+
*
|
|
4
|
+
* The framework's registries — menu, permissions, public routes, design packs —
|
|
5
|
+
* are all backend. This is the frontend equivalent, and the reason it exists is
|
|
6
|
+
* dependency direction: the news module knows about pagebuilder, so pagebuilder
|
|
7
|
+
* must not know about news.
|
|
8
|
+
*
|
|
9
|
+
* Registration is a side effect of importing a module's `puck-blocks.ts`. The
|
|
10
|
+
* host imports them all eagerly at app start (see `host/client_app/blocks.ts`),
|
|
11
|
+
* which is what guarantees the registry is populated before the first render.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ComponentConfig } from '@puckeditor/core';
|
|
15
|
+
|
|
16
|
+
// Puck's ComponentConfig is invariant in its props, so a registry that holds
|
|
17
|
+
// configs for unrelated prop shapes cannot be typed precisely. The runtime
|
|
18
|
+
// shape is what matters here; each module keeps its own widget types honest.
|
|
19
|
+
// biome-ignore lint/suspicious/noExplicitAny: a heterogeneous registry has no single prop type
|
|
20
|
+
export type AnyBlock = ComponentConfig<any>;
|
|
21
|
+
|
|
22
|
+
export interface BlockRegistration {
|
|
23
|
+
/** Block name → config. The name is the `type` stored in page content. */
|
|
24
|
+
blocks: Record<string, AnyBlock>;
|
|
25
|
+
/** Palette group. Blocks with no category land in Puck's "Other". */
|
|
26
|
+
category?: { key: string; title: string };
|
|
27
|
+
/** Also offer these in the site-layout palette. Default false. */
|
|
28
|
+
layout?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let registrations: BlockRegistration[] = [];
|
|
32
|
+
let takenNames = new Set<string>();
|
|
33
|
+
let builtIns: readonly string[] = [];
|
|
34
|
+
let version = 0;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Declare the names pagebuilder itself ships, so a module cannot shadow one.
|
|
38
|
+
*
|
|
39
|
+
* Called once by puckConfig at import time rather than hardcoded here, which
|
|
40
|
+
* would be a second list to keep in sync.
|
|
41
|
+
*/
|
|
42
|
+
export function declareBuiltInBlocks(names: readonly string[]): void {
|
|
43
|
+
builtIns = names;
|
|
44
|
+
for (const name of names) takenNames.add(name);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function registerPuckBlocks(registration: BlockRegistration): void {
|
|
48
|
+
for (const name of Object.keys(registration.blocks)) {
|
|
49
|
+
if (takenNames.has(name)) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`Puck block "${name}" is already registered. Block names are the \`type\` ` +
|
|
52
|
+
'stored in page content, so two blocks sharing one would each render ' +
|
|
53
|
+
"the other's saved data.",
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
takenNames.add(name);
|
|
57
|
+
}
|
|
58
|
+
registrations = [...registrations, registration];
|
|
59
|
+
version += 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function registeredBlocks(): readonly BlockRegistration[] {
|
|
63
|
+
return registrations;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Bumped on every registration. The config builders memoise on this rather
|
|
68
|
+
* than caching unconditionally, so a registration that lands after the first
|
|
69
|
+
* read is not silently dropped.
|
|
70
|
+
*/
|
|
71
|
+
export function registryVersion(): number {
|
|
72
|
+
return version;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Test-only: drop every registration, keeping the declared built-ins. */
|
|
76
|
+
export function resetBlockRegistry(): void {
|
|
77
|
+
registrations = [];
|
|
78
|
+
takenNames = new Set(builtIns);
|
|
79
|
+
version += 1;
|
|
80
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { ComponentConfig } from '@puckeditor/core';
|
|
2
|
+
import type { CSSProperties } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../../utils/widgetUtils';
|
|
5
|
+
import { renderRichText } from '../widgets/_internal/rich-text';
|
|
6
|
+
|
|
7
|
+
interface ButtonProps {
|
|
8
|
+
label: string;
|
|
9
|
+
href: string;
|
|
10
|
+
variant: 'primary' | 'secondary' | 'outline' | 'ghost';
|
|
11
|
+
size: 'sm' | 'md' | 'lg';
|
|
12
|
+
target: '_self' | '_blank';
|
|
13
|
+
/**
|
|
14
|
+
* Optional CSS colour override. "primary" uses it as the background (with
|
|
15
|
+
* `--pb-surface-contrast` text); the other variants use it as the text and
|
|
16
|
+
* border colour. Blank keeps the variant's own palette.
|
|
17
|
+
*/
|
|
18
|
+
color: string;
|
|
19
|
+
/**
|
|
20
|
+
* Left placement on the 12-column page grid. "none" keeps the button at the
|
|
21
|
+
* container's left edge; "4" starts it at the 4th column so it lines up with
|
|
22
|
+
* an indented text column (the Markdown widget's `indent`).
|
|
23
|
+
*/
|
|
24
|
+
indent: 'none' | '4';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// `primary`/`ghost` read the branding ramp rather than a fixed blue, so the
|
|
28
|
+
// button follows Settings → Branding like the rest of the widget set. The
|
|
29
|
+
// previous `bg-blue-600` ignored it.
|
|
30
|
+
const variantClass: Record<ButtonProps['variant'], string> = {
|
|
31
|
+
primary: 'bg-primary-700 hover:bg-primary-800 text-white',
|
|
32
|
+
secondary: 'bg-gray-700 hover:bg-gray-800 text-white',
|
|
33
|
+
outline:
|
|
34
|
+
'border border-gray-300 text-gray-900 hover:bg-gray-50 dark:text-white dark:border-gray-700 dark:hover:bg-gray-800',
|
|
35
|
+
ghost:
|
|
36
|
+
'text-[color:var(--pb-button-ghost-fg,var(--color-primary-700))] hover:bg-[var(--pb-button-ghost-bg-hover,var(--color-primary-50))]',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const sizeClass: Record<ButtonProps['size'], string> = {
|
|
40
|
+
sm: 'py-1.5 px-3 text-sm',
|
|
41
|
+
md: 'py-2 px-5 text-base',
|
|
42
|
+
lg: 'py-3 px-7 text-lg',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const ButtonBlock: ComponentConfig<ButtonProps> = {
|
|
46
|
+
label: 'Button',
|
|
47
|
+
fields: {
|
|
48
|
+
label: { type: 'text', label: 'Label' },
|
|
49
|
+
href: { type: 'text', label: 'Link' },
|
|
50
|
+
variant: {
|
|
51
|
+
type: 'select',
|
|
52
|
+
label: 'Variant',
|
|
53
|
+
options: [
|
|
54
|
+
{ label: 'Primary', value: 'primary' },
|
|
55
|
+
{ label: 'Secondary', value: 'secondary' },
|
|
56
|
+
{ label: 'Outline', value: 'outline' },
|
|
57
|
+
{ label: 'Ghost', value: 'ghost' },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
size: {
|
|
61
|
+
type: 'select',
|
|
62
|
+
label: 'Size',
|
|
63
|
+
options: [
|
|
64
|
+
{ label: 'Small', value: 'sm' },
|
|
65
|
+
{ label: 'Medium', value: 'md' },
|
|
66
|
+
{ label: 'Large', value: 'lg' },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
target: {
|
|
70
|
+
type: 'radio',
|
|
71
|
+
label: 'Link target',
|
|
72
|
+
options: [
|
|
73
|
+
{ label: 'Same tab', value: '_self' },
|
|
74
|
+
{ label: 'New tab', value: '_blank' },
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
color: { type: 'text', label: 'Colour (CSS, optional)' },
|
|
78
|
+
indent: {
|
|
79
|
+
type: 'select',
|
|
80
|
+
label: 'Grid indent',
|
|
81
|
+
options: [
|
|
82
|
+
{ label: 'None', value: 'none' },
|
|
83
|
+
{ label: '4th column', value: '4' },
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
defaultProps: {
|
|
88
|
+
label: 'Click me',
|
|
89
|
+
href: '#',
|
|
90
|
+
variant: 'primary',
|
|
91
|
+
size: 'md',
|
|
92
|
+
target: '_self',
|
|
93
|
+
color: '',
|
|
94
|
+
indent: 'none',
|
|
95
|
+
},
|
|
96
|
+
// `size`, `color` and `indent` are defaulted again in the signature, not
|
|
97
|
+
// only in `defaultProps`. Those apply to a block the author drops *now* —
|
|
98
|
+
// every Button already stored on a page predates these fields and arrives
|
|
99
|
+
// with them undefined, which would index the class maps to `undefined` and
|
|
100
|
+
// put that literal into the class list.
|
|
101
|
+
render: ({ label, href, variant, size = 'md', target, color = '', indent = 'none' }) => {
|
|
102
|
+
// Pill radius is theme-driven; the fallback is the previous `rounded`.
|
|
103
|
+
const style: CSSProperties = { borderRadius: 'var(--pb-button-radius, 0.375rem)' };
|
|
104
|
+
if (color) {
|
|
105
|
+
if (variant === 'primary') {
|
|
106
|
+
style.backgroundColor = color;
|
|
107
|
+
style.color = 'var(--pb-surface-contrast, #ffffff)';
|
|
108
|
+
} else {
|
|
109
|
+
style.color = color;
|
|
110
|
+
style.borderColor = color;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return (
|
|
114
|
+
<div className={cn(indent === '4' && 'lg:grid lg:grid-cols-12 lg:gap-6')}>
|
|
115
|
+
<a
|
|
116
|
+
href={href}
|
|
117
|
+
target={target}
|
|
118
|
+
rel={target === '_blank' ? 'noopener noreferrer' : undefined}
|
|
119
|
+
className={cn(
|
|
120
|
+
'inline-flex items-center justify-center font-medium transition-colors',
|
|
121
|
+
indent === '4' && 'lg:col-start-4 lg:col-span-9 lg:justify-self-start',
|
|
122
|
+
variantClass[variant],
|
|
123
|
+
sizeClass[size],
|
|
124
|
+
)}
|
|
125
|
+
style={style}
|
|
126
|
+
>
|
|
127
|
+
{/* Links off: this is already inside an `<a>`, and nesting anchors
|
|
128
|
+
is invalid HTML. */}
|
|
129
|
+
{renderRichText(label || '', { allowLinks: false })}
|
|
130
|
+
{/* Trailing arrow, off by default (`--pb-button-arrow-display`) and
|
|
131
|
+
suppressed when the label carries its own — a "← Back" link
|
|
132
|
+
should not come out as "← Back →". */}
|
|
133
|
+
{!/[←→]/.test(label ?? '') && (
|
|
134
|
+
<span aria-hidden="true" className="ml-2 [display:var(--pb-button-arrow-display,none)]">
|
|
135
|
+
→
|
|
136
|
+
</span>
|
|
137
|
+
)}
|
|
138
|
+
</a>
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
},
|
|
142
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ComponentConfig, Slot } from '@puckeditor/core';
|
|
2
|
+
|
|
3
|
+
export interface ColumnsProps {
|
|
4
|
+
/**
|
|
5
|
+
* `width` is a flex ratio, not a fraction — two columns at 1 and 2 split
|
|
6
|
+
* 1/3 : 2/3. `content` is the column's own slot; it lives inside the array
|
|
7
|
+
* so the number of columns stays author-controlled. A top-level slot field
|
|
8
|
+
* per column would cap the block at however many we declared.
|
|
9
|
+
*/
|
|
10
|
+
columns: { width: number; content: Slot }[];
|
|
11
|
+
gap: 'sm' | 'md' | 'lg';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const gapClass: Record<ColumnsProps['gap'], string> = {
|
|
15
|
+
sm: 'gap-2',
|
|
16
|
+
md: 'gap-4',
|
|
17
|
+
lg: 'gap-8',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const ColumnsBlock: ComponentConfig<ColumnsProps> = {
|
|
21
|
+
label: 'Columns',
|
|
22
|
+
fields: {
|
|
23
|
+
columns: {
|
|
24
|
+
type: 'array',
|
|
25
|
+
arrayFields: {
|
|
26
|
+
width: { type: 'number' },
|
|
27
|
+
content: { type: 'slot' },
|
|
28
|
+
},
|
|
29
|
+
defaultItemProps: { width: 1, content: [] },
|
|
30
|
+
},
|
|
31
|
+
gap: {
|
|
32
|
+
type: 'select',
|
|
33
|
+
options: [
|
|
34
|
+
{ label: 'Small', value: 'sm' },
|
|
35
|
+
{ label: 'Medium', value: 'md' },
|
|
36
|
+
{ label: 'Large', value: 'lg' },
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
defaultProps: {
|
|
41
|
+
columns: [
|
|
42
|
+
{ width: 1, content: [] },
|
|
43
|
+
{ width: 1, content: [] },
|
|
44
|
+
],
|
|
45
|
+
gap: 'md',
|
|
46
|
+
},
|
|
47
|
+
render: ({ columns, gap }) => (
|
|
48
|
+
<div className={`flex flex-wrap ${gapClass[gap]} my-4`}>
|
|
49
|
+
{columns.map((col, idx) => {
|
|
50
|
+
// Puck hands each slot back as a component, so it has to be bound to a
|
|
51
|
+
// capitalised name before it can be used as an element.
|
|
52
|
+
const Content = col.content;
|
|
53
|
+
return (
|
|
54
|
+
// The index IS the column's identity — it's what the pre-slots zone
|
|
55
|
+
// name was derived from, and what the migration maps back onto.
|
|
56
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: index is the column's identity
|
|
57
|
+
<div key={idx} style={{ flex: col.width }} className="min-w-0">
|
|
58
|
+
<Content />
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
})}
|
|
62
|
+
</div>
|
|
63
|
+
),
|
|
64
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { ComponentConfig } from '@puckeditor/core';
|
|
2
|
+
import type { CSSProperties } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../../utils/widgetUtils';
|
|
5
|
+
import { renderRichText } from '../widgets/_internal/rich-text';
|
|
6
|
+
|
|
7
|
+
interface HeadingProps {
|
|
8
|
+
text: string;
|
|
9
|
+
/**
|
|
10
|
+
* Stored as `h1`…`h6` rather than upstream's `1`…`6`. The enum is an
|
|
11
|
+
* internal representation, not something an author ever sees, and every
|
|
12
|
+
* Heading already on a page uses this spelling — adopting the other one
|
|
13
|
+
* would mean migrating all of them to change nothing visible.
|
|
14
|
+
*/
|
|
15
|
+
level: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
|
16
|
+
align: 'left' | 'center' | 'right';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// A lookup map rather than `text-${align}`: Tailwind's scanner only emits
|
|
20
|
+
// classes it can see spelled out, and an interpolated name is invisible to it.
|
|
21
|
+
// The old form worked by accident — these three names happen to appear
|
|
22
|
+
// literally in other files, and would have silently stopped working the day
|
|
23
|
+
// they didn't.
|
|
24
|
+
const alignClass: Record<HeadingProps['align'], string> = {
|
|
25
|
+
left: 'text-left',
|
|
26
|
+
center: 'text-center',
|
|
27
|
+
right: 'text-right',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Sizes only. Weight, tracking and face for display headings come from the
|
|
31
|
+
// design-pack tokens, so one block renders in each pack's style — the previous
|
|
32
|
+
// hardcoded `font-bold` overrode the pack.
|
|
33
|
+
const sizeClass: Record<HeadingProps['level'], string> = {
|
|
34
|
+
h1: 'text-4xl sm:text-5xl lg:text-6xl leading-[1.1]',
|
|
35
|
+
h2: 'text-3xl sm:text-4xl lg:text-5xl leading-[1.1]',
|
|
36
|
+
h3: 'text-2xl sm:text-3xl',
|
|
37
|
+
h4: 'text-xl sm:text-2xl font-medium',
|
|
38
|
+
h5: 'text-lg sm:text-xl font-medium',
|
|
39
|
+
h6: 'text-base sm:text-lg font-medium',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** h1–h3 are display headings and take the pack's display face and weight. */
|
|
43
|
+
const DISPLAY_LEVELS = new Set<HeadingProps['level']>(['h1', 'h2', 'h3']);
|
|
44
|
+
|
|
45
|
+
function headingStyle(level: HeadingProps['level']): CSSProperties {
|
|
46
|
+
const style: CSSProperties = { color: 'var(--pb-heading-color)' };
|
|
47
|
+
if (DISPLAY_LEVELS.has(level)) {
|
|
48
|
+
style.fontWeight = 'var(--pb-display-weight)' as CSSProperties['fontWeight'];
|
|
49
|
+
style.letterSpacing = 'var(--pb-display-tracking)';
|
|
50
|
+
style.fontFamily = 'var(--pb-display-font)';
|
|
51
|
+
}
|
|
52
|
+
return style;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const HeadingBlock: ComponentConfig<HeadingProps> = {
|
|
56
|
+
label: 'Heading',
|
|
57
|
+
fields: {
|
|
58
|
+
text: { type: 'text', label: 'Text' },
|
|
59
|
+
level: {
|
|
60
|
+
type: 'select',
|
|
61
|
+
label: 'Level',
|
|
62
|
+
options: [
|
|
63
|
+
{ label: 'H1', value: 'h1' },
|
|
64
|
+
{ label: 'H2', value: 'h2' },
|
|
65
|
+
{ label: 'H3', value: 'h3' },
|
|
66
|
+
{ label: 'H4', value: 'h4' },
|
|
67
|
+
{ label: 'H5', value: 'h5' },
|
|
68
|
+
{ label: 'H6', value: 'h6' },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
align: {
|
|
72
|
+
type: 'radio',
|
|
73
|
+
label: 'Alignment',
|
|
74
|
+
options: [
|
|
75
|
+
{ label: 'Left', value: 'left' },
|
|
76
|
+
{ label: 'Center', value: 'center' },
|
|
77
|
+
{ label: 'Right', value: 'right' },
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
defaultProps: { text: 'Heading', level: 'h2', align: 'left' },
|
|
82
|
+
render: ({ text, level = 'h2', align = 'left' }) => {
|
|
83
|
+
const Tag = level;
|
|
84
|
+
return (
|
|
85
|
+
<div className="container mx-auto px-4 py-2">
|
|
86
|
+
<Tag className={cn(sizeClass[level], alignClass[align])} style={headingStyle(level)}>
|
|
87
|
+
{renderRichText(text)}
|
|
88
|
+
</Tag>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
},
|
|
92
|
+
};
|