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
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
+ };