@bygghammar/cms-platform 0.90.0-m7.9 → 0.91.0
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.
- package/CHANGELOG.md +241 -0
- package/README.md +23 -1
- package/docs/GOOGLE-SHEETS.md +145 -0
- package/docs/LAYOUT-BLOCKS.md +125 -0
- package/docs/MEDIA-FOLDERS.md +90 -0
- package/docs/NEW-SITE.md +35 -0
- package/docs/PAGES.md +452 -0
- package/docs/SCHEDULING.md +130 -0
- package/docs/TYPOGRAPHY.md +86 -0
- package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
- package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
- package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
- package/package.json +5 -1
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/FormEditorView.tsx +10 -0
- package/src/backoffice/FormSettingsView.tsx +8 -0
- package/src/backoffice/MediaLibraryView.tsx +239 -21
- package/src/backoffice/NavigationView.tsx +43 -0
- package/src/backoffice/PageBuilderView.tsx +193 -36
- package/src/backoffice/PageEditorView.tsx +293 -57
- package/src/backoffice/PagesListView.tsx +294 -19
- package/src/backoffice/ScheduleFields.tsx +151 -0
- package/src/backoffice/SectionFormView.tsx +58 -0
- package/src/backoffice/StatusBadge.tsx +174 -0
- package/src/backoffice/TrackingView.tsx +247 -208
- package/src/backoffice/TypographyPanel.tsx +308 -0
- package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
- package/src/backoffice/inspectors/blocks.tsx +149 -0
- package/src/backoffice/inspectors/fields.tsx +81 -0
- package/src/backoffice/inspectors/index.tsx +3 -0
- package/src/backoffice/media/ConfirmDialog.tsx +64 -4
- package/src/backoffice/routes/host.ts +31 -0
- package/src/backoffice/routes/index.tsx +140 -15
- package/src/backoffice/routes/media-forms.tsx +5 -0
- package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
- package/src/backoffice/seo/page-seo-form.ts +11 -1
- package/src/backoffice/shell/domain-guard.ts +27 -14
- package/src/backoffice/shell/index.ts +5 -0
- package/src/backoffice/shell/use-resolved-config.tsx +67 -0
- package/src/backoffice/tracking-drafts.ts +150 -0
- package/src/blocks/block-children.tsx +40 -0
- package/src/blocks/block-tree.ts +378 -0
- package/src/blocks/components/IconGridBlock.tsx +76 -51
- package/src/blocks/components/LayoutBlock.tsx +100 -0
- package/src/blocks/countdown-time.ts +17 -87
- package/src/blocks/registry.tsx +21 -0
- package/src/blocks/types.ts +40 -0
- package/src/cms/page-publication.ts +377 -0
- package/src/cms/page-status.ts +152 -0
- package/src/cms/timezone.ts +181 -0
- package/src/cms/use-publication-boundary.ts +84 -0
- package/src/cms/use-schedule-clock.ts +43 -0
- package/src/cms/visibility-schedule.ts +267 -0
- package/src/config/modules.ts +34 -10
- package/src/config/site-config.ts +32 -0
- package/src/config/typography.ts +143 -0
- package/src/http/cache-policy.ts +49 -2
- package/src/media/folders.ts +183 -0
- package/src/media/use-media-upload.ts +21 -4
- package/src/navigation/index.ts +34 -2
- package/src/platform/contract.ts +52 -1
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +16 -0
- package/src/server/domains-core.ts +124 -107
- package/src/server/forms-delivery-core.ts +61 -10
- package/src/server/forms-submissions-core.ts +47 -0
- package/src/server/google-service-account.ts +325 -0
- package/src/server/google-sheets-core.ts +463 -0
- package/src/server/http/sitemap.ts +32 -8
- package/src/server/injections-core.ts +230 -49
- package/src/server/media-library-core.ts +27 -6
- package/src/server/pages-core.ts +680 -184
- package/src/server/published-cache.ts +25 -4
- package/src/server/resolve-globals.ts +6 -1
- package/src/server/schema-drift.ts +4 -0
- package/src/server/schemas.ts +15 -0
- package/src/site/PageRenderer.tsx +45 -12
- package/src/site/SiteFooter.tsx +44 -3
- package/src/site/SiteHeader.tsx +23 -2
- package/src/site/google-fonts.ts +191 -0
- package/src/site/host.ts +172 -0
- package/src/site/locale-domains.ts +500 -0
- package/src/site/routes/public-page.tsx +12 -1
- package/src/site/routes.ts +206 -38
- package/src/site/site-url.ts +80 -17
- package/src/site/typography.tsx +75 -0
- package/src/tracking/injections.ts +52 -9
- package/src/ui/MobileCarousel.tsx +289 -0
package/docs/NEW-SITE.md
CHANGED
|
@@ -34,6 +34,41 @@ Add one test that pins the contract version the site was built against and one
|
|
|
34
34
|
that asserts `missingMigrations(appliedFilenames)` is empty. See
|
|
35
35
|
`tests/platform-contract.test.ts` in any existing site for the exact shape.
|
|
36
36
|
|
|
37
|
+
The current contract is **v2**, because saving a page version — an ordinary
|
|
38
|
+
draft save included — is a single database function call with no JavaScript
|
|
39
|
+
fallback. Three canonical migrations are new since the last published
|
|
40
|
+
`0.90.0-m7.12` package, so a site upgrading from that baseline is missing all
|
|
41
|
+
three:
|
|
42
|
+
|
|
43
|
+
| File | Adds |
|
|
44
|
+
| --- | --- |
|
|
45
|
+
| `20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql` | restates the `draft` default, records the `pages.status` column semantics at that migration step, and adds two partial `pages` indexes: one on `key` for undeleted status-published rows, one on `status`/`updated_at` for undeleted rows |
|
|
46
|
+
| `20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql` | `page_publications` — the row that decides what is public — the backfill for pages that were already live, and the first transactional page functions |
|
|
47
|
+
| `20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql` | the rest of the transactional page lifecycle: reveals, trash, restore and cancellation, plus two repair `UPDATE`s |
|
|
48
|
+
|
|
49
|
+
The last two are the contract-v2 publication pair: they are what
|
|
50
|
+
`PLATFORM_CONTRACT_VERSION = 2` means. All three are in the canonical schema
|
|
51
|
+
manifest, though, so `missingMigrations` and `compareSchema` report every one
|
|
52
|
+
that has not been applied — applying only the pair still leaves the schema out
|
|
53
|
+
of sync. Go out in this order, and not in another:
|
|
54
|
+
|
|
55
|
+
1. Apply every missing canonical migration. Upgrading from `0.90.0-m7.12`, that
|
|
56
|
+
is all three files above, in chronological order.
|
|
57
|
+
2. Deploy the package and the site pinned to v2.
|
|
58
|
+
3. Verify both: `missingMigrations(applied)` is empty, and
|
|
59
|
+
`assertPlatformContract(2)` passes. The second compares two version numbers
|
|
60
|
+
in the site's own code and never reads the database, so it is no substitute
|
|
61
|
+
for the first.
|
|
62
|
+
4. Let editors back in.
|
|
63
|
+
|
|
64
|
+
The migrations are designed to be applied before the v2 package is deployed,
|
|
65
|
+
and the still-running m7.12 package remains compatible during that ordered
|
|
66
|
+
rollout. They are not pure DDL and not function-only: the set carries an
|
|
67
|
+
`ALTER … SET DEFAULT`, indexes and a column comment, a backfill `INSERT`, two
|
|
68
|
+
repair `UPDATE`s scoped to pages already in the trash, and new and replaced
|
|
69
|
+
functions. Nothing is deleted, and no page that is public before the upgrade
|
|
70
|
+
stops being public because of it.
|
|
71
|
+
|
|
37
72
|
## 3. Apply the canonical schema
|
|
38
73
|
|
|
39
74
|
The package ships the canonical migrations under `migrations/`, oldest first.
|
package/docs/PAGES.md
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
# Pages: draft, published, duplicated
|
|
2
|
+
|
|
3
|
+
## Draft is the default, everywhere
|
|
4
|
+
|
|
5
|
+
A page that has just been created — or just been duplicated — is a **draft**.
|
|
6
|
+
Nothing about it is on the public internet until somebody publishes it
|
|
7
|
+
deliberately.
|
|
8
|
+
|
|
9
|
+
That default is declared in three places that have to agree, and they are listed
|
|
10
|
+
here because getting one of them wrong is the failure mode that leaks an unfinished
|
|
11
|
+
page:
|
|
12
|
+
|
|
13
|
+
| Layer | Where |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| Database default | `ALTER TABLE public.pages ALTER COLUMN status SET DEFAULT 'draft'` |
|
|
16
|
+
| Server | `DEFAULT_PAGE_STATUS` in `src/cms/page-status.ts`, applied in `pages-core.ts` |
|
|
17
|
+
| Public read | `page_publications` — an open publication window, and nothing else, makes a page public |
|
|
18
|
+
|
|
19
|
+
Path resolution is deliberately status-blind: `resolvePathCore` answers with a
|
|
20
|
+
page key for a draft too, because the same map serves the backoffice preview.
|
|
21
|
+
The refusal happens one step later, in the public route's loader. If the
|
|
22
|
+
published-page query answers with nothing — draft, scheduled but not yet open,
|
|
23
|
+
or soft-deleted — the loader throws `notFound()`.
|
|
24
|
+
|
|
25
|
+
That ordering is the point. `head()` is built from `loaderData`, so a loader
|
|
26
|
+
that throws means the page's title, description and canonical URL are never
|
|
27
|
+
emitted; without the refusal the route answered `200` with an empty body under
|
|
28
|
+
a draft's own SEO. `sitemap.xml` never lists the page either, because it asks
|
|
29
|
+
the same question of the same rows.
|
|
30
|
+
|
|
31
|
+
Knowing the URL gets a visitor a 404.
|
|
32
|
+
|
|
33
|
+
## One rule decides what is public
|
|
34
|
+
|
|
35
|
+
`src/cms/page-publication.ts` holds it, in a sentence:
|
|
36
|
+
|
|
37
|
+
> the newest active publication whose `publishAt` has arrived governs, and the
|
|
38
|
+
> page is public while that row's `unpublishAt` has not.
|
|
39
|
+
|
|
40
|
+
`pages.status` is **not** consulted by the public read. It records editorial
|
|
41
|
+
intent — is this page finished? — and a finished page is not thereby public: a
|
|
42
|
+
version scheduled for next week, a countdown target waiting for its deadline and
|
|
43
|
+
a page whose window has closed are all finished and all hidden.
|
|
44
|
+
|
|
45
|
+
Two things used to answer the question, and they disagreed in every direction
|
|
46
|
+
that matters. A public read that fell back to `pages.status` served a countdown
|
|
47
|
+
target the moment the countdown went up rather than at the deadline; it served a
|
|
48
|
+
version scheduled for next week immediately; and `unpublish_at` hid nothing at
|
|
49
|
+
all on a page that had already been published once. The fallback is gone.
|
|
50
|
+
|
|
51
|
+
### The screens say what a visitor gets
|
|
52
|
+
|
|
53
|
+
If `pages.status` does not decide what is public, an admin screen that shows only
|
|
54
|
+
`pages.status` is telling the editor something that is not true. Both screens now
|
|
55
|
+
show the state the rule produces — `publicationStateAt` over the page's own
|
|
56
|
+
`page_publications` rows — in five words an editor can act on:
|
|
57
|
+
|
|
58
|
+
| State | Badge | Means |
|
|
59
|
+
| --- | --- | --- |
|
|
60
|
+
| `draft` | Hidden | Nothing open, nothing scheduled |
|
|
61
|
+
| `scheduled` | Scheduled to publish | Hidden now; a window opens later |
|
|
62
|
+
| `published` | Published | A visitor can open it right now |
|
|
63
|
+
| `ending` | Scheduled to hide | Public, with an end date already set |
|
|
64
|
+
| `expired` | Hidden after expiry | Was public; the window has closed |
|
|
65
|
+
|
|
66
|
+
`pages.status` is still shown, labelled **Editorial** so it cannot be mistaken
|
|
67
|
+
for the other one, and the action offered follows the publication state rather
|
|
68
|
+
than the status: a page reading `Published` offers Unpublish even when its status
|
|
69
|
+
says `draft`, and an expired page offers Publish even when its status says
|
|
70
|
+
`published`.
|
|
71
|
+
|
|
72
|
+
### Where it is now, and what happens next, are two questions
|
|
73
|
+
|
|
74
|
+
The five states above answer only the first one. A page that is live today and
|
|
75
|
+
has a newer version scheduled for Friday is `published` — a visitor can open it
|
|
76
|
+
right now — and a badge that stopped there would say nothing about Friday. A
|
|
77
|
+
badge that instead read `Scheduled to publish` would be worse: it would claim the
|
|
78
|
+
page is hidden.
|
|
79
|
+
|
|
80
|
+
So the next boundary is derived separately, by `nextPublicationTransition`, which
|
|
81
|
+
answers *when* and *which of three things*:
|
|
82
|
+
|
|
83
|
+
| Kind | Happens at that instant |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| `publish` | A page that is hidden now becomes public |
|
|
86
|
+
| `replace` | A page that is public now starts serving a different version |
|
|
87
|
+
| `hide` | A page that is public now stops being reachable |
|
|
88
|
+
|
|
89
|
+
`publicationSummary` composes the two into what the badge says, so
|
|
90
|
+
`published` + `replace` reads **Published — update scheduled** and shows the
|
|
91
|
+
handover time, while `ending` + `hide` still reads `Scheduled to hide`. The
|
|
92
|
+
publication list marks the row that takes over as **next** —
|
|
93
|
+
`publication_next_version_id`, the version that transition installs — so it is
|
|
94
|
+
clear which of several queued rows is the one about to happen.
|
|
95
|
+
|
|
96
|
+
Both confirmations follow the transition rather than the state.
|
|
97
|
+
`PUBLISH_OVER_REPLACEMENT_CONFIRMATION` warns that publishing now makes the
|
|
98
|
+
current draft visible immediately *and cancels that update*;
|
|
99
|
+
`UNPUBLISH_OVER_SCHEDULE_CONFIRMATION` warns that unpublishing also cancels a
|
|
100
|
+
publication already scheduled, which will not happen by itself afterwards. A
|
|
101
|
+
host that has not started sending `publication_transition` yet still gets the
|
|
102
|
+
right wording: `statusChangeConfirmation` falls back to the publication state.
|
|
103
|
+
|
|
104
|
+
A screen left open crosses these boundaries without anybody touching it. Both
|
|
105
|
+
screens arm a timer for the soonest boundary they can see and refetch when it
|
|
106
|
+
passes, so a page goes from `Scheduled to publish` to `Published` in front of the
|
|
107
|
+
editor rather than at the next navigation. `boundaryTimeoutMs` clamps the wait to
|
|
108
|
+
six hours, so a boundary three months out costs one timer rather than a
|
|
109
|
+
`setTimeout` overflow, and re-arms until it arrives.
|
|
110
|
+
|
|
111
|
+
The awkward part is what happens *at* the boundary, and it is why both screens
|
|
112
|
+
call one hook — `usePublicationBoundaryRefresh` — instead of keeping a timer each.
|
|
113
|
+
The boundary is a fact about the server's data and the refresh is asynchronous, so
|
|
114
|
+
between asking for fresh data and receiving it `boundaryTimeoutMs` keeps answering
|
|
115
|
+
zero: the boundary really has passed, and it goes on having passed until a new
|
|
116
|
+
`publication_at` arrives. Re-arming on that zero is a refetch loop with no delay in
|
|
117
|
+
it, for as long as the answer takes and forever if the read fails. So the hook owns
|
|
118
|
+
a chain of timers rather than a dependency on its own state. Once a refresh has
|
|
119
|
+
been asked for, the next attempt waits `boundaryRetryMs` — thirty seconds, doubling
|
|
120
|
+
to a five-minute ceiling — and what re-arms the timer at its natural length is a
|
|
121
|
+
*new* boundary arriving in fresh data. Hitting the six-hour clamp is not an attempt
|
|
122
|
+
at anything, so it refetches and starts the wait over rather than counting against
|
|
123
|
+
the backoff.
|
|
124
|
+
|
|
125
|
+
### Cancelling a schedule is not unpublishing
|
|
126
|
+
|
|
127
|
+
The Cancel button on a publication row is offered only for a row that has not
|
|
128
|
+
started, and the server decides that, not React. `cms_cancel_page_publication`
|
|
129
|
+
locks the row and compares `publish_at` against the database clock: a row already
|
|
130
|
+
started is refused with a message that names Unpublish as the thing to use
|
|
131
|
+
instead, because cancelling it would take the page off the public website —
|
|
132
|
+
possibly putting an older version back — under a word that promises the opposite.
|
|
133
|
+
A row that is already inactive answers `already-cancelled` and writes nothing, so
|
|
134
|
+
a second click is not an error. A cancellation that goes through retires exactly
|
|
135
|
+
that row, audits it as `page.schedule.cancel`, and leaves what visitors can reach
|
|
136
|
+
untouched.
|
|
137
|
+
|
|
138
|
+
That check is against `now()` on the server, so a screen rendered a minute before
|
|
139
|
+
the boundary and clicked a minute after it is refused rather than obeyed. Live
|
|
140
|
+
rows do not offer the button at all; they carry the sentence *Live — unpublish
|
|
141
|
+
the page to take it down*.
|
|
142
|
+
|
|
143
|
+
`listPagesCore` and `getPageForEditCore` read those rows, so the derivation
|
|
144
|
+
happens once on the server rather than three times in three components. A
|
|
145
|
+
`page_publications` read that fails is an error, not an empty list — a page
|
|
146
|
+
silently reported as `Hidden` because a query failed is the same lie in the other
|
|
147
|
+
direction.
|
|
148
|
+
|
|
149
|
+
The second half of the rule — *that row's* `unpublishAt` — is why an older row
|
|
150
|
+
never comes back into force behind a newer one. Without it, "hide this at
|
|
151
|
+
midnight" would quietly mean "show the previous version again at midnight".
|
|
152
|
+
|
|
153
|
+
Boundaries are exact. A page is public **at** its `publish_at` and hidden **at**
|
|
154
|
+
its `unpublish_at`, to the millisecond, and
|
|
155
|
+
`tests/page-publication-window.test.ts` pins all five positions — before, at the
|
|
156
|
+
start, inside, at the end, after — through the pure rule, the page reader and
|
|
157
|
+
the sitemap alike.
|
|
158
|
+
|
|
159
|
+
Caches inherit the boundary rather than outliving it. `getPublishedPageCore`
|
|
160
|
+
returns `revalidateAt`, the next instant its answer can change; the server cache
|
|
161
|
+
expires an entry at that instant when it comes before the TTL, and the browser
|
|
162
|
+
query's `staleTime` is clamped the same way. A twenty-second cache can no longer
|
|
163
|
+
carry a 404 five seconds past the moment a page goes live.
|
|
164
|
+
|
|
165
|
+
### The migrations, and the order they go out in
|
|
166
|
+
|
|
167
|
+
Three files are new since the last published `0.90.0-m7.12` package, so a site
|
|
168
|
+
upgrading from that baseline is missing all three:
|
|
169
|
+
|
|
170
|
+
| File | Adds | Needed for |
|
|
171
|
+
| --- | --- | --- |
|
|
172
|
+
| `20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql` | restates the `draft` default, records the `pages.status` column semantics at that migration step, and adds two partial `pages` indexes: one on `key` for undeleted status-published rows, one on `status`/`updated_at` for undeleted rows | canonical schema parity |
|
|
173
|
+
| `20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql` | `page_publications`, the backfill, `cms_save_page_version`, `cms_set_page_status`, `cms_schedule_page_reveal` | contract-v2 publication behaviour |
|
|
174
|
+
| `20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql` | reveals and audit metadata inside `cms_save_page_version`, `cms_soft_delete_page`, `cms_restore_page`, `cms_cancel_page_publication`, `cms_schedule_page_version`, and two repair `UPDATE`s | contract-v2 publication behaviour |
|
|
175
|
+
|
|
176
|
+
The last two are the pair that establishes contract-v2 publication behaviour:
|
|
177
|
+
together they are what `PLATFORM_CONTRACT_VERSION = 2` means. A site declares the
|
|
178
|
+
contract it was built against and `assertPlatformContract` refuses the mismatch
|
|
179
|
+
at startup, rather than letting an editor discover it when a publish fails
|
|
180
|
+
halfway.
|
|
181
|
+
|
|
182
|
+
All three are listed in `SCHEMA_MIGRATIONS`. `compareSchema` names whichever of
|
|
183
|
+
them is missing and does not report in sync until every canonical migration has
|
|
184
|
+
been applied, so applying only the publication pair leaves the drift check
|
|
185
|
+
pointing at the first file.
|
|
186
|
+
|
|
187
|
+
Release order, and it is not interchangeable:
|
|
188
|
+
|
|
189
|
+
1. **Apply every missing canonical migration.** Upgrading from `0.90.0-m7.12`,
|
|
190
|
+
that is all three files above, in chronological order. They add a column
|
|
191
|
+
default, indexes and a comment, a table, functions and replaced function
|
|
192
|
+
bodies, and the running m7.12 package keeps working against them. They are
|
|
193
|
+
not, however, pure DDL: the second backfills an open publication for every
|
|
194
|
+
published page that has none, and the third runs two repair `UPDATE`s over
|
|
195
|
+
rows the old code left inconsistent — retiring publications still open on
|
|
196
|
+
pages already in the trash, and setting those pages' status back to `draft`.
|
|
197
|
+
Both are described below. Nothing is deleted and no page that is public
|
|
198
|
+
before the upgrade stops being public because of it.
|
|
199
|
+
2. **Deploy the contract-v2 package.** It calls the new signatures, which by then
|
|
200
|
+
exist.
|
|
201
|
+
3. **Verify both.** `compareSchema` reports in sync — no canonical migration
|
|
202
|
+
missing — and `assertPlatformContract(2)` returns. The second compares two
|
|
203
|
+
version numbers in the site's own code and never reads the database, so on
|
|
204
|
+
its own it says nothing about whether the migrations ran.
|
|
205
|
+
4. **Let editors back in.**
|
|
206
|
+
|
|
207
|
+
Deploying the package first gives an editor a publish that fails on a function
|
|
208
|
+
that is not there yet; that is the click-time failure this ordering exists to
|
|
209
|
+
avoid.
|
|
210
|
+
|
|
211
|
+
The `…140000_…` file backfills an open publication for every published page that
|
|
212
|
+
has none, so a site that was live before the upgrade is still live after it.
|
|
213
|
+
|
|
214
|
+
The `…160000_…` file repairs what the trash left behind, in two `UPDATE`s over
|
|
215
|
+
existing rows. A page deleted before this migration kept its open publication —
|
|
216
|
+
unreachable, because the reader skips deleted pages, but waiting for the restore
|
|
217
|
+
button to make it public again — so those rows are retired. And those same pages
|
|
218
|
+
often still said `published` in a list, a claim about the public website that
|
|
219
|
+
stopped being true when they were deleted, so their status goes back to `draft`.
|
|
220
|
+
Both statements touch only pages with `deleted_at` set: nothing a visitor can
|
|
221
|
+
reach today changes.
|
|
222
|
+
|
|
223
|
+
It deliberately skips a page that already has an active row: that row is a deliberate schedule —
|
|
224
|
+
a countdown reveal, a dated launch — and publishing it now is exactly the bug
|
|
225
|
+
being fixed. A published page whose window has already closed goes dark, because
|
|
226
|
+
that is what its editor asked for.
|
|
227
|
+
|
|
228
|
+
## Publishing is one transaction
|
|
229
|
+
|
|
230
|
+
Publishing is five statements: write the version, move `current_version_id`,
|
|
231
|
+
retire the old publication, open the new one, flip the status. A PostgREST
|
|
232
|
+
client cannot wrap them, and checking each response is not a fix — it reports the
|
|
233
|
+
failure honestly and leaves the site half published either way. An open
|
|
234
|
+
publication with the status never flipped made a page public that the editor was
|
|
235
|
+
told had not published; a moved current-version pointer with the replacement
|
|
236
|
+
publication missing put the next draft in front of visitors.
|
|
237
|
+
|
|
238
|
+
So the whole change lives in the database:
|
|
239
|
+
|
|
240
|
+
| Function | Called by |
|
|
241
|
+
| --- | --- |
|
|
242
|
+
| `cms_save_page_version` | `savePageVersionCore` |
|
|
243
|
+
| `cms_set_page_status` | `setPageStatusCore` |
|
|
244
|
+
| `cms_schedule_page_reveal` | `cms_save_page_version`, and the countdown path |
|
|
245
|
+
| `cms_soft_delete_page` | `deletePageCore` |
|
|
246
|
+
| `cms_restore_page` | `restorePageCore` |
|
|
247
|
+
|
|
248
|
+
Each is `SECURITY DEFINER` with the same staff check as every other page
|
|
249
|
+
mutation, and each writes its audit row inside the same transaction — a
|
|
250
|
+
publication nobody can account for afterwards is its own half-applied write.
|
|
251
|
+
|
|
252
|
+
Countdown reveals are inside that transaction too, and that is the second half of
|
|
253
|
+
the fix. Scheduling them afterwards meant a reveal that could not be scheduled —
|
|
254
|
+
a target renamed, or never written to — reported a failed publish to an editor
|
|
255
|
+
whose page was already live. `savePageVersionCore` now reads the reveal
|
|
256
|
+
instructions out of the blocks *before* it calls anything and passes them in as
|
|
257
|
+
`_reveals`; the source page, every reveal and every audit row commit together or
|
|
258
|
+
not at all. `_audit` carries the actor's own metadata into the `page.publish`
|
|
259
|
+
row written by the same statement.
|
|
260
|
+
|
|
261
|
+
There is deliberately **no JavaScript fallback**. Compensating for a failed step
|
|
262
|
+
with further un-transacted steps is precisely the thing that does not work.
|
|
263
|
+
|
|
264
|
+
`tests/page-publish-duplicate.test.ts` breaks each statement in turn and then
|
|
265
|
+
asks the public reader what it serves; the answer has to be exactly what it was
|
|
266
|
+
before the call. `tests/page-publish-atomicity.test.ts` does the same for the
|
|
267
|
+
reveals — the only schedule, the second of several, the publication audit — and
|
|
268
|
+
checks the database rows as well as the public answer, because "nothing visible
|
|
269
|
+
changed" and "nothing changed" are different claims.
|
|
270
|
+
|
|
271
|
+
## Deleting is not unpublishing, and restoring is not publishing
|
|
272
|
+
|
|
273
|
+
The trash used to leak in both directions. `deletePageCore` set `deleted_at` and
|
|
274
|
+
left the publication rows active; `restorePageCore` cleared `deleted_at` and
|
|
275
|
+
those rows came back into force. Publish, trash, restore, and the page was on the
|
|
276
|
+
internet again — a publishing action nobody confirmed, from a button labelled
|
|
277
|
+
Restore.
|
|
278
|
+
|
|
279
|
+
Both halves are now one statement each:
|
|
280
|
+
|
|
281
|
+
- **`cms_soft_delete_page`** retires every active publication, sets `deleted_at`
|
|
282
|
+
and sets `status` to `draft`, in one transaction. A page in the trash is off
|
|
283
|
+
the internet by the same rule that governs every other page, not by a second
|
|
284
|
+
rule that happens to agree.
|
|
285
|
+
- **`cms_restore_page`** clears `deleted_at` and leaves the page a hidden draft.
|
|
286
|
+
It never inserts a publication and never reactivates one — including the rows a
|
|
287
|
+
page deleted before this migration still carries, which it retires on the way
|
|
288
|
+
through. A restored page becomes public when somebody presses Publish.
|
|
289
|
+
|
|
290
|
+
`tests/page-trash-restore.test.ts` walks it: publish, delete, restore,
|
|
291
|
+
`getPublishedPageCore` answers `null`, `listPublishedPageKeysCore` omits the key,
|
|
292
|
+
and only an explicit publish puts it back.
|
|
293
|
+
|
|
294
|
+
## Granting EXECUTE on a self-hosted server
|
|
295
|
+
|
|
296
|
+
The functions are `SECURITY DEFINER`, so PUBLIC is revoked unconditionally — on
|
|
297
|
+
every server, with or without the hosted roles. `anon`, `authenticated` and
|
|
298
|
+
`service_role` are PostgREST deployment conventions; naming a role that does not
|
|
299
|
+
exist is an error rather than a warning, so those grants run only when the role
|
|
300
|
+
is there. Existing hosted installations are unaffected: `authenticated` and
|
|
301
|
+
`service_role` keep EXECUTE, `anon` gets nothing.
|
|
302
|
+
|
|
303
|
+
A self-hosted installation grants its own application role, in a migration of its
|
|
304
|
+
own. Every statement, in full:
|
|
305
|
+
|
|
306
|
+
```sql
|
|
307
|
+
GRANT EXECUTE ON FUNCTION
|
|
308
|
+
public.cms_save_page_version(uuid, jsonb, text, boolean, text, jsonb, jsonb) TO cms_app;
|
|
309
|
+
GRANT EXECUTE ON FUNCTION public.cms_set_page_status(uuid, text, jsonb, text) TO cms_app;
|
|
310
|
+
GRANT EXECUTE ON FUNCTION
|
|
311
|
+
public.cms_schedule_page_reveal(text, timestamptz, jsonb, text, uuid, timestamptz) TO cms_app;
|
|
312
|
+
GRANT EXECUTE ON FUNCTION
|
|
313
|
+
public.cms_schedule_page_version(uuid, timestamptz, timestamptz, text, jsonb, jsonb, text) TO cms_app;
|
|
314
|
+
GRANT EXECUTE ON FUNCTION public.cms_cancel_page_publication(uuid, jsonb, text) TO cms_app;
|
|
315
|
+
GRANT EXECUTE ON FUNCTION public.cms_soft_delete_page(uuid, jsonb, text) TO cms_app;
|
|
316
|
+
GRANT EXECUTE ON FUNCTION public.cms_restore_page(uuid, jsonb, text) TO cms_app;
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
`cms_actor_email` is deliberately absent. It is an internal helper the writers
|
|
320
|
+
call for themselves, and nothing outside them needs it.
|
|
321
|
+
|
|
322
|
+
## Proving it against a real server
|
|
323
|
+
|
|
324
|
+
The unit tests run all of this against a fake PostgREST client. That proves the
|
|
325
|
+
TypeScript, not the SQL: whether the functions parse, keep the signatures the
|
|
326
|
+
client calls, grant legally on a server without the hosted roles, and really do
|
|
327
|
+
roll a half-finished publish back. Only a server answers those.
|
|
328
|
+
|
|
329
|
+
```bash
|
|
330
|
+
bun run test:migrations
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
It starts a disposable `postgres:16-alpine` container, applies every migration to
|
|
334
|
+
two databases and makes 103 assertions:
|
|
335
|
+
|
|
336
|
+
- **neutral** — the hosted roles are dropped from the cluster *before* the two
|
|
337
|
+
publication migrations run. Both apply; PUBLIC can execute none of the seven
|
|
338
|
+
writers; a freshly created application role granted by the statements above
|
|
339
|
+
can execute all seven, and still not `cms_actor_email`.
|
|
340
|
+
- **legacy** — every migration in order on a database that has the hosted roles.
|
|
341
|
+
Signatures are checked against what the client calls, `authenticated` and
|
|
342
|
+
`service_role` keep EXECUTE, `anon` has none.
|
|
343
|
+
- **behaviour** — a publish with a reveal; then a trigger that refuses the
|
|
344
|
+
publication audit, and another that refuses the reveal *after* the source page
|
|
345
|
+
has been published, each of which has to leave the database exactly as it was;
|
|
346
|
+
then delete, restore, and the explicit publish that finally puts the page back.
|
|
347
|
+
- **guards** — the refusals, which are worth as little as the server that
|
|
348
|
+
enforces them: restoring a page that is not in the trash; a countdown aimed at
|
|
349
|
+
the page carrying it, at a page already public, or at a deadline that has
|
|
350
|
+
already passed; several reveals of which one is impossible; cancelling a
|
|
351
|
+
scheduled publication, and being refused when it has already started; and the
|
|
352
|
+
times `cms_schedule_page_version` will and will not accept. Each refusal is
|
|
353
|
+
followed by a check that the publication rows, the pages and the sitemap are
|
|
354
|
+
byte-for-byte what they were before it.
|
|
355
|
+
|
|
356
|
+
`ops/migration-smoke/prelude.sql` supplies the `auth` and `storage` stubs a plain
|
|
357
|
+
server does not have. It is not shipped and is not a migration — it exists so the
|
|
358
|
+
real migrations can be run against something.
|
|
359
|
+
|
|
360
|
+
### Pointing it at a server of your own
|
|
361
|
+
|
|
362
|
+
```bash
|
|
363
|
+
CMS_SMOKE_ALLOW_DESTRUCTIVE=1 CMS_SMOKE_DSN=postgres://… bun run test:migrations
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
`CMS_SMOKE_DSN` skips Docker, which is what CI's `migrations` job does with a
|
|
367
|
+
`postgres:16` service. **It requires a disposable PostgreSQL cluster.** The
|
|
368
|
+
runner creates and drops databases, and it drops and recreates the cluster-wide
|
|
369
|
+
roles `anon`, `authenticated` and `service_role` — by those names, because the
|
|
370
|
+
migrations grant to them by those names, so they cannot be renamed per run.
|
|
371
|
+
Against a shared or production server that is an outage, which is why a DSN
|
|
372
|
+
without `CMS_SMOKE_ALLOW_DESTRUCTIVE=1` refuses to run at all rather than
|
|
373
|
+
starting and stopping halfway. The container form needs no acknowledgement: it
|
|
374
|
+
creates the server it destroys.
|
|
375
|
+
|
|
376
|
+
Everything else the run names for itself carries a random suffix —
|
|
377
|
+
`cms_smoke_neutral_…`, `cms_smoke_legacy_…`, `cms_smoke_app_…` — so a crashed run
|
|
378
|
+
leaves debris that is recognisably this script's, and the next run clears it
|
|
379
|
+
before it starts. Only that naming is ever touched. `CMS_SMOKE_KEEP=1` keeps
|
|
380
|
+
them, and the container, around to poke at.
|
|
381
|
+
|
|
382
|
+
The job is deliberately not part of `bun run check`, which must keep running with
|
|
383
|
+
nothing but Bun installed.
|
|
384
|
+
|
|
385
|
+
## Publishing is a confirmed action
|
|
386
|
+
|
|
387
|
+
One click never publishes, from either screen that can.
|
|
388
|
+
|
|
389
|
+
The Pages list opens a dialog whose text is fixed in `src/cms/page-status.ts` so
|
|
390
|
+
it cannot drift between screens:
|
|
391
|
+
|
|
392
|
+
```ts
|
|
393
|
+
PUBLISH_CONFIRMATION // "This will make the page visible to everyone on the internet…"
|
|
394
|
+
UNPUBLISH_CONFIRMATION // "Visitors will no longer be able to reach this page…"
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
The dialog names the page and its address (`About us — /about`), so the last thing
|
|
398
|
+
an editor reads before publishing is *which* page they are publishing. Escape and
|
|
399
|
+
Cancel change nothing; the tests assert the mutation was **not called**, rather
|
|
400
|
+
than that a dialog closed.
|
|
401
|
+
|
|
402
|
+
`ConfirmDialog` handles the accessibility: `role="dialog"`, `aria-modal`,
|
|
403
|
+
`aria-labelledby` on the heading, `aria-describedby` on the confirmation text,
|
|
404
|
+
initial focus on the confirm button, Tab trapped inside, focus restored on close.
|
|
405
|
+
|
|
406
|
+
The page builder's **Publish now** sits in the same button row as **Save draft**
|
|
407
|
+
and is the other way a page goes live, so it confirms too — with the same native
|
|
408
|
+
`confirm()` its neighbours in that screen already use for cancelling a scheduled
|
|
409
|
+
publication and restoring a version. It names the page. Declining calls nothing;
|
|
410
|
+
the test asserts `save` was not invoked, not that a dialog closed.
|
|
411
|
+
|
|
412
|
+
Unpublish is styled `bg-red-600`. Publish is not. The destructive-looking one is
|
|
413
|
+
the one that takes something away from visitors.
|
|
414
|
+
|
|
415
|
+
## Duplication
|
|
416
|
+
|
|
417
|
+
Duplicating asks for a name first, because the copy is going to sit next to the
|
|
418
|
+
original in a list and "About us (copy)" is only useful for about a minute.
|
|
419
|
+
|
|
420
|
+
- The address is derived from the name (`slugifyPageKey`) and made unique
|
|
421
|
+
(`uniquePageKey`), so `about-us` becomes `about-us-2`.
|
|
422
|
+
- Once the editor edits the address themselves, it **stops** following the name.
|
|
423
|
+
A field that keeps overwriting what you typed is worse than no suggestion.
|
|
424
|
+
- The copy is a deep copy with fresh block ids throughout
|
|
425
|
+
(`duplicateBlockTree`), so editing the copy cannot reach into the original.
|
|
426
|
+
- **No canonical URL is duplicated.** The copy is built from a deliberately
|
|
427
|
+
narrow `select` — key, name and the current version's blocks — so canonical
|
|
428
|
+
URLs, slugs and meta titles are never carried across. They are per-page facts,
|
|
429
|
+
and two pages claiming the same canonical is a silent search-ranking bug.
|
|
430
|
+
- `is_system` is `false` on the copy, whatever the original was. A duplicate of
|
|
431
|
+
a system page is ordinary content.
|
|
432
|
+
- The copy is a draft. Always.
|
|
433
|
+
|
|
434
|
+
The dialog states both of those last two facts in words — "the copy is a draft"
|
|
435
|
+
and "the original is not changed" — because that is the question an editor
|
|
436
|
+
actually has before pressing the button.
|
|
437
|
+
|
|
438
|
+
## Host wiring
|
|
439
|
+
|
|
440
|
+
Both actions are **optional** on the host contract:
|
|
441
|
+
|
|
442
|
+
```ts
|
|
443
|
+
pages: { fns: {
|
|
444
|
+
setPageStatus?: HostServerFn;
|
|
445
|
+
duplicatePage?: HostServerFn;
|
|
446
|
+
} }
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
A host that has not registered them keeps working — the Pages list hides the two
|
|
450
|
+
actions rather than offering a button that throws. Authorization is the existing
|
|
451
|
+
one: both go through the same staff guard as every other page mutation, and
|
|
452
|
+
`tests/page-publish-duplicate.test.ts` pins a non-staff caller being refused.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Scheduled visibility
|
|
2
|
+
|
|
3
|
+
One question — *should this be on screen right now?* — is answered in one place,
|
|
4
|
+
`src/cms/visibility-schedule.ts`. Menu items, footer columns and footer links all
|
|
5
|
+
call the same function with the same clock. Nothing re-implements the comparison,
|
|
6
|
+
because two implementations of "is it visible yet" drift, and the drift is
|
|
7
|
+
invisible until a customer sees a menu entry that the footer says is gone.
|
|
8
|
+
|
|
9
|
+
## The shape
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
type VisibilitySchedule = {
|
|
13
|
+
enabled?: boolean; // the manual switch; absent means shown
|
|
14
|
+
publishAt?: string | null; // ISO-8601 UTC instant, or absent
|
|
15
|
+
unpublishAt?: string | null; // ISO-8601 UTC instant, or absent
|
|
16
|
+
};
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
An item with no schedule at all is visible. That is the important default: this
|
|
20
|
+
feature was added to a site that already had menus, and none of those rows have
|
|
21
|
+
a `schedule` key. `isVisibleAt(undefined, now)` is `true`, so nothing disappeared
|
|
22
|
+
on upgrade.
|
|
23
|
+
|
|
24
|
+
`normalizeSchedule` **omits** keys rather than writing `null` for them, so a
|
|
25
|
+
schedule that has never been given a date round-trips through JSONB as `{}`
|
|
26
|
+
instead of accumulating null fields on every save.
|
|
27
|
+
|
|
28
|
+
## Storage is UTC. Display is the site's timezone.
|
|
29
|
+
|
|
30
|
+
The stored value is always a UTC instant. The editor never sees one.
|
|
31
|
+
|
|
32
|
+
`ScheduleField` (`src/backoffice/ScheduleFields.tsx`) is the only place the
|
|
33
|
+
conversion happens, in both directions:
|
|
34
|
+
|
|
35
|
+
| Direction | Function | Example |
|
|
36
|
+
| --- | --- | --- |
|
|
37
|
+
| Editor types → stored | `localInputToIso` | `2026-09-10T18:00` in `Europe/Stockholm` → `2026-09-10T16:00:00.000Z` |
|
|
38
|
+
| Stored → editor sees | `isoToLocalInput` | the same instant back to `2026-09-10T18:00` |
|
|
39
|
+
|
|
40
|
+
The zone comes from `siteTimezone()` (`src/config/site-config.ts`), not from the
|
|
41
|
+
administrator's laptop. An editor in Madrid scheduling a Swedish site's banner
|
|
42
|
+
gets Swedish opening time, which is what they meant.
|
|
43
|
+
|
|
44
|
+
`src/cms/timezone.ts` does the offset arithmetic with `Intl.DateTimeFormat` —
|
|
45
|
+
no date library was added. `zoneAbbreviation` puts `CEST` next to the fields so
|
|
46
|
+
the editor can see which clock they are on.
|
|
47
|
+
|
|
48
|
+
## The one rule that matters
|
|
49
|
+
|
|
50
|
+
**Do not call `Date.now()` inside a render path that shows scheduled content.**
|
|
51
|
+
|
|
52
|
+
`renderableNavigation(items, locale, now)` takes the instant as a parameter. So
|
|
53
|
+
does `visibleItems`, `isVisibleAt` and `visibilityStateAt`. This is not test
|
|
54
|
+
convenience — it is what makes a boundary *reachable*. A component that reads the
|
|
55
|
+
clock itself renders once, caches, and then shows yesterday's menu until
|
|
56
|
+
something unrelated re-renders it.
|
|
57
|
+
|
|
58
|
+
`useScheduleClock` (`src/cms/use-schedule-clock.ts`) closes that loop: give it the
|
|
59
|
+
schedules on screen and it re-renders at the next boundary and no sooner. Not a
|
|
60
|
+
one-second interval — `nextScheduleChangeAcross` computes the single next instant
|
|
61
|
+
anything changes, and a page with no schedules never sets a timer at all.
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
const now = useScheduleClock(navigationSchedules(items));
|
|
65
|
+
const visible = renderableNavigation(items, locale, now);
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## What an editor sees
|
|
69
|
+
|
|
70
|
+
`VisibilityBadge` renders six states, each with a word and an icon shape as well
|
|
71
|
+
as a colour: **Visible**, **Hidden**, **Scheduled to publish**, **Scheduled to
|
|
72
|
+
hide**, **Expired**, **Needs attention**. Status is never colour alone.
|
|
73
|
+
|
|
74
|
+
Two kinds of nonsense are refused rather than stored.
|
|
75
|
+
|
|
76
|
+
`scheduleProblems` names a range that ends before it starts. `zonedLocalExists`
|
|
77
|
+
catches the other one: a wall-clock time the site's timezone never reaches.
|
|
78
|
+
Typing `2026-03-29T02:30` for a Stockholm site would resolve to an instant an
|
|
79
|
+
hour from what was typed, because the clocks jump 02:00 → 03:00 that night, so
|
|
80
|
+
`ScheduleField` keeps the field's previous value and says why instead. In both
|
|
81
|
+
cases the offending field takes `aria-invalid` and the message is tied to it
|
|
82
|
+
with `aria-describedby`.
|
|
83
|
+
|
|
84
|
+
Autumn's *repeated* hour is deliberately allowed. `01:30` happens twice, so both
|
|
85
|
+
readings are real instants and taking the first is a defensible answer; the
|
|
86
|
+
spring gap has no reading at all.
|
|
87
|
+
|
|
88
|
+
`ScheduleField` is a controlled input, though, and the Save button belongs to
|
|
89
|
+
whichever screen embeds it. So the refusal cannot end at the field — two places
|
|
90
|
+
make it binding:
|
|
91
|
+
|
|
92
|
+
- `saveContentSectionCore` runs `scheduleProblemsIn` over the whole content
|
|
93
|
+
payload before writing and rejects the save, naming the path of the first bad
|
|
94
|
+
schedule (`columns[0].links[2].schedule`). Menus, footers and shared sections
|
|
95
|
+
all save through it, so a host cannot store an invalid schedule by forgetting
|
|
96
|
+
to wire the field's validity into its own button.
|
|
97
|
+
- The page builder checks the schedule panel's own fields before it saves
|
|
98
|
+
anything, because that button is the platform's.
|
|
99
|
+
|
|
100
|
+
The page builder reads those fields in `siteTimezone()` too, not in the
|
|
101
|
+
browser's zone — an editor on a laptop still set to New York schedules the same
|
|
102
|
+
instant as a colleague at home.
|
|
103
|
+
|
|
104
|
+
## Pages schedule through publications, not through this
|
|
105
|
+
|
|
106
|
+
Everything above is about *items inside a page* — menu entries, footer links,
|
|
107
|
+
sections. A whole page's visibility is a different mechanism with its own rows:
|
|
108
|
+
`page_publications`, one rule in `src/cms/page-publication.ts`, described in
|
|
109
|
+
[`PAGES.md`](PAGES.md).
|
|
110
|
+
|
|
111
|
+
They are deliberately not merged. A page's schedule has to be readable by the
|
|
112
|
+
sitemap and by the public route without loading the page's content first, and it
|
|
113
|
+
has to survive a version being replaced underneath it. A JSONB `schedule` key on
|
|
114
|
+
a content row answers neither.
|
|
115
|
+
|
|
116
|
+
What they share is the vocabulary. `publishAt` and `unpublishAt` mean the same
|
|
117
|
+
thing on both sides, both store UTC instants and display the site's timezone,
|
|
118
|
+
and both are closed **at** `unpublishAt` rather than a tick later. An editor
|
|
119
|
+
unpublishing a page still gets it gone now: `setPageStatusCore` closes every open
|
|
120
|
+
window as well as setting the status, so a page cannot stay live through a
|
|
121
|
+
schedule somebody forgot about. Moving a page to the trash closes them too, and
|
|
122
|
+
restoring it does not reopen them — see
|
|
123
|
+
[Deleting is not unpublishing](PAGES.md#deleting-is-not-unpublishing-and-restoring-is-not-publishing).
|
|
124
|
+
|
|
125
|
+
They also share the five words for what a schedule is currently doing. A page's
|
|
126
|
+
badge comes from `publicationStateAt` — Hidden, Scheduled to publish, Published,
|
|
127
|
+
Scheduled to hide, Hidden after expiry — for the same reason an item's comes from
|
|
128
|
+
`visibilityStateAt`: "draft" and "published" cannot say which side of a window
|
|
129
|
+
something is on, and that is the only thing an editor looking at a schedule wants
|
|
130
|
+
to know.
|