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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. pagebuilder/__init__.py +0 -0
  2. pagebuilder/components/DiffSummary.tsx +107 -0
  3. pagebuilder/components/ScheduledBadge.tsx +34 -0
  4. pagebuilder/components/StatusBadge.tsx +21 -0
  5. pagebuilder/components/blockRegistry.test.ts +56 -0
  6. pagebuilder/components/blockRegistry.ts +80 -0
  7. pagebuilder/components/blocks/Button.tsx +142 -0
  8. pagebuilder/components/blocks/Columns.tsx +64 -0
  9. pagebuilder/components/blocks/Heading.tsx +92 -0
  10. pagebuilder/components/blocks/Image.tsx +125 -0
  11. pagebuilder/components/blocks/MediaPicker.tsx +178 -0
  12. pagebuilder/components/blocks/Spacer.tsx +31 -0
  13. pagebuilder/components/blocks/Text.tsx +80 -0
  14. pagebuilder/components/editor/PageEditorToolbar.tsx +169 -0
  15. pagebuilder/components/editor/RevisionHistoryPanel.tsx +95 -0
  16. pagebuilder/components/editor/SchedulePanel.tsx +85 -0
  17. pagebuilder/components/editor/SeoSettingsPanel.tsx +119 -0
  18. pagebuilder/components/layoutPuckConfig.tsx +109 -0
  19. pagebuilder/components/media/MediaFilters.tsx +83 -0
  20. pagebuilder/components/media/MediaFolderSidebar.tsx +97 -0
  21. pagebuilder/components/media/MediaGrid.tsx +76 -0
  22. pagebuilder/components/media/MediaHeader.tsx +37 -0
  23. pagebuilder/components/media/MediaUploadQueue.tsx +103 -0
  24. pagebuilder/components/media/types.ts +20 -0
  25. pagebuilder/components/migrateContent.test.ts +146 -0
  26. pagebuilder/components/migrateContent.ts +135 -0
  27. pagebuilder/components/puckConfig.test.tsx +118 -0
  28. pagebuilder/components/puckConfig.tsx +121 -0
  29. pagebuilder/components/widgetCatalog.test.tsx +123 -0
  30. pagebuilder/components/widgetCatalog.ts +208 -0
  31. pagebuilder/components/widgets/_internal/accent-text.tsx +60 -0
  32. pagebuilder/components/widgets/_internal/disclosure-chevron.tsx +14 -0
  33. pagebuilder/components/widgets/_internal/eyebrow-split-section.tsx +71 -0
  34. pagebuilder/components/widgets/_internal/link-arrow.tsx +23 -0
  35. pagebuilder/components/widgets/_internal/rich-text.tsx +243 -0
  36. pagebuilder/components/widgets/_internal/solid-icons.tsx +73 -0
  37. pagebuilder/components/widgets/_shared/background-media.tsx +74 -0
  38. pagebuilder/components/widgets/_shared/content-media.tsx +43 -0
  39. pagebuilder/components/widgets/_shared/cta-button.tsx +69 -0
  40. pagebuilder/components/widgets/_shared/empty-placeholder.tsx +15 -0
  41. pagebuilder/components/widgets/_shared/eyebrow-text.tsx +27 -0
  42. pagebuilder/components/widgets/_shared/heading.tsx +88 -0
  43. pagebuilder/components/widgets/_shared/index.ts +39 -0
  44. pagebuilder/components/widgets/_shared/mask.ts +54 -0
  45. pagebuilder/components/widgets/_shared/section.tsx +60 -0
  46. pagebuilder/components/widgets/_shared/social-icon.tsx +71 -0
  47. pagebuilder/components/widgets/_shared/text-link.tsx +47 -0
  48. pagebuilder/components/widgets/accordion-widget.tsx +64 -0
  49. pagebuilder/components/widgets/alert-widget.tsx +47 -0
  50. pagebuilder/components/widgets/app-store-badges-widget.tsx +91 -0
  51. pagebuilder/components/widgets/article-cards-render.tsx +132 -0
  52. pagebuilder/components/widgets/article-cards-widget.tsx +72 -0
  53. pagebuilder/components/widgets/call-to-action-render.tsx +285 -0
  54. pagebuilder/components/widgets/call-to-action-styles.ts +28 -0
  55. pagebuilder/components/widgets/call-to-action-widget.tsx +83 -0
  56. pagebuilder/components/widgets/carousel-widget.tsx +97 -0
  57. pagebuilder/components/widgets/centered-hero-widget.tsx +54 -0
  58. pagebuilder/components/widgets/colored-action-list-widget.tsx +117 -0
  59. pagebuilder/components/widgets/column-widget.tsx +63 -0
  60. pagebuilder/components/widgets/contact-cards-widget.tsx +172 -0
  61. pagebuilder/components/widgets/contact-form-render.tsx +293 -0
  62. pagebuilder/components/widgets/contact-form-widget.tsx +69 -0
  63. pagebuilder/components/widgets/container-widget.tsx +93 -0
  64. pagebuilder/components/widgets/definition-list-widget.tsx +144 -0
  65. pagebuilder/components/widgets/divider-widget.tsx +56 -0
  66. pagebuilder/components/widgets/eyebrow-section-widget.tsx +56 -0
  67. pagebuilder/components/widgets/faq-render.tsx +268 -0
  68. pagebuilder/components/widgets/faq-widget.tsx +64 -0
  69. pagebuilder/components/widgets/feature-card-widget.tsx +98 -0
  70. pagebuilder/components/widgets/feature-cards-render.tsx +258 -0
  71. pagebuilder/components/widgets/feature-cards-styles.ts +107 -0
  72. pagebuilder/components/widgets/feature-cards-widget.tsx +133 -0
  73. pagebuilder/components/widgets/feature-grid-widget.tsx +118 -0
  74. pagebuilder/components/widgets/feature-showcase-widget.tsx +118 -0
  75. pagebuilder/components/widgets/gallery-widget.tsx +139 -0
  76. pagebuilder/components/widgets/grid-widget.tsx +59 -0
  77. pagebuilder/components/widgets/hero-widget.tsx +292 -0
  78. pagebuilder/components/widgets/html-widget.tsx +42 -0
  79. pagebuilder/components/widgets/iframe-widget.tsx +44 -0
  80. pagebuilder/components/widgets/leaderboard-widget.tsx +203 -0
  81. pagebuilder/components/widgets/list-widget.tsx +56 -0
  82. pagebuilder/components/widgets/logo-cloud-widget.tsx +127 -0
  83. pagebuilder/components/widgets/markdown-widget.tsx +148 -0
  84. pagebuilder/components/widgets/media-object-layout.ts +156 -0
  85. pagebuilder/components/widgets/media-object-render.tsx +284 -0
  86. pagebuilder/components/widgets/media-object-text.tsx +202 -0
  87. pagebuilder/components/widgets/media-object-widget.tsx +176 -0
  88. pagebuilder/components/widgets/newsletter-widget.tsx +217 -0
  89. pagebuilder/components/widgets/page-header-widget.tsx +118 -0
  90. pagebuilder/components/widgets/prose-section-widget.tsx +99 -0
  91. pagebuilder/components/widgets/quote-widget.tsx +51 -0
  92. pagebuilder/components/widgets/row-widget.tsx +74 -0
  93. pagebuilder/components/widgets/section-intro-widget.tsx +143 -0
  94. pagebuilder/components/widgets/signup-banner-widget.tsx +50 -0
  95. pagebuilder/components/widgets/simple-action-list-widget.tsx +91 -0
  96. pagebuilder/components/widgets/site-footer-widget.tsx +97 -0
  97. pagebuilder/components/widgets/site-header-nav.tsx +153 -0
  98. pagebuilder/components/widgets/site-header-widget.tsx +182 -0
  99. pagebuilder/components/widgets/social-banner-widget.tsx +83 -0
  100. pagebuilder/components/widgets/stats-widget.tsx +200 -0
  101. pagebuilder/components/widgets/steps-widget.tsx +211 -0
  102. pagebuilder/components/widgets/story-cards-widget.tsx +182 -0
  103. pagebuilder/components/widgets/table-widget.tsx +86 -0
  104. pagebuilder/components/widgets/tabs-widget.tsx +95 -0
  105. pagebuilder/components/widgets/tags-widget.tsx +85 -0
  106. pagebuilder/components/widgets/testimonial-widget.tsx +102 -0
  107. pagebuilder/components/widgets/timeline-layout.ts +73 -0
  108. pagebuilder/components/widgets/timeline-render.tsx +294 -0
  109. pagebuilder/components/widgets/timeline-widget.tsx +106 -0
  110. pagebuilder/components/widgets/under-construction-widget.tsx +40 -0
  111. pagebuilder/components/widgets/video-widget.tsx +103 -0
  112. pagebuilder/components/widgets/welcome-widget.tsx +38 -0
  113. pagebuilder/contracts/__init__.py +57 -0
  114. pagebuilder/contracts/events.py +25 -0
  115. pagebuilder/contracts/schemas.py +239 -0
  116. pagebuilder/conversion/blocks-to-html.test.ts +105 -0
  117. pagebuilder/conversion/blocks-to-html.ts +102 -0
  118. pagebuilder/conversion/index.ts +2 -0
  119. pagebuilder/conversion/types.ts +5 -0
  120. pagebuilder/deps.py +26 -0
  121. pagebuilder/diff.py +129 -0
  122. pagebuilder/endpoints/__init__.py +0 -0
  123. pagebuilder/endpoints/api/__init__.py +21 -0
  124. pagebuilder/endpoints/api/_deps.py +21 -0
  125. pagebuilder/endpoints/api/layout.py +78 -0
  126. pagebuilder/endpoints/api/pages.py +85 -0
  127. pagebuilder/endpoints/api/revisions.py +79 -0
  128. pagebuilder/endpoints/api/uploads.py +92 -0
  129. pagebuilder/endpoints/api/workflow.py +124 -0
  130. pagebuilder/endpoints/seo.py +95 -0
  131. pagebuilder/endpoints/views.py +228 -0
  132. pagebuilder/fields/checkbox-field.tsx +22 -0
  133. pagebuilder/fields/file-picker/file-gallery.tsx +152 -0
  134. pagebuilder/fields/file-picker/image-picker-field.tsx +214 -0
  135. pagebuilder/fields/file-picker/index.ts +6 -0
  136. pagebuilder/fields/file-picker/types.ts +15 -0
  137. pagebuilder/fields/index.ts +9 -0
  138. pagebuilder/fields/media-adapter.ts +47 -0
  139. pagebuilder/hooks/useAutosave.ts +134 -0
  140. pagebuilder/hooks/useEditorForm.ts +193 -0
  141. pagebuilder/hooks/useMediaUploads.ts +96 -0
  142. pagebuilder/hooks/usePageRevisions.ts +59 -0
  143. pagebuilder/hooks/usePageSchedule.ts +72 -0
  144. pagebuilder/hooks/usePageWorkflow.ts +173 -0
  145. pagebuilder/layout_service.py +101 -0
  146. pagebuilder/media_images.py +185 -0
  147. pagebuilder/media_queries.py +92 -0
  148. pagebuilder/media_service.py +222 -0
  149. pagebuilder/models.py +209 -0
  150. pagebuilder/module.py +279 -0
  151. pagebuilder/package.json +18 -0
  152. pagebuilder/pages/LayoutEditor.tsx +183 -0
  153. pagebuilder/pages/MediaLibrary.tsx +249 -0
  154. pagebuilder/pages/PageEditor.tsx +181 -0
  155. pagebuilder/pages/PageList.tsx +129 -0
  156. pagebuilder/pages/PendingReview.tsx +128 -0
  157. pagebuilder/pages/PublicPage.tsx +126 -0
  158. pagebuilder/permissions.py +44 -0
  159. pagebuilder/py.typed +0 -0
  160. pagebuilder/security.py +230 -0
  161. pagebuilder/service/__init__.py +141 -0
  162. pagebuilder/service/_common.py +24 -0
  163. pagebuilder/service/_revisions.py +92 -0
  164. pagebuilder/service/_workflow.py +207 -0
  165. pagebuilder/services.py +19 -0
  166. pagebuilder/settings.py +161 -0
  167. pagebuilder/static/widgets-base.css +57 -0
  168. pagebuilder/translation/apply.ts +54 -0
  169. pagebuilder/translation/extract.ts +141 -0
  170. pagebuilder/translation/index.ts +20 -0
  171. pagebuilder/translation/translatable-fields.ts +65 -0
  172. pagebuilder/translation/translation.test.ts +167 -0
  173. pagebuilder/utils/api.ts +12 -0
  174. pagebuilder/utils/datetime.ts +25 -0
  175. pagebuilder/utils/decorative-image.ts +15 -0
  176. pagebuilder/utils/editorSnapshot.ts +39 -0
  177. pagebuilder/utils/markdown.ts +160 -0
  178. pagebuilder/utils/mediaApi.ts +112 -0
  179. pagebuilder/utils/mediaFormat.ts +19 -0
  180. pagebuilder/utils/pagesApi.ts +111 -0
  181. pagebuilder/utils/parse-list.ts +7 -0
  182. pagebuilder/utils/request.ts +52 -0
  183. pagebuilder/utils/slugify.ts +9 -0
  184. pagebuilder/utils/types.ts +171 -0
  185. pagebuilder/utils/widgetUtils.ts +11 -0
  186. simple_module_pagebuilder-0.0.1.dist-info/METADATA +152 -0
  187. simple_module_pagebuilder-0.0.1.dist-info/RECORD +189 -0
  188. simple_module_pagebuilder-0.0.1.dist-info/WHEEL +4 -0
  189. simple_module_pagebuilder-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,207 @@
1
+ """Status transitions: publish, unpublish, schedule, submit, approve, reject.
2
+
3
+ Mixed into :class:`~pagebuilder.service.PagesService`. Split out to keep each
4
+ file focused; the methods are unchanged and still rely on ``self.db``,
5
+ ``self.get_page``, and ``self._record_revision`` from the composing class.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime
11
+
12
+ from fastapi import HTTPException
13
+ from sqlalchemy.ext.asyncio import AsyncSession
14
+ from sqlmodel import select
15
+
16
+ from pagebuilder.models import Page, PageStatus, RevisionEvent
17
+ from pagebuilder.service._common import _UNSET, _normalize_to_utc, _Unset
18
+ from pagebuilder.service._revisions import RevisionsMixin
19
+
20
+
21
+ class WorkflowMixin(RevisionsMixin):
22
+ """Status-transition behaviour for :class:`PagesService`."""
23
+
24
+ db: AsyncSession
25
+
26
+ async def publish(self, page_id: int, note: str | None = None) -> Page:
27
+ page = await self.get_page(page_id)
28
+ page.published_data = page.draft_data
29
+ page.status = PageStatus.PUBLISHED
30
+ page.rejection_note = None
31
+ # A manual publish supersedes any pending auto-publish for the
32
+ # same page; clearing it stops the scheduler from re-firing on
33
+ # the next tick.
34
+ page.publish_at = None
35
+ self.db.add(page)
36
+ self._record_revision(page, event=RevisionEvent.PUBLISH, note=note)
37
+ await self.db.flush()
38
+ await self.db.refresh(page)
39
+ return page
40
+
41
+ async def unpublish(self, page_id: int, note: str | None = None) -> Page:
42
+ page = await self.get_page(page_id)
43
+ page.status = PageStatus.DRAFT
44
+ # Same reasoning as ``publish``: a manual unpublish wipes any
45
+ # pending auto-unpublish so the scheduler doesn't re-fire.
46
+ page.unpublish_at = None
47
+ self.db.add(page)
48
+ self._record_revision(page, event=RevisionEvent.UNPUBLISH, note=note)
49
+ await self.db.flush()
50
+ await self.db.refresh(page)
51
+ return page
52
+
53
+ async def schedule(
54
+ self,
55
+ page_id: int,
56
+ *,
57
+ publish_at: datetime | _Unset | None = _UNSET,
58
+ unpublish_at: datetime | _Unset | None = _UNSET,
59
+ ) -> Page:
60
+ """Set or clear a page's scheduled flip timestamps.
61
+
62
+ Each field is independently optional: pass a ``datetime`` to set,
63
+ pass ``None`` to clear, omit (sentinel ``_UNSET``) to leave the
64
+ existing value alone. Aware datetimes are normalized to UTC;
65
+ naive ones are assumed UTC. Rejects ``publish_at >= unpublish_at``
66
+ with a 422 since that would auto-unpublish before the auto-publish
67
+ could fire.
68
+ """
69
+ page = await self.get_page(page_id)
70
+ # Normalize both the incoming values and any already-stored values:
71
+ # SQLite drops tz info on round-trip, so a naive existing column
72
+ # would otherwise break the cross-field comparison below.
73
+ new_publish = (
74
+ _normalize_to_utc(publish_at)
75
+ if not isinstance(publish_at, _Unset)
76
+ else _normalize_to_utc(page.publish_at)
77
+ )
78
+ new_unpublish = (
79
+ _normalize_to_utc(unpublish_at)
80
+ if not isinstance(unpublish_at, _Unset)
81
+ else _normalize_to_utc(page.unpublish_at)
82
+ )
83
+ if (
84
+ new_publish is not None
85
+ and new_unpublish is not None
86
+ and new_publish >= new_unpublish
87
+ ):
88
+ raise HTTPException(
89
+ status_code=422,
90
+ detail="publish_at must be strictly before unpublish_at",
91
+ )
92
+ page.publish_at = new_publish
93
+ page.unpublish_at = new_unpublish
94
+ self.db.add(page)
95
+ await self.db.flush()
96
+ await self.db.refresh(page)
97
+ return page
98
+
99
+ async def process_due(self, now: datetime) -> list[Page]:
100
+ """Flip every page whose scheduled time has elapsed.
101
+
102
+ Drafts with ``publish_at <= now`` go live (clearing ``publish_at``);
103
+ published pages with ``unpublish_at <= now`` go back to draft
104
+ (clearing ``unpublish_at``). Returns the flipped pages so callers
105
+ can log / emit metrics.
106
+
107
+ Idempotent: each tick re-queries the table, so a missed wakeup
108
+ catches up on the next one. Failures on a single page are caught
109
+ and skipped so one bad row doesn't poison the whole tick.
110
+ """
111
+ flipped: list[Page] = []
112
+
113
+ pub_due = await self.db.execute(
114
+ select(Page).where(
115
+ Page.status == PageStatus.DRAFT,
116
+ Page.publish_at.is_not(None), # type: ignore[union-attr]
117
+ Page.publish_at <= now, # type: ignore[operator]
118
+ )
119
+ )
120
+ for page in pub_due.scalars().all():
121
+ try:
122
+ await self.publish(page.id or 0, note="Auto-published on schedule")
123
+ flipped.append(page)
124
+ except HTTPException:
125
+ continue
126
+
127
+ unpub_due = await self.db.execute(
128
+ select(Page).where(
129
+ Page.status == PageStatus.PUBLISHED,
130
+ Page.unpublish_at.is_not(None), # type: ignore[union-attr]
131
+ Page.unpublish_at <= now, # type: ignore[operator]
132
+ )
133
+ )
134
+ for page in unpub_due.scalars().all():
135
+ try:
136
+ await self.unpublish(page.id or 0, note="Auto-unpublished on schedule")
137
+ flipped.append(page)
138
+ except HTTPException:
139
+ continue
140
+
141
+ return flipped
142
+
143
+ async def submit_for_review(self, page_id: int, note: str | None = None) -> Page:
144
+ """Editor flow: hand a draft off to an approver.
145
+
146
+ Allowed transitions: ``DRAFT → SUBMITTED_FOR_REVIEW``. Re-submitting
147
+ an already-submitted page is a 409 — the approver still owns it.
148
+ Re-submission *after* a rejection is fine because the page was sent
149
+ back to ``DRAFT`` with a ``rejection_note`` that's now cleared.
150
+ """
151
+ page = await self.get_page(page_id)
152
+ if page.status != PageStatus.DRAFT:
153
+ raise HTTPException(
154
+ status_code=409,
155
+ detail=f"Cannot submit a page in '{page.status.value}' status",
156
+ )
157
+ page.status = PageStatus.SUBMITTED_FOR_REVIEW
158
+ page.rejection_note = None
159
+ self.db.add(page)
160
+ self._record_revision(page, event=RevisionEvent.SUBMIT, note=note)
161
+ await self.db.flush()
162
+ await self.db.refresh(page)
163
+ return page
164
+
165
+ async def approve(self, page_id: int, note: str | None = None) -> Page:
166
+ """Approver flow: take a submission live.
167
+
168
+ One audit row tagged ``APPROVE``; the row's ``data`` snapshot is
169
+ what ``/p/{slug}`` will serve, so it doubles as a restorable
170
+ revision the editor history offers alongside plain publishes.
171
+ """
172
+ page = await self.get_page(page_id)
173
+ if page.status != PageStatus.SUBMITTED_FOR_REVIEW:
174
+ raise HTTPException(
175
+ status_code=409,
176
+ detail=f"Cannot approve a page in '{page.status.value}' status",
177
+ )
178
+ page.published_data = page.draft_data
179
+ page.status = PageStatus.PUBLISHED
180
+ page.rejection_note = None
181
+ self.db.add(page)
182
+ self._record_revision(page, event=RevisionEvent.APPROVE, note=note)
183
+ await self.db.flush()
184
+ await self.db.refresh(page)
185
+ return page
186
+
187
+ async def reject(self, page_id: int, note: str) -> Page:
188
+ """Approver flow: send a submission back to draft with feedback.
189
+
190
+ ``note`` is captured on the page row (so the editor banner sees
191
+ the latest one without joining the revision table) *and* on the
192
+ revision row (so older notes stay queryable from the history).
193
+ """
194
+ page = await self.get_page(page_id)
195
+ if page.status != PageStatus.SUBMITTED_FOR_REVIEW:
196
+ raise HTTPException(
197
+ status_code=409,
198
+ detail=f"Cannot reject a page in '{page.status.value}' status",
199
+ )
200
+ page.status = PageStatus.DRAFT
201
+ page.rejection_note = note
202
+ self.db.add(page)
203
+ self._record_revision(page, event=RevisionEvent.REJECT, note=note)
204
+ await self.db.flush()
205
+ await self.db.refresh(page)
206
+ return page
207
+
@@ -0,0 +1,19 @@
1
+ """Module-scoped state container.
2
+
3
+ Stored as ``app.state.pagebuilder`` by
4
+ :meth:`PagebuilderModule.register_settings`. Set once during boot,
5
+ treat as read-only after.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ from pagebuilder.settings import PagebuilderSettings
13
+
14
+
15
+ @dataclass
16
+ class PagebuilderServices:
17
+ """PageBuilder module singletons."""
18
+
19
+ settings: PagebuilderSettings
@@ -0,0 +1,161 @@
1
+ """PageBuilder module settings.
2
+
3
+ Per-module env-var prefix is ``SM_PAGEBUILDER_*``.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ from pydantic_settings import BaseSettings, SettingsConfigDict
11
+
12
+
13
+ class PagebuilderSettings(BaseSettings):
14
+ model_config = SettingsConfigDict(env_prefix="SM_PAGEBUILDER_", extra="ignore")
15
+
16
+ public_route_prefix: str = "/p"
17
+ """URL prefix used for public published pages: ``{prefix}/{slug}``."""
18
+
19
+ media_root: Path = Path("var/pagebuilder/media")
20
+ """Filesystem directory where uploaded media is stored.
21
+
22
+ Resolved against the host's working directory on startup; created if
23
+ it doesn't exist.
24
+ """
25
+
26
+ media_url_prefix: str = "/media/pagebuilder"
27
+ """URL prefix the media directory is mounted at."""
28
+
29
+ media_max_bytes: int = 10 * 1024 * 1024
30
+ """Reject uploads larger than this (default 10 MB)."""
31
+
32
+ media_allowed_content_types: tuple[str, ...] = (
33
+ "image/jpeg",
34
+ "image/png",
35
+ "image/gif",
36
+ "image/webp",
37
+ )
38
+ """Content-types accepted by the upload endpoint.
39
+
40
+ SVG is intentionally excluded from the default allowlist: SVG is XML
41
+ and can embed ``<script>`` tags, yielding stored-XSS the moment a
42
+ user views the file under a same-origin URL. Hosts that need SVG
43
+ should add it explicitly via ``SM_PAGEBUILDER_MEDIA_ALLOWED_CONTENT_TYPES``
44
+ and pair it with their own sanitizer.
45
+ """
46
+
47
+ media_thumbnail_widths: tuple[int, ...] = (320, 640, 1280, 1920)
48
+ """Pixel widths to generate webp thumbnails for on upload.
49
+
50
+ Only widths *strictly less than* the source width produce a derivative
51
+ — there's no point upscaling a 600px hero to 1920px. Set to an empty
52
+ tuple to disable thumbnail generation entirely.
53
+ """
54
+
55
+ media_webp_quality: int = 82
56
+ """Pillow encoder ``quality`` for webp thumbnails (0-100)."""
57
+
58
+ public_csp: str = (
59
+ "default-src 'self'; "
60
+ "script-src 'self'; "
61
+ "style-src 'self' 'unsafe-inline'; "
62
+ "img-src 'self' https: data:; "
63
+ "font-src 'self' data:; "
64
+ "connect-src 'self'; "
65
+ "frame-ancestors 'none'; "
66
+ "base-uri 'self'; "
67
+ "form-action 'self'"
68
+ )
69
+ """Content-Security-Policy header value for ``/p/{slug}`` responses.
70
+
71
+ Override via ``SM_PAGEBUILDER_PUBLIC_CSP`` to widen for analytics,
72
+ fonts, CDNs, etc. Set to an empty string to disable.
73
+ """
74
+
75
+ public_cache_max_age: int = 60
76
+ """``max-age`` (seconds) on ``Cache-Control`` for published pages."""
77
+
78
+ public_cache_swr: int = 600
79
+ """``stale-while-revalidate`` (seconds) on published pages."""
80
+
81
+ public_base_url: str | None = None
82
+ """Absolute base URL of the public site (e.g. ``https://example.com``).
83
+
84
+ Used to build canonical / ``og:url`` values. When unset, the public
85
+ page falls back to ``Request.url`` — fine for local dev but unreliable
86
+ behind a reverse proxy that strips ``Host`` / ``X-Forwarded-*``
87
+ headers. No trailing slash; the public route prefix is appended.
88
+ """
89
+
90
+ site_name: str | None = None
91
+ """Brand name emitted as ``<meta property="og:site_name">``.
92
+
93
+ Skip the tag entirely when blank — empty meta tags hurt social-card
94
+ previews more than missing ones.
95
+ """
96
+
97
+ twitter_handle: str | None = None
98
+ """Publisher handle emitted as ``<meta name="twitter:site">``.
99
+
100
+ Include the leading ``@``; rendered verbatim so consumers (Twitter's
101
+ crawler, link unfurlers) see exactly what's configured. Skipped when
102
+ blank.
103
+ """
104
+
105
+ sitemap_enabled: bool = True
106
+ """Serve ``GET /sitemap.xml`` listing all published, indexable pages.
107
+
108
+ Disable when the host already publishes its own sitemap (e.g. a
109
+ multi-module deployment whose top-level sitemap aggregates several
110
+ sources).
111
+ """
112
+
113
+ robots_enabled: bool = True
114
+ """Serve ``GET /robots.txt`` referencing the sitemap when enabled."""
115
+
116
+ robots_body: str | None = None
117
+ """Override the default ``robots.txt`` body.
118
+
119
+ When ``None``, the route emits a minimal ``User-agent: *\nAllow: /``
120
+ plus a ``Sitemap:`` reference (only when ``sitemap_enabled``). Set
121
+ explicitly to lock crawlers out of a staging environment.
122
+ """
123
+
124
+ seo_cache_max_age: int = 300
125
+ """``max-age`` (seconds) on the ``Cache-Control`` header for
126
+ ``/sitemap.xml`` and ``/robots.txt``."""
127
+
128
+ requires_auth: bool = True
129
+ """Gate admin routes (``/pagebuilder/*`` and ``/api/pagebuilder/*``)
130
+ behind a ``get_current_user`` dependency.
131
+
132
+ Defaults to ``True`` so a host scaffolded without auth middleware does
133
+ not silently expose page CRUD + media uploads to the public internet.
134
+ The public viewer (``/p/{slug}``) is always anonymous regardless of
135
+ this setting. Disable via ``SM_PAGEBUILDER_REQUIRES_AUTH=false`` only
136
+ when the host gates admin paths at a layer above the application
137
+ (e.g. an authenticating reverse proxy).
138
+ """
139
+
140
+ csrf_protect: bool = True
141
+ """Require an ``X-CSRF-Token`` header on every mutating admin request.
142
+
143
+ The token is per-session, generated lazily on the first admin GET and
144
+ surfaced both via Inertia shared props (``csrf.token``) and a
145
+ non-``HttpOnly`` cookie (see ``csrf_cookie_name``) so the React client
146
+ can read it back and include it on every POST/PUT/PATCH/DELETE.
147
+ """
148
+
149
+ csrf_cookie_name: str = "pagebuilder_csrf"
150
+ """Cookie name used to expose the per-session CSRF token to JS."""
151
+
152
+ scheduler_enabled: bool = True
153
+ """Run an in-process loop that flips pages on their scheduled times.
154
+
155
+ Disable in deployments where a separate worker (Celery beat, k8s
156
+ CronJob, etc.) drives :meth:`PagesService.process_due` directly —
157
+ otherwise both would race and double-publish.
158
+ """
159
+
160
+ scheduler_interval_seconds: int = 30
161
+ """How often the in-process scheduler wakes to look for due flips."""
@@ -0,0 +1,57 @@
1
+ /* Base design tokens for the ported GCA widget set.
2
+ *
3
+ * The widgets read these through var(), so a site changes their whole look
4
+ * by overriding the tokens rather than by editing widget classes. A design
5
+ * pack (e.g. gca.css) layers over this.
6
+ */
7
+
8
+ :root {
9
+ /* Page-builder (Puck widget) theme tokens — Recodo defaults. Tenants that
10
+ need a different look (e.g. GCA in .gca-root) override these so the same
11
+ widgets render per-tenant via the `style` property instead of hardcoded
12
+ classes. */
13
+ --pb-display-weight: 300;
14
+ --pb-display-tracking: -0.025em;
15
+ --pb-display-font: var(--font-sans);
16
+ --pb-body-color: var(--muted-foreground);
17
+ --pb-heading-color: var(--foreground);
18
+ --pb-surface: var(--card);
19
+ --pb-surface-muted: var(--muted);
20
+ --pb-accent: var(--primary);
21
+ --pb-accent-foreground: var(--primary-foreground);
22
+ /* Text color on colored band surfaces (MediaObject/Faq/CallToAction
23
+ surfaceColor, FeatureCards cardBg). */
24
+ --pb-surface-contrast: #ffffff;
25
+ /* CTA/Button pill shape + trailing arrow. `0.375rem` = the prior `rounded-md`;
26
+ arrow is off by default (tenants set `inline-block` to reveal "→"). */
27
+ --pb-button-radius: 0.375rem;
28
+ --pb-button-arrow-display: none;
29
+ /* Section-eyebrow case/tracking is deliberately NOT defaulted here.
30
+ `--pb-eyebrow-transform`/`--pb-eyebrow-tracking` are read only through
31
+ `var(…, <fallback>)` in the widgets, and a var() fallback is skipped
32
+ entirely once the property is set — so declaring `none`/`normal` globally
33
+ made every call site's own fallback unreachable. That silently flattened
34
+ the one call site whose previous literal was `uppercase tracking-wide`
35
+ (Timeline "blobs"). The values here were byte-identical to
36
+ EYEBROW_CASE_STYLE's fallbacks in _shared/mask.ts, so removing them
37
+ changes nothing for the sentence-case eyebrows and lets each call site
38
+ state its own prior literal. Tenants still override via their design pack. */
39
+ /* Hero geometry (consumed by the Hero/PageHeader widgets). */
40
+ --pb-hero-min-h: 560px;
41
+ --pb-hero-logo-max-h: 14rem;
42
+ --pb-hero-pull: 0px;
43
+ }
44
+
45
+ /* The public page wraps the layout's header slot in its own <header>, whose
46
+ box is exactly as tall as the header block inside it — so `position: sticky`
47
+ on that block would have no distance to travel and silently do nothing. The
48
+ SiteHeader widget therefore marks itself and the *wrapper* takes the sticky
49
+ positioning. Scoped by the attribute so a header built from plain blocks, or
50
+ one with "stick to the top" switched off, still scrolls away normally. */
51
+ /* Descendant, not child: Puck's <Render> wraps every block in its own div, so
52
+ the widget is a grandchild of the slot wrapper, never a direct child. */
53
+ header:has([data-pb-site-header="sticky"]) {
54
+ position: sticky;
55
+ top: 0;
56
+ z-index: 50;
57
+ }
@@ -0,0 +1,54 @@
1
+ import type { FieldPath } from './extract';
2
+
3
+ // Objects and arrays are both indexable by a path segment — `obj["key"]` and
4
+ // `arr[2]` alike — and arrays are `typeof === "object"`, so one guard covers
5
+ // both.
6
+ function isIndexable(value: unknown): value is Record<PropertyKey, unknown> {
7
+ return typeof value === 'object' && value !== null;
8
+ }
9
+
10
+ /**
11
+ * Write `translations[n]` back to the location `paths[n]` recorded, and
12
+ * re-serialize.
13
+ *
14
+ * The exact inverse of `extractTranslatableStrings`: feeding it the strings
15
+ * that function just extracted reproduces the input structurally. Props not
16
+ * named in `paths` are never touched, so a translation pass can't quietly
17
+ * rewrite a URL or drop a field it didn't understand.
18
+ */
19
+ export function applyTranslatedStrings(
20
+ puckJson: string,
21
+ paths: FieldPath[],
22
+ translations: string[],
23
+ ): string {
24
+ let parsed: unknown;
25
+ try {
26
+ parsed = JSON.parse(puckJson);
27
+ } catch {
28
+ // `extractTranslatableStrings` returns an empty result for unparseable
29
+ // input rather than throwing. These two are documented as exact inverses,
30
+ // so a caller that survived the first half must not be crashed by the
31
+ // second — hand the input straight back untouched.
32
+ return puckJson;
33
+ }
34
+
35
+ paths.forEach((path, index) => {
36
+ const translation = translations[index];
37
+ // Loose equality catches `undefined` (array shorter than `paths`) and a
38
+ // literal JSON `null` alike. Either way the original stands — writing
39
+ // `null` over real copy is the one outcome worse than not translating.
40
+ if (translation == null || path.length === 0) return;
41
+
42
+ // Walk to the container that directly holds the final segment.
43
+ let current: unknown = parsed;
44
+ for (let i = 0; i < path.length - 1; i++) {
45
+ if (!isIndexable(current)) return;
46
+ current = current[path[i]];
47
+ }
48
+ if (!isIndexable(current)) return;
49
+
50
+ current[path[path.length - 1]] = translation;
51
+ });
52
+
53
+ return JSON.stringify(parsed);
54
+ }
@@ -0,0 +1,141 @@
1
+ import type { ArrayField, Config, Field } from '@puckeditor/core';
2
+
3
+ import { isTranslatableField } from './translatable-fields';
4
+
5
+ /** One step of a {@link FieldPath}: an object key or an array index. */
6
+ export type PathSegment = string | number;
7
+
8
+ /**
9
+ * Locates one translatable string inside a parsed Puck document, as the list
10
+ * of segments walked from the root. Examples:
11
+ * - `["content", 2, "props", "text"]`
12
+ * - `["content", 0, "props", "items", 1, "links", 0, "label"]`
13
+ * - `["root", "props", "title"]`
14
+ *
15
+ * Depth-agnostic, so an array nested inside an array item is addressable.
16
+ * `applyTranslatedStrings` is the exact inverse.
17
+ */
18
+ export type FieldPath = PathSegment[];
19
+
20
+ export interface ExtractResult {
21
+ strings: string[];
22
+ paths: FieldPath[];
23
+ }
24
+
25
+ function isRecord(value: unknown): value is Record<string, unknown> {
26
+ return typeof value === 'object' && value !== null;
27
+ }
28
+
29
+ /**
30
+ * Pull every human-readable string out of a serialized Puck document.
31
+ *
32
+ * Walks `content[]`, each `zones[*][]`, and `root.props`, resolving each
33
+ * block's field shape from `config` so non-copy props — URLs, colours, ids,
34
+ * enums, image pickers — are left alone. Recurses into array fields to any
35
+ * depth. Pure: no network, no React, no DOM.
36
+ *
37
+ * Both halves take and return the serialized JSON rather than parsed data,
38
+ * so a caller can hand a translated document straight back to storage without
39
+ * a re-serialize that might reorder keys.
40
+ */
41
+ export function extractTranslatableStrings(puckJson: string, config: Config): ExtractResult {
42
+ const strings: string[] = [];
43
+ const paths: FieldPath[] = [];
44
+
45
+ let parsed: unknown;
46
+ try {
47
+ parsed = JSON.parse(puckJson);
48
+ } catch {
49
+ return { strings, paths };
50
+ }
51
+ if (!isRecord(parsed)) return { strings, paths };
52
+
53
+ function visitFields(
54
+ container: Record<string, unknown>,
55
+ fields: Record<string, Field>,
56
+ basePath: FieldPath,
57
+ ): void {
58
+ for (const [key, field] of Object.entries(fields)) {
59
+ const value = container[key];
60
+
61
+ // A slot holds whole *blocks*, not field-shaped items, so it recurses
62
+ // through `visitBlock` (which resolves each child's own field shape)
63
+ // rather than through `visitFields`. Without this, everything an author
64
+ // nested inside a Columns or Container would be invisible to the
65
+ // translator — the blocks are there, but nothing walks into them.
66
+ if (field?.type === 'slot') {
67
+ if (!Array.isArray(value)) continue;
68
+ value.forEach((child: unknown, index: number) =>
69
+ visitBlock(child, [...basePath, key, index]),
70
+ );
71
+ continue;
72
+ }
73
+
74
+ if (field?.type === 'array') {
75
+ if (!Array.isArray(value)) continue;
76
+ const arrayFields = (field as ArrayField).arrayFields as
77
+ | Record<string, Field>
78
+ | undefined;
79
+ if (!arrayFields) continue;
80
+ value.forEach((item: unknown, index: number) => {
81
+ if (!isRecord(item)) return;
82
+ // An array item's own fields may themselves include a slot —
83
+ // `Columns.columns[].content` is exactly that shape.
84
+ visitFields(item, arrayFields, [...basePath, key, index]);
85
+ });
86
+ continue;
87
+ }
88
+
89
+ if (isTranslatableField(key, field) && typeof value === 'string' && value.trim() !== '') {
90
+ strings.push(value);
91
+ paths.push([...basePath, key]);
92
+ }
93
+ }
94
+ }
95
+
96
+ function visitBlock(block: unknown, blockPath: FieldPath): void {
97
+ if (!isRecord(block)) return;
98
+ const props = block.props;
99
+ if (!isRecord(props)) return;
100
+
101
+ const type = typeof block.type === 'string' ? block.type : undefined;
102
+ const fields = type ? config.components?.[type]?.fields : config.root?.fields;
103
+ if (!fields) return;
104
+
105
+ visitFields(props, fields as Record<string, Field>, [...blockPath, 'props']);
106
+ }
107
+
108
+ const content = parsed.content;
109
+ if (Array.isArray(content)) {
110
+ content.forEach((block: unknown, index: number) => visitBlock(block, ['content', index]));
111
+ }
112
+
113
+ // Pre-slots documents keep nested blocks here. `migrateContent` folds these
114
+ // into slot props on load, but extraction runs against whatever is stored,
115
+ // which may not have been through the editor since.
116
+ const zones = parsed.zones;
117
+ if (isRecord(zones)) {
118
+ for (const [zoneKey, zoneBlocks] of Object.entries(zones)) {
119
+ if (!Array.isArray(zoneBlocks)) continue;
120
+ zoneBlocks.forEach((block: unknown, index: number) =>
121
+ visitBlock(block, ['zones', zoneKey, index]),
122
+ );
123
+ }
124
+ }
125
+
126
+ const root = parsed.root;
127
+ if (isRecord(root)) {
128
+ if (isRecord(root.props)) {
129
+ visitBlock(root, ['root']);
130
+ } else if (config.root?.fields) {
131
+ // Oldest shape: root props sit directly on `root` with no `props` nest.
132
+ // `visitBlock` bails on those because it requires `props`, which would
133
+ // make a pre-0.20 page's title quietly untranslatable — and this file
134
+ // already goes out of its way to handle the other legacy shape (`zones`),
135
+ // so handling one and not the other would be arbitrary.
136
+ visitFields(root, config.root.fields as Record<string, Field>, ['root']);
137
+ }
138
+ }
139
+
140
+ return { strings, paths };
141
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Content i18n: pull the human copy out of a stored page, and write
3
+ * translations back into the same shape.
4
+ *
5
+ * Pure functions with no storage of their own — same as upstream. Nothing in
6
+ * this module decides *where* a translated document lives; that is a schema
7
+ * question for whichever host wants the feature.
8
+ *
9
+ * Distinct from UI i18n (the editor's own English strings), which is tracked
10
+ * separately in CLAUDE.md.
11
+ */
12
+
13
+ export { applyTranslatedStrings } from './apply';
14
+ export {
15
+ type ExtractResult,
16
+ extractTranslatableStrings,
17
+ type FieldPath,
18
+ type PathSegment,
19
+ } from './extract';
20
+ export { isTranslatableField, TRANSLATABLE_TEXT_TYPES } from './translatable-fields';