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,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
|
+
|
pagebuilder/services.py
ADDED
|
@@ -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
|
pagebuilder/settings.py
ADDED
|
@@ -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';
|