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,239 @@
|
|
|
1
|
+
"""Pydantic request/response schemas for the pagebuilder API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
|
|
10
|
+
from pagebuilder.models import PageStatus, RevisionEvent
|
|
11
|
+
|
|
12
|
+
PuckData = dict[str, Any]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PageCreate(BaseModel):
|
|
16
|
+
title: str = Field(min_length=1, max_length=300)
|
|
17
|
+
slug: str = Field(min_length=1, max_length=200, pattern=r"^[a-z0-9][a-z0-9-]*$")
|
|
18
|
+
meta_description: str | None = Field(default=None, max_length=500)
|
|
19
|
+
og_image: str | None = Field(default=None, max_length=500)
|
|
20
|
+
canonical_url: str | None = Field(default=None, max_length=500)
|
|
21
|
+
index_in_search: bool = True
|
|
22
|
+
json_ld: dict[str, Any] | None = None
|
|
23
|
+
draft_data: PuckData = Field(default_factory=dict)
|
|
24
|
+
publish_at: datetime | None = None
|
|
25
|
+
unpublish_at: datetime | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PageUpdate(BaseModel):
|
|
29
|
+
title: str | None = Field(default=None, min_length=1, max_length=300)
|
|
30
|
+
slug: str | None = Field(
|
|
31
|
+
default=None, min_length=1, max_length=200, pattern=r"^[a-z0-9][a-z0-9-]*$"
|
|
32
|
+
)
|
|
33
|
+
meta_description: str | None = Field(default=None, max_length=500)
|
|
34
|
+
og_image: str | None = Field(default=None, max_length=500)
|
|
35
|
+
canonical_url: str | None = Field(default=None, max_length=500)
|
|
36
|
+
index_in_search: bool | None = None
|
|
37
|
+
json_ld: dict[str, Any] | None = None
|
|
38
|
+
draft_data: PuckData | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PageScheduleRequest(BaseModel):
|
|
42
|
+
"""Schedule a future flip into / out of ``published``.
|
|
43
|
+
|
|
44
|
+
Both fields are optional and independent — set ``publish_at`` to flip
|
|
45
|
+
a draft live, set ``unpublish_at`` to take a published page down, or
|
|
46
|
+
set both for a bounded campaign. Send ``null`` to clear an existing
|
|
47
|
+
schedule. Sending ``{}`` is a no-op.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
publish_at: datetime | None = None
|
|
51
|
+
unpublish_at: datetime | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PageRead(BaseModel):
|
|
55
|
+
model_config = ConfigDict(from_attributes=True)
|
|
56
|
+
|
|
57
|
+
id: int
|
|
58
|
+
slug: str
|
|
59
|
+
title: str
|
|
60
|
+
status: PageStatus
|
|
61
|
+
has_published: bool
|
|
62
|
+
meta_description: str | None
|
|
63
|
+
og_image: str | None
|
|
64
|
+
canonical_url: str | None = None
|
|
65
|
+
index_in_search: bool = True
|
|
66
|
+
rejection_note: str | None
|
|
67
|
+
publish_at: datetime | None = None
|
|
68
|
+
unpublish_at: datetime | None = None
|
|
69
|
+
created_at: datetime
|
|
70
|
+
updated_at: datetime | None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PageDetail(PageRead):
|
|
74
|
+
draft_data: PuckData
|
|
75
|
+
published_data: PuckData | None
|
|
76
|
+
json_ld: dict[str, Any] | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class PageListResponse(BaseModel):
|
|
80
|
+
items: list[PageRead]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class PageRevisionRead(BaseModel):
|
|
84
|
+
model_config = ConfigDict(from_attributes=True)
|
|
85
|
+
|
|
86
|
+
id: int
|
|
87
|
+
page_id: int
|
|
88
|
+
title: str
|
|
89
|
+
meta_description: str | None
|
|
90
|
+
og_image: str | None
|
|
91
|
+
event: RevisionEvent
|
|
92
|
+
note: str | None
|
|
93
|
+
created_at: datetime
|
|
94
|
+
created_by: str | None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class PageRevisionDetail(PageRevisionRead):
|
|
98
|
+
data: PuckData
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class PageRevisionListResponse(BaseModel):
|
|
102
|
+
items: list[PageRevisionRead]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class PageRejectRequest(BaseModel):
|
|
106
|
+
"""Approver-supplied reason for sending a submission back to draft.
|
|
107
|
+
|
|
108
|
+
Required and non-empty so the editor history panel always has
|
|
109
|
+
actionable feedback — an empty rejection is a 422 from the API.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
note: str = Field(min_length=1, max_length=2000)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class PageNoteRequest(BaseModel):
|
|
116
|
+
"""Optional author note attached to a publish / approve / unpublish.
|
|
117
|
+
|
|
118
|
+
Body is always required by FastAPI when the endpoint declares this
|
|
119
|
+
schema; clients send ``{}`` (or ``{"note": null}``) to opt out.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
note: str | None = Field(default=None, max_length=2000)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class BlockChange(BaseModel):
|
|
126
|
+
id: str
|
|
127
|
+
type: str | None = None
|
|
128
|
+
fields: list[str] = Field(default_factory=list)
|
|
129
|
+
type_before: str | None = None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class MetadataChange(BaseModel):
|
|
133
|
+
before: Any = None
|
|
134
|
+
after: Any = None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class RevisionDiffResponse(BaseModel):
|
|
138
|
+
"""Block-level diff between two revisions.
|
|
139
|
+
|
|
140
|
+
``metadata`` only contains keys whose value actually changed;
|
|
141
|
+
``blocks`` is always present so the client can render an empty diff
|
|
142
|
+
("no changes") deterministically.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
before_id: int
|
|
146
|
+
after_id: int
|
|
147
|
+
metadata: dict[str, MetadataChange] = Field(default_factory=dict)
|
|
148
|
+
blocks: dict[str, list[BlockChange]] = Field(default_factory=dict)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class LayoutUpdate(BaseModel):
|
|
152
|
+
"""``None`` leaves a slot alone; an empty Puck doc clears it."""
|
|
153
|
+
|
|
154
|
+
header_data: PuckData | None = None
|
|
155
|
+
footer_data: PuckData | None = None
|
|
156
|
+
note: str | None = Field(default=None, max_length=2000)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class LayoutRead(BaseModel):
|
|
160
|
+
model_config = ConfigDict(from_attributes=True)
|
|
161
|
+
|
|
162
|
+
id: int
|
|
163
|
+
created_at: datetime
|
|
164
|
+
updated_at: datetime | None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class LayoutDetail(LayoutRead):
|
|
168
|
+
header_data: PuckData
|
|
169
|
+
footer_data: PuckData
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class LayoutRevisionRead(BaseModel):
|
|
173
|
+
model_config = ConfigDict(from_attributes=True)
|
|
174
|
+
|
|
175
|
+
id: int
|
|
176
|
+
layout_id: int
|
|
177
|
+
note: str | None
|
|
178
|
+
created_at: datetime
|
|
179
|
+
created_by: str | None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class LayoutRevisionDetail(LayoutRevisionRead):
|
|
183
|
+
header_data: PuckData
|
|
184
|
+
footer_data: PuckData
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class LayoutRevisionListResponse(BaseModel):
|
|
188
|
+
items: list[LayoutRevisionRead]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class MediaAssetVariant(BaseModel):
|
|
192
|
+
"""One server-generated derivative of an uploaded image.
|
|
193
|
+
|
|
194
|
+
Width is the canonical key for ``srcset`` so it's required; height is
|
|
195
|
+
optional because some derivatives (e.g. a same-size webp transcode of
|
|
196
|
+
a non-decodable input) may not carry it back.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
filename: str
|
|
200
|
+
url: str
|
|
201
|
+
content_type: str
|
|
202
|
+
width: int
|
|
203
|
+
height: int | None = None
|
|
204
|
+
size_bytes: int
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class MediaAssetRead(BaseModel):
|
|
208
|
+
model_config = ConfigDict(from_attributes=True)
|
|
209
|
+
|
|
210
|
+
id: int
|
|
211
|
+
filename: str
|
|
212
|
+
original_filename: str
|
|
213
|
+
content_type: str
|
|
214
|
+
size_bytes: int
|
|
215
|
+
url: str
|
|
216
|
+
width: int | None = None
|
|
217
|
+
height: int | None = None
|
|
218
|
+
folder: str | None = None
|
|
219
|
+
variants: dict[str, MediaAssetVariant] = Field(default_factory=dict)
|
|
220
|
+
created_at: datetime
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class MediaAssetListResponse(BaseModel):
|
|
224
|
+
"""Cursor-paginated page of media assets.
|
|
225
|
+
|
|
226
|
+
``next_cursor`` is the ``id`` to pass back as the ``cursor`` query
|
|
227
|
+
param to fetch the next page (assets are returned newest-first, so
|
|
228
|
+
the cursor advances toward smaller ids). ``None`` when there are no
|
|
229
|
+
more rows. ``folders`` is the full set of distinct folder names in
|
|
230
|
+
the library — independent of the current filter so the sidebar
|
|
231
|
+
doesn't disappear when the user drills in.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
items: list[MediaAssetRead]
|
|
235
|
+
next_cursor: int | None = None
|
|
236
|
+
folders: list[str] = Field(default_factory=list)
|
|
237
|
+
total: int | None = None
|
|
238
|
+
"""Row count matching the filters. Only set for offset paging — cursor
|
|
239
|
+
paging deliberately avoids the extra COUNT query."""
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { blocksToHtml } from './blocks-to-html';
|
|
4
|
+
import type { PuckBlock } from './types';
|
|
5
|
+
|
|
6
|
+
const b = (type: string, props: Record<string, unknown>): PuckBlock => ({
|
|
7
|
+
type,
|
|
8
|
+
props: { id: `${type}-1`, ...props },
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe('blocksToHtml', () => {
|
|
12
|
+
it('converts a heading, accepting either level spelling', () => {
|
|
13
|
+
expect(blocksToHtml([b('Heading', { text: 'Hi', level: 'h3' })]).html).toBe('<h3>Hi</h3>');
|
|
14
|
+
// Upstream's spelling, in case a document ever arrives from there.
|
|
15
|
+
expect(blocksToHtml([b('Heading', { text: 'Hi', level: '3' })]).html).toBe('<h3>Hi</h3>');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('falls back to h2 for a level it cannot make sense of', () => {
|
|
19
|
+
expect(blocksToHtml([b('Heading', { text: 'Hi', level: 'h9' })]).html).toBe('<h2>Hi</h2>');
|
|
20
|
+
expect(blocksToHtml([b('Heading', { text: 'Hi', level: undefined })]).html).toBe(
|
|
21
|
+
'<h2>Hi</h2>',
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('escapes heading text', () => {
|
|
26
|
+
expect(blocksToHtml([b('Heading', { text: 'a < b & c', level: 'h2' })]).html).toBe(
|
|
27
|
+
'<h2>a < b & c</h2>',
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('renders Text as markdown rather than passing it through', () => {
|
|
32
|
+
// The block stores markdown, so `**x**` has to become an element. Passing
|
|
33
|
+
// it through verbatim — which is right for upstream, where the field holds
|
|
34
|
+
// HTML — would print the asterisks on the page.
|
|
35
|
+
expect(blocksToHtml([b('Text', { text: 'Hello **world**' })]).html).toBe(
|
|
36
|
+
'<p>Hello <strong>world</strong></p>',
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('escapes markup inside Text instead of emitting it', () => {
|
|
41
|
+
// The corollary of the above: because the field is markdown, anything
|
|
42
|
+
// tag-shaped in it is literal content and must not become an element.
|
|
43
|
+
expect(blocksToHtml([b('Text', { text: 'a<b are adjacent' })]).html).toBe(
|
|
44
|
+
'<p>a<b are adjacent</p>',
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('converts a Markdown block', () => {
|
|
49
|
+
expect(blocksToHtml([b('Markdown', { content: '# T' })]).html).toBe('<h1>T</h1>');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('converts images, escaping the alt and carrying known dimensions', () => {
|
|
53
|
+
expect(blocksToHtml([b('Image', { src: '/a.png', alt: 'an "image"' })]).html).toBe(
|
|
54
|
+
'<img src="/a.png" alt="an "image"">',
|
|
55
|
+
);
|
|
56
|
+
expect(
|
|
57
|
+
blocksToHtml([b('Image', { src: '/a.png', alt: '', width: 800, height: 600 })]).html,
|
|
58
|
+
).toBe('<img src="/a.png" alt="" width="800" height="600">');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('treats Html as lossy unless raw markup is explicitly asked for', () => {
|
|
62
|
+
// The Html widget renders its markup inside `<iframe sandbox>` because a
|
|
63
|
+
// ContentEditor's input isn't fully trusted. This function's output has no
|
|
64
|
+
// such boundary, so emitting it has to be a decision the caller makes.
|
|
65
|
+
const { html, lossyTypes } = blocksToHtml([b('Html', { html: '<section>raw</section>' })]);
|
|
66
|
+
expect(html).toBe('');
|
|
67
|
+
expect(lossyTypes).toEqual(['Html']);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('passes raw Html through when opted in', () => {
|
|
71
|
+
expect(
|
|
72
|
+
blocksToHtml([b('Html', { html: '<section>raw</section>' })], { includeRawHtml: true }).html,
|
|
73
|
+
).toBe('<section>raw</section>');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('attributes a quote from whichever of author and source is filled in', () => {
|
|
77
|
+
expect(blocksToHtml([b('Quote', { quote: 'Q', author: 'A', source: 'S' })]).html).toBe(
|
|
78
|
+
'<blockquote><p>Q</p><footer>A, S</footer></blockquote>',
|
|
79
|
+
);
|
|
80
|
+
expect(blocksToHtml([b('Quote', { quote: 'Q', author: '', source: '' })]).html).toBe(
|
|
81
|
+
'<blockquote><p>Q</p></blockquote>',
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('names the types it could not convert, deduplicated and in order', () => {
|
|
86
|
+
const { html, lossyTypes } = blocksToHtml([
|
|
87
|
+
b('Grid', {}),
|
|
88
|
+
b('Heading', { text: 'Keep', level: 'h2' }),
|
|
89
|
+
b('Hero', {}),
|
|
90
|
+
b('Grid', {}),
|
|
91
|
+
]);
|
|
92
|
+
expect(html).toBe('<h2>Keep</h2>');
|
|
93
|
+
expect(lossyTypes).toEqual(['Grid', 'Hero']);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('reports a block with missing props as lossy rather than throwing', () => {
|
|
97
|
+
expect(blocksToHtml([{ type: 'Heading', props: { id: 'x' } }]).lossyTypes).toEqual([
|
|
98
|
+
'Heading',
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('returns empty output for no blocks', () => {
|
|
103
|
+
expect(blocksToHtml([])).toEqual({ html: '', lossyTypes: [] });
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a page's blocks as a plain HTML string — for an export, a feed item,
|
|
3
|
+
* or an email, where the React tree isn't available.
|
|
4
|
+
*
|
|
5
|
+
* Only blocks with a meaningful HTML equivalent convert. A hero or a layout
|
|
6
|
+
* block has no honest flat representation, so rather than emit something
|
|
7
|
+
* lossy and quiet, they're skipped and named in `lossyTypes` — the caller can
|
|
8
|
+
* then say exactly what didn't come across.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { escapeHtml, markdownToHtml } from '../utils/markdown';
|
|
12
|
+
import type { PuckBlock } from './types';
|
|
13
|
+
|
|
14
|
+
export type BlocksToHtmlResult = { html: string; lossyTypes: string[] };
|
|
15
|
+
|
|
16
|
+
export type BlocksToHtmlOptions = {
|
|
17
|
+
/**
|
|
18
|
+
* Emit `Html` blocks' stored markup verbatim. **Off by default.**
|
|
19
|
+
*
|
|
20
|
+
* The Html widget renders that markup inside `<iframe sandbox>` precisely
|
|
21
|
+
* because a ContentEditor's input is not fully trusted. This function's
|
|
22
|
+
* output has no such boundary — a caller inlining it into an email or a feed
|
|
23
|
+
* would run whatever the block contains in their own origin. Off by default,
|
|
24
|
+
* `Html` is reported in `lossyTypes` like any other block with no safe flat
|
|
25
|
+
* representation. Turn it on only where the output is going somewhere the
|
|
26
|
+
* markup is already trusted to run.
|
|
27
|
+
*/
|
|
28
|
+
includeRawHtml?: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** `h3` (what we store) and `3` (what upstream stores) both mean the same. */
|
|
32
|
+
function headingLevel(raw: unknown): string {
|
|
33
|
+
const match = /^h?([1-6])$/.exec(String(raw ?? ''));
|
|
34
|
+
return match ? match[1] : '2';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function blocksToHtml(
|
|
38
|
+
blocks: PuckBlock[],
|
|
39
|
+
options: BlocksToHtmlOptions = {},
|
|
40
|
+
): BlocksToHtmlResult {
|
|
41
|
+
const parts: string[] = [];
|
|
42
|
+
const lossy = new Set<string>();
|
|
43
|
+
|
|
44
|
+
for (const block of blocks) {
|
|
45
|
+
const html = blockToHtml(block, options);
|
|
46
|
+
if (html === null) {
|
|
47
|
+
lossy.add(block.type);
|
|
48
|
+
} else if (html) {
|
|
49
|
+
parts.push(html);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { html: parts.join('\n'), lossyTypes: [...lossy] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function blockToHtml(block: PuckBlock, options: BlocksToHtmlOptions): string | null {
|
|
56
|
+
const props = block.props ?? {};
|
|
57
|
+
switch (block.type) {
|
|
58
|
+
case 'Html':
|
|
59
|
+
if (!options.includeRawHtml) return null;
|
|
60
|
+
return typeof props.html === 'string' ? props.html : null;
|
|
61
|
+
|
|
62
|
+
// Text and Markdown both hold markdown here — Text is the light inline
|
|
63
|
+
// subset, Markdown the full block syntax — so both go through the same
|
|
64
|
+
// converter, which escapes as it goes. Upstream has to sniff Text for a
|
|
65
|
+
// leading tag because its Text field stores HTML; ours never does.
|
|
66
|
+
case 'Text':
|
|
67
|
+
return typeof props.text === 'string' ? markdownToHtml(props.text) : null;
|
|
68
|
+
case 'Markdown':
|
|
69
|
+
return typeof props.content === 'string' ? markdownToHtml(props.content) : null;
|
|
70
|
+
|
|
71
|
+
case 'Heading': {
|
|
72
|
+
if (typeof props.text !== 'string') return null;
|
|
73
|
+
const level = headingLevel(props.level);
|
|
74
|
+
return `<h${level}>${escapeHtml(props.text)}</h${level}>`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
case 'Image': {
|
|
78
|
+
if (typeof props.src !== 'string') return null;
|
|
79
|
+
const alt = typeof props.alt === 'string' ? props.alt : '';
|
|
80
|
+
// `width`/`height` are carried through when known: without them a
|
|
81
|
+
// consumer laying the HTML out has to load the image to size it, which
|
|
82
|
+
// is the reflow the picker stores those numbers to avoid.
|
|
83
|
+
const dims = ['width', 'height']
|
|
84
|
+
.map((key) => (typeof props[key] === 'number' ? ` ${key}="${props[key]}"` : ''))
|
|
85
|
+
.join('');
|
|
86
|
+
return `<img src="${escapeHtml(props.src)}" alt="${escapeHtml(alt)}"${dims}>`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
case 'Quote': {
|
|
90
|
+
if (typeof props.quote !== 'string') return null;
|
|
91
|
+
const cite = ['author', 'source']
|
|
92
|
+
.map((key) => (typeof props[key] === 'string' ? props[key].trim() : ''))
|
|
93
|
+
.filter(Boolean)
|
|
94
|
+
.join(', ');
|
|
95
|
+
const attribution = cite ? `<footer>${escapeHtml(cite)}</footer>` : '';
|
|
96
|
+
return `<blockquote><p>${escapeHtml(props.quote)}</p>${attribution}</blockquote>`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
default:
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
pagebuilder/deps.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""FastAPI dependencies for the pagebuilder module.
|
|
2
|
+
|
|
3
|
+
Centralised here so admin views and the JSON API share one source of
|
|
4
|
+
truth for resolving module-scoped state without underscore-prefixed
|
|
5
|
+
imports across endpoint files.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from fastapi import Depends, Request
|
|
11
|
+
from simple_module_db import get_db
|
|
12
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
13
|
+
|
|
14
|
+
from pagebuilder.media_service import MediaService
|
|
15
|
+
from pagebuilder.settings import PagebuilderSettings
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_settings(request: Request) -> PagebuilderSettings:
|
|
19
|
+
return request.app.state.pagebuilder.settings
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_media_service(
|
|
23
|
+
request: Request,
|
|
24
|
+
db: AsyncSession = Depends(get_db),
|
|
25
|
+
) -> MediaService:
|
|
26
|
+
return MediaService(db, request.app.state.pagebuilder.settings)
|
pagebuilder/diff.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Structured diff between two ``PageRevision`` snapshots.
|
|
2
|
+
|
|
3
|
+
Puck stores the page document as ``{content: [...blocks], root: {...}}``
|
|
4
|
+
where every block carries a ``props.id`` Puck assigned. Diffing by id
|
|
5
|
+
keeps the result stable when authors reorder blocks instead of
|
|
6
|
+
rewriting them — and lets the editor render a per-block added /
|
|
7
|
+
removed / changed summary without trying to read minds about field
|
|
8
|
+
intent.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from pagebuilder.models import PageRevision
|
|
16
|
+
|
|
17
|
+
_TRACKED_METADATA = ("title", "meta_description", "og_image")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _normalise_blocks(data: dict[str, Any] | None) -> list[dict[str, Any]]:
|
|
21
|
+
"""Pull the block list out of a Puck document.
|
|
22
|
+
|
|
23
|
+
Returns an empty list for ``None`` / malformed input so the diff
|
|
24
|
+
treats "no doc" as "no blocks" instead of raising — a missing
|
|
25
|
+
snapshot is a 404 at the route layer.
|
|
26
|
+
"""
|
|
27
|
+
if not isinstance(data, dict):
|
|
28
|
+
return []
|
|
29
|
+
content = data.get("content")
|
|
30
|
+
return content if isinstance(content, list) else []
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _block_key(block: dict[str, Any]) -> str | None:
|
|
34
|
+
"""Block identity used to pair entries across revisions.
|
|
35
|
+
|
|
36
|
+
Puck assigns ``props.id`` at insertion time; falling back to
|
|
37
|
+
positional indexing only when an id is missing avoids treating an
|
|
38
|
+
entire reorder as "remove all + add all".
|
|
39
|
+
"""
|
|
40
|
+
if not isinstance(block, dict):
|
|
41
|
+
return None
|
|
42
|
+
props = block.get("props")
|
|
43
|
+
if isinstance(props, dict):
|
|
44
|
+
block_id = props.get("id")
|
|
45
|
+
if isinstance(block_id, str) and block_id:
|
|
46
|
+
return block_id
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _changed_fields(before: dict[str, Any], after: dict[str, Any]) -> list[str]:
|
|
51
|
+
keys = sorted(set(before) | set(after))
|
|
52
|
+
return [k for k in keys if before.get(k) != after.get(k)]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def revision_diff(before: PageRevision, after: PageRevision) -> dict[str, Any]:
|
|
56
|
+
"""Compute a block-level diff between two revision snapshots.
|
|
57
|
+
|
|
58
|
+
The result has two top-level keys:
|
|
59
|
+
|
|
60
|
+
- ``metadata`` lists per-field ``before``/``after`` tuples for the
|
|
61
|
+
page-level columns that survive in a revision row (title,
|
|
62
|
+
meta_description, og_image).
|
|
63
|
+
- ``blocks`` groups Puck blocks into ``added`` / ``removed`` /
|
|
64
|
+
``changed``, keyed by ``props.id``. Changed entries include
|
|
65
|
+
``fields`` — the list of prop names whose value differs.
|
|
66
|
+
"""
|
|
67
|
+
metadata: dict[str, dict[str, Any]] = {}
|
|
68
|
+
for field in _TRACKED_METADATA:
|
|
69
|
+
before_value = getattr(before, field)
|
|
70
|
+
after_value = getattr(after, field)
|
|
71
|
+
if before_value != after_value:
|
|
72
|
+
metadata[field] = {"before": before_value, "after": after_value}
|
|
73
|
+
|
|
74
|
+
before_blocks = _normalise_blocks(before.data)
|
|
75
|
+
after_blocks = _normalise_blocks(after.data)
|
|
76
|
+
|
|
77
|
+
before_by_id = {key: b for b in before_blocks if (key := _block_key(b)) is not None}
|
|
78
|
+
after_by_id = {key: b for b in after_blocks if (key := _block_key(b)) is not None}
|
|
79
|
+
|
|
80
|
+
added: list[dict[str, Any]] = []
|
|
81
|
+
removed: list[dict[str, Any]] = []
|
|
82
|
+
changed: list[dict[str, Any]] = []
|
|
83
|
+
|
|
84
|
+
for block_id, block in after_by_id.items():
|
|
85
|
+
if block_id in before_by_id:
|
|
86
|
+
continue
|
|
87
|
+
added.append({"id": block_id, "type": block.get("type")})
|
|
88
|
+
|
|
89
|
+
for block_id, block in before_by_id.items():
|
|
90
|
+
if block_id in after_by_id:
|
|
91
|
+
continue
|
|
92
|
+
removed.append({"id": block_id, "type": block.get("type")})
|
|
93
|
+
|
|
94
|
+
for block_id, after_block in after_by_id.items():
|
|
95
|
+
before_block = before_by_id.get(block_id)
|
|
96
|
+
if before_block is None:
|
|
97
|
+
continue
|
|
98
|
+
type_changed = before_block.get("type") != after_block.get("type")
|
|
99
|
+
before_props = before_block.get("props") or {}
|
|
100
|
+
after_props = after_block.get("props") or {}
|
|
101
|
+
if not isinstance(before_props, dict):
|
|
102
|
+
before_props = {}
|
|
103
|
+
if not isinstance(after_props, dict):
|
|
104
|
+
after_props = {}
|
|
105
|
+
# ``id`` is the diff key itself — reporting it as a changed
|
|
106
|
+
# field would be noise on every entry that survives.
|
|
107
|
+
fields = [f for f in _changed_fields(before_props, after_props) if f != "id"]
|
|
108
|
+
if type_changed or fields:
|
|
109
|
+
changed.append(
|
|
110
|
+
{
|
|
111
|
+
"id": block_id,
|
|
112
|
+
"type": after_block.get("type"),
|
|
113
|
+
"fields": fields,
|
|
114
|
+
**(
|
|
115
|
+
{"type_before": before_block.get("type")}
|
|
116
|
+
if type_changed
|
|
117
|
+
else {}
|
|
118
|
+
),
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
"metadata": metadata,
|
|
124
|
+
"blocks": {
|
|
125
|
+
"added": added,
|
|
126
|
+
"removed": removed,
|
|
127
|
+
"changed": changed,
|
|
128
|
+
},
|
|
129
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""JSON admin API for the pagebuilder module, split by resource.
|
|
2
|
+
|
|
3
|
+
Mounted by ``PagebuilderModule.register_routes`` under ``/api/pagebuilder``.
|
|
4
|
+
Sub-routers carry no prefix of their own, so every path is identical to the
|
|
5
|
+
single-file version this replaced.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter
|
|
11
|
+
|
|
12
|
+
from pagebuilder.endpoints.api import layout, pages, revisions, uploads, workflow
|
|
13
|
+
|
|
14
|
+
router = APIRouter()
|
|
15
|
+
router.include_router(pages.router)
|
|
16
|
+
router.include_router(workflow.router)
|
|
17
|
+
router.include_router(revisions.router)
|
|
18
|
+
router.include_router(layout.router)
|
|
19
|
+
router.include_router(uploads.router)
|
|
20
|
+
|
|
21
|
+
__all__ = ["router"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Permission dependencies shared by every admin API sub-router.
|
|
2
|
+
|
|
3
|
+
Per-endpoint :class:`~pagebuilder.permissions.RequiresPermission` deps let
|
|
4
|
+
hosts run the editor → publisher workflow without granting every editor
|
|
5
|
+
publish rights.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from fastapi import Depends
|
|
11
|
+
|
|
12
|
+
from pagebuilder.permissions import (
|
|
13
|
+
PERM_APPROVE,
|
|
14
|
+
PERM_EDIT,
|
|
15
|
+
PERM_PUBLISH,
|
|
16
|
+
RequiresPermission,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
require_edit = Depends(RequiresPermission(PERM_EDIT))
|
|
20
|
+
require_publish = Depends(RequiresPermission(PERM_PUBLISH))
|
|
21
|
+
require_approve = Depends(RequiresPermission(PERM_APPROVE))
|