@bygghammar/cms-platform 0.90.0-m7.8 → 0.91.0-m7.1
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 +297 -0
- package/README.md +39 -3
- package/docs/EDIT-LOCKS.md +213 -0
- 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/20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql +274 -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 +11 -1
- package/scripts/create-cms-site.mjs +5 -4
- package/scripts/sync-migrations.mjs +27 -6
- package/src/backoffice/ContactDetailsView.tsx +19 -5
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/EditLock.tsx +513 -0
- package/src/backoffice/EmailSettingsView.tsx +99 -0
- package/src/backoffice/FormEditorView.tsx +541 -512
- package/src/backoffice/FormSettingsView.tsx +24 -4
- package/src/backoffice/GlobalSectionBuilderView.tsx +42 -11
- package/src/backoffice/MediaLibraryView.tsx +239 -21
- package/src/backoffice/NavigationView.tsx +78 -11
- package/src/backoffice/PageBuilderView.tsx +243 -51
- package/src/backoffice/PageEditorView.tsx +293 -57
- package/src/backoffice/PagesListView.tsx +294 -19
- package/src/backoffice/RichTextEditor.tsx +50 -14
- package/src/backoffice/ScheduleFields.tsx +151 -0
- package/src/backoffice/SectionEditorView.tsx +18 -6
- package/src/backoffice/SectionFormView.tsx +242 -31
- 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/forms/VersionHistory.tsx +4 -2
- package/src/backoffice/inspectors/blocks.tsx +171 -1
- package/src/backoffice/inspectors/fields.tsx +102 -0
- package/src/backoffice/inspectors/index.tsx +3 -0
- package/src/backoffice/media/ConfirmDialog.tsx +64 -4
- package/src/backoffice/routes/host.ts +34 -0
- package/src/backoffice/routes/index.tsx +141 -15
- package/src/backoffice/routes/media-forms.tsx +5 -0
- package/src/backoffice/routes/pages.tsx +10 -6
- package/src/backoffice/seo/PageSeoPanelView.tsx +245 -202
- 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/CtaBannerBlock.tsx +18 -12
- package/src/blocks/components/HeroFullBlock.tsx +25 -12
- package/src/blocks/components/IconGridBlock.tsx +76 -51
- package/src/blocks/components/LayoutBlock.tsx +100 -0
- package/src/blocks/components/MediaSplitBlock.tsx +91 -41
- package/src/blocks/components/PriceTableBlock.tsx +123 -89
- package/src/blocks/components/RichTextBlock.tsx +26 -4
- package/src/blocks/countdown-time.ts +17 -87
- package/src/blocks/index.ts +8 -0
- package/src/blocks/optional-content.ts +82 -0
- package/src/blocks/registry.tsx +22 -0
- package/src/blocks/types.ts +46 -0
- package/src/cms/edit-lock.ts +491 -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/host/index.tsx +6 -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 +61 -0
- package/src/platform/contract.ts +52 -1
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +38 -5
- package/src/server/domains-core.ts +124 -107
- package/src/server/edit-lock-adapter.ts +91 -0
- package/src/server/edit-lock-supabase-adapter.ts +135 -0
- package/src/server/edit-locks-core.ts +286 -0
- package/src/server/forms-core.ts +37 -2
- 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/page-seo-save-core.ts +27 -0
- package/src/server/pages-core.ts +788 -195
- package/src/server/provider-secrets.ts +100 -4
- 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 +89 -9
- package/src/site/PageRenderer.tsx +45 -12
- package/src/site/SiteFooter.tsx +44 -3
- package/src/site/SiteHeader.tsx +32 -5
- 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/src/ui/Reveal.tsx +74 -9
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,303 @@ matches `package.json` exactly. See
|
|
|
8
8
|
Versions 0.1.0 through 0.78.0 predate this repository and were reconstructed
|
|
9
9
|
from the published npm tarballs.
|
|
10
10
|
|
|
11
|
+
## 0.91.0-m7.1
|
|
12
|
+
|
|
13
|
+
**`PLATFORM_CONTRACT_VERSION` is 2.** This release changes what a host's
|
|
14
|
+
database must provide, and a breaking contract change takes a minor bump while
|
|
15
|
+
this package is pre-1.0, so the minor moves and the milestone counter restarts.
|
|
16
|
+
The channel is unchanged: this publishes to the `m7` dist-tag and does not move
|
|
17
|
+
`latest`.
|
|
18
|
+
|
|
19
|
+
- `page_publications` alone decides what the public website serves. A page with
|
|
20
|
+
no open publication row cannot be reached, however well somebody knows its
|
|
21
|
+
URL, and the public reader answers from those rows.
|
|
22
|
+
- `pages.status` is separate editorial metadata. It does not grant or withhold
|
|
23
|
+
public access, and it is not a mirror of it: a page whose status is `draft`
|
|
24
|
+
can intentionally be public for the length of a scheduled publication window,
|
|
25
|
+
because the row says so and the status has no vote.
|
|
26
|
+
- Publishing is an explicit, confirmed act. Publish and unpublish both ask
|
|
27
|
+
first, and the confirmation follows the transition rather than the state, so
|
|
28
|
+
publishing over a queued update says that it cancels that update and
|
|
29
|
+
unpublishing over a schedule says that it cancels the schedule. New and
|
|
30
|
+
duplicated pages start as drafts.
|
|
31
|
+
- Scheduling is real, and the two schedulable things have different shapes. A
|
|
32
|
+
page version has a required start and an optional end, and the transition is
|
|
33
|
+
applied inside the same statement that records it. Menu items, footer columns
|
|
34
|
+
and footer links support independent show and hide boundaries through one
|
|
35
|
+
`isVisibleAt`: either bound may be left empty, and an item with neither is
|
|
36
|
+
simply live. Instants are stored as UTC; editors type wall-clock time in the
|
|
37
|
+
site's timezone.
|
|
38
|
+
- Cancelling a schedule is no longer a way to unpublish. The server refuses to
|
|
39
|
+
cancel a publication row that has already started and names Unpublish as the
|
|
40
|
+
thing to use instead, because cancelling a started row would take the page off
|
|
41
|
+
the public website — possibly restoring an older version — under a word that
|
|
42
|
+
promises the opposite. A row that is already inactive answers
|
|
43
|
+
`already-cancelled` and writes nothing.
|
|
44
|
+
- The trash cannot leave a page published. Soft-deleting retires the
|
|
45
|
+
publication rows that made it public, and restoring puts a page back as a
|
|
46
|
+
draft instead of silently republishing whatever was live when it was deleted.
|
|
47
|
+
Restore now refuses a page that is not in the trash rather than retiring its
|
|
48
|
+
publications.
|
|
49
|
+
- A screen left open crosses a publication boundary in front of the editor.
|
|
50
|
+
Both the builder and the pages list re-read themselves when the next
|
|
51
|
+
scheduled transition falls due, through one hook that owns a chain of timers.
|
|
52
|
+
A boundary that has passed is asked about once and then on a bounded, growing
|
|
53
|
+
wait — thirty seconds doubling to five minutes — so a slow or failing read
|
|
54
|
+
cannot turn into a zero-delay refetch loop; what restores the ordinary timer
|
|
55
|
+
is a new `publication_at` arriving in fresh data.
|
|
56
|
+
- Layout blocks are a real nested block model. A layout block holds two to
|
|
57
|
+
eight columns that fold through tablet to a single column on mobile, and an
|
|
58
|
+
illegal move is refused by a typed `MoveResult` instead of being silently
|
|
59
|
+
dropped.
|
|
60
|
+
- Icon cards become a scroll-snap carousel on mobile only; desktop is
|
|
61
|
+
unchanged. It honours `prefers-reduced-motion`, is keyboard-navigable, and
|
|
62
|
+
adds no dependency.
|
|
63
|
+
- Uploads land in dated media folders. No existing file moves and no existing
|
|
64
|
+
URL changes. A custom folder name is sanitised, or refused outright rather
|
|
65
|
+
than allowed to escape its prefix.
|
|
66
|
+
- Google Sheets delivery moves to a service account, so the spreadsheet stays
|
|
67
|
+
private and no credential reaches the browser. A submission is stored before
|
|
68
|
+
delivery is attempted, so a Sheets outage cannot lose one, and "Test
|
|
69
|
+
connection" classifies each failure instead of reporting an unproven write as
|
|
70
|
+
success — it never writes a row.
|
|
71
|
+
- Menu and footer each pick a Google font independently, from a static
|
|
72
|
+
catalogue, offering only the weights a family actually has. No API key is
|
|
73
|
+
needed in the browser to show the picker, and no font is selected for an
|
|
74
|
+
existing site by this release.
|
|
75
|
+
- New documentation: `docs/PAGES.md`, `docs/SCHEDULING.md`,
|
|
76
|
+
`docs/LAYOUT-BLOCKS.md`, `docs/MEDIA-FOLDERS.md`, `docs/GOOGLE-SHEETS.md` and
|
|
77
|
+
`docs/TYPOGRAPHY.md`.
|
|
78
|
+
|
|
79
|
+
**All three new migrations must be applied, in chronological order.** The latter
|
|
80
|
+
two are the contract-v2 publication migrations.
|
|
81
|
+
|
|
82
|
+
- `20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql` — additive only:
|
|
83
|
+
restates the `pages.status` default, comments the column, adds two partial
|
|
84
|
+
indexes.
|
|
85
|
+
- `20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql` — backfills one open
|
|
86
|
+
publication row for every published, undeleted page that has no active row, so
|
|
87
|
+
a site live before the upgrade is live after it, and installs the functions
|
|
88
|
+
that own visibility.
|
|
89
|
+
- `20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql` — adds the trash and
|
|
90
|
+
cancellation functions, and runs two repair `UPDATE`s confined to pages
|
|
91
|
+
already in the trash.
|
|
92
|
+
|
|
93
|
+
All three are registered in `SCHEMA_MIGRATIONS`, so `compareSchema` reports any
|
|
94
|
+
one of them as missing — the additive first one included. Skipping it because it
|
|
95
|
+
changes no behaviour still leaves a site reporting drift.
|
|
96
|
+
|
|
97
|
+
**Deployment must enforce both checks before editors are enabled.**
|
|
98
|
+
`compareSchema(applied)` is what names a missing migration file, and it needs
|
|
99
|
+
the host to hand it the migrations actually applied. `assertPlatformContract(2)`
|
|
100
|
+
compares version numbers and never looks at the database. Neither stands in for
|
|
101
|
+
the other: run both, and let editors back in only once both pass.
|
|
102
|
+
|
|
103
|
+
## 0.90.0-m7.12
|
|
104
|
+
|
|
105
|
+
- The page SEO panel no longer prints a bare page path under the Canonical
|
|
106
|
+
heading. For a locale with no configured public domain there is no canonical
|
|
107
|
+
URL, and a path shown there reads as an address on whatever hostname the
|
|
108
|
+
backoffice happens to be open at.
|
|
109
|
+
- That case now says so in words — *No public URL — no domain configured for
|
|
110
|
+
this language* — instead of a relative path, the current or backoffice
|
|
111
|
+
hostname, another locale's domain, or a fabricated absolute URL.
|
|
112
|
+
- The Route row is unchanged and still shows the page path alongside the
|
|
113
|
+
current registry route, so nothing that was accurate is lost.
|
|
114
|
+
- A mapped locale is unaffected: its canonical preview stays absolute on that
|
|
115
|
+
locale's own domain. So is single-origin mode, where the one hostname serving
|
|
116
|
+
every language answers for each of them.
|
|
117
|
+
- `pageSeoPreviewCanonical` keeps its signature and its return value, path
|
|
118
|
+
fallback included, so existing callers are unaffected; what an empty origin
|
|
119
|
+
means is decided by `PageSeoPanelView`, which owns the display. The helper's
|
|
120
|
+
documentation now records that the fallback is not a URL.
|
|
121
|
+
- Regression coverage renders the panel and asserts the Canonical row itself
|
|
122
|
+
for the mapped, single-origin and unmapped cases.
|
|
123
|
+
- No migration and no database change.
|
|
124
|
+
|
|
125
|
+
## 0.90.0-m7.11
|
|
126
|
+
|
|
127
|
+
- The page builder no longer invents a public URL for a locale with no
|
|
128
|
+
configured public domain. An empty `publicHost(locale)` means no public URL
|
|
129
|
+
exists, and is no longer read as a relative one.
|
|
130
|
+
- For an unmapped locale the page path is shown as plain, non-clickable
|
|
131
|
+
information, saying that no domain is configured for that language. The
|
|
132
|
+
public-link anchor and the Copy URL action are omitted rather than pointed
|
|
133
|
+
somewhere else: no relative href, no backoffice or request hostname, no other
|
|
134
|
+
locale's domain.
|
|
135
|
+
- Draft Preview keeps working for an unmapped locale. Previewing is a
|
|
136
|
+
backoffice operation and has never needed a public hostname.
|
|
137
|
+
- The `publicHost`, `publicPath` and `usePageUrl` host contracts document this,
|
|
138
|
+
so a host knows what the empty string means before it implements one.
|
|
139
|
+
- No migration and no database change. Nothing in this release concerns a
|
|
140
|
+
host's own integration code.
|
|
141
|
+
|
|
142
|
+
## 0.90.0-m7.10
|
|
143
|
+
|
|
144
|
+
- Code & tracking drafts survive a refetch. A background reload replaced the
|
|
145
|
+
table under the administrator's hands: a brand new profile, a duplicate made
|
|
146
|
+
from an existing one, and edits typed but not yet saved all disappeared
|
|
147
|
+
without a word. What the server sends is merged into what is on screen
|
|
148
|
+
instead of replacing it.
|
|
149
|
+
- Tracking profiles apply in global, then language, then domain order, so a
|
|
150
|
+
domain profile refines the language one rather than replacing it. Hostname
|
|
151
|
+
matching is consistent throughout: an apex and its single `www.` are the same
|
|
152
|
+
site everywhere the decision is made.
|
|
153
|
+
- GTM, GA4, Meta Pixel, TikTok Pixel, verification meta tags and raw
|
|
154
|
+
head / body-start / body-end code all reach the server-rendered HTML at the
|
|
155
|
+
position each one requires, rather than depending on a client-side step to
|
|
156
|
+
fire.
|
|
157
|
+
- Raw custom code is injected byte for byte. `$&`, `` $` ``, `$'` and `$$` in a
|
|
158
|
+
snippet were read as replacement patterns and rewrote the markup around them;
|
|
159
|
+
a profile now reaches the page exactly as it was typed.
|
|
160
|
+
- Tracking is excluded from the configured backoffice path, preview routes, API
|
|
161
|
+
routes and server-function routes, so pixels no longer fire on administrative
|
|
162
|
+
traffic or on responses that are not pages.
|
|
163
|
+
- A tracking read that fails leaves the public site standing. The page renders
|
|
164
|
+
without injection, the failure is reported through an injectable logger, and
|
|
165
|
+
it is not cached — the next request retries instead of serving a fabricated
|
|
166
|
+
empty configuration for the rest of the cache window. A backend that answers
|
|
167
|
+
with an error rather than throwing counts as a failure; treating it as "no
|
|
168
|
+
profiles" made a broken deployment indistinguishable from an unconfigured one.
|
|
169
|
+
- The tracking cache is bounded rather than growing one entry per hostname
|
|
170
|
+
seen, and a successful create, update or delete drops it, so a change goes
|
|
171
|
+
live within the TTL instead of sitting behind whatever was cached first.
|
|
172
|
+
- A failed audit entry or a failed log line cannot undo a write that already
|
|
173
|
+
happened. Both are best-effort in both directions: the mutation stands and is
|
|
174
|
+
reported as the success it was, rather than coming back as an error whose
|
|
175
|
+
obvious retry inserts a second profile. That now holds even when the
|
|
176
|
+
host-supplied logger is itself what throws.
|
|
177
|
+
- A newly created profile whose id the server cannot establish is held until the
|
|
178
|
+
page is reloaded. The insert may well have landed, so pressing Save again
|
|
179
|
+
would create a duplicate firing every pixel twice; the screen says to reload,
|
|
180
|
+
and saving existing profiles is unaffected.
|
|
181
|
+
- Languages & domains has moved out of the main sidebar into System
|
|
182
|
+
administration, alongside the other installation-level screens.
|
|
183
|
+
- Code & tracking and Languages & domains require the `admin` role, matching the
|
|
184
|
+
authorization their server functions already enforce. Staff without it get a
|
|
185
|
+
plain no-access screen instead of a form that fails on Save.
|
|
186
|
+
- Storing a hostname and matching a request against one are now separate
|
|
187
|
+
questions. Canonical output carries the exact hostname the deployment
|
|
188
|
+
configured; matching still folds an apex and one `www.` together. A page is no
|
|
189
|
+
longer canonical on two of its own addresses.
|
|
190
|
+
- Canonical URLs, `og:url`, hreflang, `og:locale:alternate` and both sitemaps
|
|
191
|
+
are built from the configured canonical hostnames.
|
|
192
|
+
- Rows written before the domain validator existed are ignored for matching and
|
|
193
|
+
for every generated URL, instead of being normalized into a plausible-looking
|
|
194
|
+
address. `ftp://example.com` no longer reduces to `example.com` and wins a
|
|
195
|
+
request for it — which had decided the locale, the tracking profiles that fire
|
|
196
|
+
and the backoffice hostname alike.
|
|
197
|
+
- In explicit domain-mapping mode a supported language with no configured domain
|
|
198
|
+
is left out of hreflang and sitemap alternates, instead of borrowing another
|
|
199
|
+
locale's origin — or the origin of the current request — and advertising a URL
|
|
200
|
+
this deployment may not serve.
|
|
201
|
+
- Single-origin multilingual sites are supported by the sitemap helpers.
|
|
202
|
+
`sitemapEntries` and `sitemapIndexEntries` take the public origin and emit
|
|
203
|
+
absolute URLs only. Previously a locale sitemap could come out carrying
|
|
204
|
+
relative URLs, while the sitemap index had no domain mappings from which to
|
|
205
|
+
build entries at all.
|
|
206
|
+
- Public locale-domain projections omit `isAdminDomain`. Which hostname serves
|
|
207
|
+
the backoffice is not something an anonymous reader needs to be told.
|
|
208
|
+
- `SITE_URL` is validated before it can become the authoritative admin hostname,
|
|
209
|
+
and as configured rather than after normalizing — normalizing strips the
|
|
210
|
+
scheme, so a value nobody can serve validated cleanly and was allowed to
|
|
211
|
+
override the database and switch off the check that stops an unreachable
|
|
212
|
+
admin domain being saved.
|
|
213
|
+
- Admin-host resolution fails open. An unusable flagged row falls through to the
|
|
214
|
+
next source instead of stranding the backoffice, the screen names which source
|
|
215
|
+
the hostname came from, and the banner refreshes when a domain is saved.
|
|
216
|
+
- **Host integration required after upgrading.** Nothing below is done by this
|
|
217
|
+
package on the host's behalf:
|
|
218
|
+
- expose the shared Languages & domains screen at the host's canonical
|
|
219
|
+
`/admin/domains` route;
|
|
220
|
+
- keep the old `/domains` route as a compatibility redirect where applicable;
|
|
221
|
+
- register `getEffectiveAdminHost` among the backoffice host functions, so
|
|
222
|
+
the screen can say where the backoffice actually is;
|
|
223
|
+
- use `siteCanonicalHost()` when passing `SITE_URL` authority, not the raw
|
|
224
|
+
environment value;
|
|
225
|
+
- pass the public origin to `sitemapEntries` and `sitemapIndexEntries` on
|
|
226
|
+
single-origin sites;
|
|
227
|
+
- pass the configured `backofficePath` into `createServerEntry`, or tracking
|
|
228
|
+
will be injected into the backoffice;
|
|
229
|
+
- split public-safe locale-domain reads from authenticated admin reads; and
|
|
230
|
+
- ensure the tracking-create server function returns the newly created
|
|
231
|
+
profile id, which the screen now requires before it will treat a create as
|
|
232
|
+
complete.
|
|
233
|
+
- No database migration is included in this release, and no database data is
|
|
234
|
+
changed by it.
|
|
235
|
+
- Not included and not yet verified: Voyara's own integration is not part of
|
|
236
|
+
this package release, and neither staging nor production has been verified
|
|
237
|
+
with `0.90.0-m7.10`.
|
|
238
|
+
|
|
239
|
+
## 0.90.0-m7.9
|
|
240
|
+
|
|
241
|
+
- The email settings screen says where provider credentials actually go. An
|
|
242
|
+
administrator told only that "passwords are stored as server secrets" does the
|
|
243
|
+
one thing the sentence was meant to prevent and pastes the password into the
|
|
244
|
+
CMS field in front of them, so the screen names the configuration file, the
|
|
245
|
+
environment-variable names and the secret-name convention for that
|
|
246
|
+
installation instead. It stays value-free: no secret is read, returned or
|
|
247
|
+
rendered, and hosts supply their own deployment root through
|
|
248
|
+
`secretsLocation` — including `/`, which stays absolute.
|
|
249
|
+
- Optional block content whose localized value is empty is no longer rendered.
|
|
250
|
+
A heading or paragraph written in one locale and left blank in another used to
|
|
251
|
+
leave its empty markup behind in the locale that had nothing to say.
|
|
252
|
+
- Nested navigation items are editable. Only top-level entries could be changed,
|
|
253
|
+
so fixing a typo in a child link meant deleting it and building it again.
|
|
254
|
+
- Rich text holds up at length, and a block chooses how it appears. Each block
|
|
255
|
+
carries its own reveal mode; `Reveal` still defaults to `fade`, so every
|
|
256
|
+
already-published page looks exactly as it did.
|
|
257
|
+
- Cooperative editing locks stop two administrators overwriting each other. A
|
|
258
|
+
lock is taken when an editor opens a resource, renewed while it stays open and
|
|
259
|
+
released when it closes, and a second editor is told who holds it instead of
|
|
260
|
+
silently losing the earlier write. Pages, global sections, shared content,
|
|
261
|
+
navigation, contact details, forms, form defaults, form history and page SEO
|
|
262
|
+
are covered, and the refusal is enforced in the server cores — not only by
|
|
263
|
+
disabling controls in the interface.
|
|
264
|
+
- The lock's session token is private and the session match is exact.
|
|
265
|
+
`session_id` is never returned by any read: authenticated callers hold column
|
|
266
|
+
grants that omit it, the JSON shape drops it, and the administrator listing
|
|
267
|
+
returns a redacted row. A write against a live lock succeeds only for the
|
|
268
|
+
exact, non-empty session that holds it. Empty or malformed lock responses fail
|
|
269
|
+
closed rather than reading as "free", a stale asynchronous reply cannot revive
|
|
270
|
+
a lock that was already released, reopening an editor can no longer let its
|
|
271
|
+
own release overtake its next acquire, and a duplicated browser tab is given
|
|
272
|
+
its own session identity — Web Locks where the browser provides them, a
|
|
273
|
+
best-effort BroadcastChannel fallback where it does not.
|
|
274
|
+
- Lock storage is a host port, not a provider. `editLockActionCore` and
|
|
275
|
+
`assertEditable` reach the database only through `EditLockAdapter`
|
|
276
|
+
(`@bygghammar/cms-platform/server/edit-lock-adapter`) — five methods covering
|
|
277
|
+
acquire, status, release, list and the staff check — so an application on
|
|
278
|
+
ordinary PostgreSQL implements it with the SQL it already writes, with no
|
|
279
|
+
PostgREST client and no `supabase` object to imitate. Hosts that already build
|
|
280
|
+
`{ supabase: { rpc }, userId, claims }` for their other cores keep passing
|
|
281
|
+
exactly that: `createSupabaseEditLockAdapter` is applied for them
|
|
282
|
+
automatically and nothing on an existing host changes.
|
|
283
|
+
- Locks require a migration and host wiring, and change nothing until both are
|
|
284
|
+
done. `migrations/20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql`
|
|
285
|
+
creates the table and its functions; `docs/EDIT-LOCKS.md` is the host
|
|
286
|
+
contract: one authenticated server endpoint through `editLockActionCore`, one
|
|
287
|
+
`EditLockProvider` built with `createEditLockApi`, the non-empty
|
|
288
|
+
`editSessionId` forwarded to every protected write, `pageKey` passed to
|
|
289
|
+
`PageSeoPanelView`, and the `edit_locked:` message prefix preserved across the
|
|
290
|
+
host's own transport. Until then every screen behaves exactly as it did before
|
|
291
|
+
locks existed.
|
|
292
|
+
- The lock is a cooperative server preflight, not a transaction-atomic write
|
|
293
|
+
fence. The check and the mutation are separate statements, so a host needing
|
|
294
|
+
strict exclusion must put both in one transaction or RPC. Full-document exits
|
|
295
|
+
deliberately let the lock lapse by TTL — 120 seconds by default — rather than
|
|
296
|
+
race a reload against its own release.
|
|
297
|
+
- Verified on PostgreSQL 16 and 17: the migration applies in a single
|
|
298
|
+
transaction against a throwaway container carrying a host's real role,
|
|
299
|
+
authentication and user objects; the grants, the RLS policy and the
|
|
300
|
+
`SECURITY DEFINER` `search_path` come out as written; twenty simultaneous
|
|
301
|
+
acquires of one resource leave exactly one holder and no errors; and a
|
|
302
|
+
shadowing `pg_temp` object cannot reach inside a definer body.
|
|
303
|
+
- Not verified in this release: the migration has not been applied to any real
|
|
304
|
+
staging or production database, and the two-user real-browser lock behaviour
|
|
305
|
+
has not been exercised against a live host. Both remain required before
|
|
306
|
+
production use.
|
|
307
|
+
|
|
11
308
|
## 0.90.0-m7.8
|
|
12
309
|
|
|
13
310
|
- Requires `@bygghammar/lightweight-popup@>=0.2.0-m7.8`. `m7.6` and `m7.7` were
|
package/README.md
CHANGED
|
@@ -25,6 +25,33 @@ apply the canonical schema with `bunx cms-platform-migrations`, import
|
|
|
25
25
|
`theme.css`, register host components / platform data / translations, wrap the
|
|
26
26
|
server cores and mount the shared backoffice views.
|
|
27
27
|
|
|
28
|
+
## Editing locks
|
|
29
|
+
|
|
30
|
+
`docs/EDIT-LOCKS.md` is what a host wires so two administrators cannot
|
|
31
|
+
overwrite each other: run the migration, expose one server endpoint through
|
|
32
|
+
`editLockActionCore`, mount `EditLockProvider` with `createEditLockApi`, and
|
|
33
|
+
forward `editSessionId` to the saves. Until that is done every screen behaves
|
|
34
|
+
exactly as it did before locks existed.
|
|
35
|
+
|
|
36
|
+
## Feature guides
|
|
37
|
+
|
|
38
|
+
| Guide | What it covers |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| `docs/PAGES.md` | Draft/published pages, the publish and unpublish confirmations, duplication |
|
|
41
|
+
| `docs/SCHEDULING.md` | `VisibilitySchedule`, site-timezone editing, scheduled menu and footer items |
|
|
42
|
+
| `docs/LAYOUT-BLOCKS.md` | The columns block, the nested block tree and its move rules, the Icon Cards mobile carousel |
|
|
43
|
+
| `docs/MEDIA-FOLDERS.md` | Automatic month folders, custom upload folders and their sanitising, library browsing |
|
|
44
|
+
| `docs/GOOGLE-SHEETS.md` | Service-account form delivery, the connection test, and the server variables it needs |
|
|
45
|
+
| `docs/TYPOGRAPHY.md` | Independent Google Fonts for the menu and the footer |
|
|
46
|
+
|
|
47
|
+
## Server variables
|
|
48
|
+
|
|
49
|
+
Beyond the existing provider secrets, Google Sheets delivery reads either
|
|
50
|
+
`GOOGLE_SERVICE_ACCOUNT_JSON`, or `GOOGLE_SERVICE_ACCOUNT_EMAIL` plus
|
|
51
|
+
`GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY`. All three are server-only; nothing Google
|
|
52
|
+
related is ever read in the browser, and the font picker needs no API key. See
|
|
53
|
+
`docs/GOOGLE-SHEETS.md`.
|
|
54
|
+
|
|
28
55
|
## Compatibility policy
|
|
29
56
|
|
|
30
57
|
- `CHANGELOG.md` records every release; read it before upgrading.
|
|
@@ -32,7 +59,10 @@ server cores and mount the shared backoffice views.
|
|
|
32
59
|
releases are always compatible.
|
|
33
60
|
- `PLATFORM_CONTRACT_VERSION` (`platform/contract`) is bumped when a shared
|
|
34
61
|
screen starts requiring schema or adapter capabilities an older site lacks.
|
|
35
|
-
Sites pin it in a test.
|
|
62
|
+
Sites pin it in a test. It is **v2**: page saves, publishing, trashing and
|
|
63
|
+
restoring go through database functions that the two `20260904…` publication
|
|
64
|
+
migrations install, so migrations are applied before the package is deployed.
|
|
65
|
+
`docs/NEW-SITE.md` gives the order.
|
|
36
66
|
- `migrations/` is schema only. Site content, SEO copy and seeds never belong
|
|
37
67
|
there.
|
|
38
68
|
|
|
@@ -119,8 +149,14 @@ bun run check # typecheck + tests + package verification
|
|
|
119
149
|
|
|
120
150
|
The package ships TypeScript sources; there is no build output. Every change
|
|
121
151
|
goes through a pull request, and every release is triggered by pushing a
|
|
122
|
-
`vX.Y.Z` tag that matches `package.json
|
|
123
|
-
|
|
152
|
+
`vX.Y.Z` tag that matches `package.json`. That tag runs
|
|
153
|
+
`.github/workflows/publish.yml`, which publishes to npm using **Trusted
|
|
154
|
+
Publishing**: GitHub mints a short-lived OIDC token scoped to that single
|
|
155
|
+
workflow run, and npm exchanges it for publish rights through the package's
|
|
156
|
+
Trusted Publisher configuration. No long-lived npm credential takes part — no
|
|
157
|
+
`NPM_TOKEN`, no `NODE_AUTH_TOKEN`, no OTP. [RELEASING.md](./RELEASING.md) is
|
|
158
|
+
what the workflow checks and the npm settings it has to match. Never publish
|
|
159
|
+
manually.
|
|
124
160
|
|
|
125
161
|
See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full workflow and changelog
|
|
126
162
|
conventions. Consumers install an explicit released version such as
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# Editing locks: what a host has to wire
|
|
2
|
+
|
|
3
|
+
Two administrators opening the same page used to be a silent race — whoever
|
|
4
|
+
saved last won, and the other one's work disappeared without a message. The
|
|
5
|
+
lock makes that a refusal instead: the second person sees who has the page and
|
|
6
|
+
cannot type into it, and the server refuses the write even if they try.
|
|
7
|
+
|
|
8
|
+
The package supplies all of it except three connections only the host can make.
|
|
9
|
+
Until they are made every screen behaves exactly as it did before locks
|
|
10
|
+
existed, which is what lets an installation upgrade the package first and the
|
|
11
|
+
database afterwards.
|
|
12
|
+
|
|
13
|
+
## 1. Run the migration
|
|
14
|
+
|
|
15
|
+
`migrations/20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql` creates
|
|
16
|
+
`public.cms_edit_locks` and five functions. It is safe to run more than once.
|
|
17
|
+
It is ordinary PostgreSQL — a table, an index, grants, one RLS policy and four
|
|
18
|
+
`SECURITY DEFINER` functions with a pinned `search_path`. A host that stores
|
|
19
|
+
locks some other way can skip it and supply its own adapter instead; see
|
|
20
|
+
[Storage](#storage).
|
|
21
|
+
|
|
22
|
+
Until it runs, `assertEditable` sees "no such function" and lets every write
|
|
23
|
+
through — an upgrade cannot break saving. After it runs, the server checks are
|
|
24
|
+
available; the provider and save-path forwarding below are still required for
|
|
25
|
+
per-tab UI locking.
|
|
26
|
+
|
|
27
|
+
## 2. One server endpoint
|
|
28
|
+
|
|
29
|
+
Every lock call goes through `editLockActionCore`, so the host writes one
|
|
30
|
+
server function rather than five:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { editLockActionCore, isEditLockAction } from "@bygghammar/cms-platform/server/edit-locks-core";
|
|
34
|
+
import { editLockSchemas } from "@bygghammar/cms-platform/server/schemas";
|
|
35
|
+
|
|
36
|
+
export const editLock = serverFn(async ({ data }) => {
|
|
37
|
+
if (!isEditLockAction(data.action)) throw new Error("Unknown lock action");
|
|
38
|
+
const input = editLockSchemas.acquire.parse(data);
|
|
39
|
+
return editLockActionCore(await authContext(), data.action, input);
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`authContext()` returns the lock storage for the signed-in user — see
|
|
44
|
+
[Storage](#storage) for what that is. The lock functions run as that user, so
|
|
45
|
+
`is_staff` and RLS apply exactly as they do everywhere else.
|
|
46
|
+
|
|
47
|
+
## 3. One provider in the browser
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
import { useMemo } from "react";
|
|
51
|
+
import { createEditLockApi, EditLockProvider } from "@bygghammar/cms-platform/backoffice/EditLock";
|
|
52
|
+
|
|
53
|
+
const lockApi = useMemo(
|
|
54
|
+
() => createEditLockApi(
|
|
55
|
+
(action, input) => editLock({ data: { action, ...input } }),
|
|
56
|
+
{ canSteal: isSuperadmin },
|
|
57
|
+
),
|
|
58
|
+
[editLock, isSuperadmin],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
<EditLockProvider api={lockApi}>{backoffice}</EditLockProvider>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Mount it once, around the backoffice. Every editing screen finds it by itself.
|
|
65
|
+
Keep the API object stable with `useMemo`: its identity controls the editors'
|
|
66
|
+
acquire/release effects, so rebuilding it during each render would repeatedly
|
|
67
|
+
release and reacquire their locks.
|
|
68
|
+
|
|
69
|
+
`canSteal` only decides whether the take-over button is *offered*; the database
|
|
70
|
+
function refuses anyone but a superadmin regardless, so offering it to an
|
|
71
|
+
ordinary editor produces a button that always fails.
|
|
72
|
+
|
|
73
|
+
Leave the api `null` — or mount no provider at all — and every screen reports
|
|
74
|
+
"not enforced" and behaves as before.
|
|
75
|
+
|
|
76
|
+
## 4. Pass the session id on to the saves
|
|
77
|
+
|
|
78
|
+
The provider is what shows the banner. The session id is what makes the server
|
|
79
|
+
able to tell *this* editor's save from someone else's, and it travels with the
|
|
80
|
+
save, not with the lock.
|
|
81
|
+
|
|
82
|
+
Each screen already puts `lock.sessionId` into the deps it calls. The host's
|
|
83
|
+
job is to forward it to the matching core:
|
|
84
|
+
|
|
85
|
+
| Screen | Host dep | Core | Resource |
|
|
86
|
+
| --- | --- | --- | --- |
|
|
87
|
+
| `PageBuilderView` | `save`, `rename`, `schedule`, `cancelPublication`, `restoreVersion` | `savePageVersionCore`, `renamePageCore`, `schedulePageVersionCore`, `cancelPagePublicationCore`, `restorePageVersionCore` | `page` / page id |
|
|
88
|
+
| `GlobalSectionBuilderView` | `save`, `rename`, `setKind` | `saveGlobalSectionVersionCore`, `renameGlobalSectionCore`, `setGlobalSectionKindCore` | `global_section` / section id |
|
|
89
|
+
| `SectionEditorView`, `NavigationView`, `ContactDetailsView` | `save` | `saveContentSectionCore` | `content_section` / section key |
|
|
90
|
+
| `FormEditorView` | `saveForm`; pass `editSessionId` into its host `VersionHistory` too | `saveFormCore`, `restoreFormVersionCore` | `form` / form id |
|
|
91
|
+
| `FormSettingsView` | `saveDefaults`; forward the whole `data` object, or destructure its `editSessionId` into the core's third argument | `saveFormDefaultsCore` | `form_defaults` / `form_settings` |
|
|
92
|
+
| `PageSeoPanelView` | `onSave(form, editSessionId)`, `onReset(editSessionId)` | `savePageSeoCore`, `deletePageSeoOverrideCore` | `page_seo` / `pageKey` |
|
|
93
|
+
|
|
94
|
+
The cores and schemas accept `editSessionId` as an optional field so an
|
|
95
|
+
unwired/unmigrated host keeps its old behaviour while no lock exists. Once a
|
|
96
|
+
resource has a live lock, every protected write requires the matching session
|
|
97
|
+
id, including writes by another tab of the same user. Forward it on every path
|
|
98
|
+
in the table; omission is not a bypass.
|
|
99
|
+
|
|
100
|
+
`PageSeoPanelView` additionally needs `pageKey`. Without it the SEO panel has
|
|
101
|
+
no resource to lock and stays unlocked, even with everything else wired.
|
|
102
|
+
|
|
103
|
+
## Storage
|
|
104
|
+
|
|
105
|
+
`editLockActionCore` and `assertEditable` never touch a database themselves.
|
|
106
|
+
Every read and write goes through `EditLockAdapter`
|
|
107
|
+
(`@bygghammar/cms-platform/server/edit-lock-adapter`), five methods over the
|
|
108
|
+
domain operations and nothing else:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
type EditLockAdapter = {
|
|
112
|
+
assertStaff(): Promise<void>;
|
|
113
|
+
acquire(input: EditLockRef & { sessionId: string; ttlSeconds: number; steal: boolean }): Promise<EditLockState>;
|
|
114
|
+
status(input: EditLockRef & { sessionId: string | null }): Promise<EditLockState>;
|
|
115
|
+
release(input: EditLockRef & { sessionId: string }): Promise<boolean>;
|
|
116
|
+
list(): Promise<EditLockRow[]>;
|
|
117
|
+
};
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The context a core takes is `{ editLocks: <adapter> }`. Nothing in the contract
|
|
121
|
+
names a driver, a client library or a wire format, so a self-hosted application
|
|
122
|
+
on ordinary PostgreSQL implements it with the SQL it already writes:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import type { EditLockAdapter } from "@bygghammar/cms-platform/server/edit-lock-adapter";
|
|
126
|
+
import { EditLockNotInstalledError } from "@bygghammar/cms-platform/server/edit-lock-adapter";
|
|
127
|
+
import { asEditLockState } from "@bygghammar/cms-platform/cms/edit-lock";
|
|
128
|
+
|
|
129
|
+
const MISSING = new Set(["42883", "42P01"]); // no such function, no such table
|
|
130
|
+
|
|
131
|
+
export function editLockStorage(pool: Pool, userId: string): EditLockAdapter {
|
|
132
|
+
const query = async (text: string, values: unknown[]) => {
|
|
133
|
+
try {
|
|
134
|
+
return await pool.query(text, values);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (MISSING.has(error.code)) throw new EditLockNotInstalledError();
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
return {
|
|
141
|
+
async acquire(input) {
|
|
142
|
+
const { rows } = await query(
|
|
143
|
+
"SELECT public.cms_acquire_edit_lock($1, $2, $3, $4, $5) AS state",
|
|
144
|
+
[input.resourceType, input.resourceId, input.sessionId, input.ttlSeconds, input.steal],
|
|
145
|
+
);
|
|
146
|
+
return asEditLockState(rows[0]?.state);
|
|
147
|
+
},
|
|
148
|
+
// status, release, list and assertStaff in the same shape
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Two rules an adapter has to honour, whatever it is built on:
|
|
154
|
+
|
|
155
|
+
- **`acquire` must be one atomic statement.** Two editors opening the same page
|
|
156
|
+
at the same instant have to contend on a single unique index so exactly one
|
|
157
|
+
wins. Read-then-write leaves a window in which both do.
|
|
158
|
+
- **`sessionId` is a bearer token.** It goes in and is never returned. Knowing
|
|
159
|
+
another tab's session id is enough to save as its holder, so no read may
|
|
160
|
+
include it — not `status`, not `list`.
|
|
161
|
+
|
|
162
|
+
Whether the storage runs on a stored function, plain SQL in the adapter, or a
|
|
163
|
+
service of its own is the host's decision. A PostgREST-shaped host that already
|
|
164
|
+
builds `{ supabase: { rpc }, userId, claims }` for its other cores can keep
|
|
165
|
+
passing exactly that: `createSupabaseEditLockAdapter` is applied for it
|
|
166
|
+
automatically, and the migration's functions are the storage.
|
|
167
|
+
|
|
168
|
+
## What the server does with it
|
|
169
|
+
|
|
170
|
+
`assertEditable` sits before each protected write. A write from another session
|
|
171
|
+
throws `EditLockedError`. On the server it has `error.code === "edit_locked"` and
|
|
172
|
+
`error.state`. TanStack Start serializes only an Error's message, so the message
|
|
173
|
+
also begins with `edit_locked:` and `isEditLockedError()` recognises either
|
|
174
|
+
form after transport. Do not replace that failure with a success response.
|
|
175
|
+
|
|
176
|
+
This is a cooperative preflight lock, not a transaction-wide database fence.
|
|
177
|
+
Expiry, a new acquisition, a superadmin steal, or an explicit release could
|
|
178
|
+
land between the status preflight and a later PostgREST mutation. Strict
|
|
179
|
+
transactional exclusion would require each mutation and its lock check in one
|
|
180
|
+
database RPC, or a fencing token checked by the write itself.
|
|
181
|
+
|
|
182
|
+
The per-tab session id is a bearer value and is never returned by a lock
|
|
183
|
+
status/acquire response. Authenticated staff have column-level read access to
|
|
184
|
+
the operational lock fields but not `session_id`; the administrative list RPC
|
|
185
|
+
omits that column. This prevents a blocked browser from reading the
|
|
186
|
+
holder's token and replaying it in a save.
|
|
187
|
+
|
|
188
|
+
Modern browsers also hold an exclusive Web Lock for that bearer value before
|
|
189
|
+
the first server acquire. This detects a duplicated tab even when the original
|
|
190
|
+
is frozen. Browsers without Web Locks fall back to a short BroadcastChannel
|
|
191
|
+
handshake. That fallback is best-effort because a frozen source tab can answer
|
|
192
|
+
too late; without either primitive there is no duplicate-tab signal at all.
|
|
193
|
+
Multi-user locking remains enforced in either case.
|
|
194
|
+
|
|
195
|
+
## Choosing the lifetime
|
|
196
|
+
|
|
197
|
+
A lock lasts 120 seconds by default and is renewed every 40. A browser that
|
|
198
|
+
crashes therefore frees the page about two minutes later, with no cleanup job
|
|
199
|
+
anywhere. Pass `ttlSeconds` to `createEditLockApi` to change it; the database
|
|
200
|
+
clamps to 15–3600 whatever it is told.
|
|
201
|
+
|
|
202
|
+
Leaving an editor through client-side navigation releases immediately. Closing
|
|
203
|
+
or reloading the whole document relies on the TTL: firing a release from the
|
|
204
|
+
old document can arrive after the reload has already reacquired with the same
|
|
205
|
+
session and delete its new lock. A restored/backgrounded page stays fenced
|
|
206
|
+
until its catch-up renewal confirms it still holds the resource.
|
|
207
|
+
|
|
208
|
+
## Recovering a stuck lock
|
|
209
|
+
|
|
210
|
+
`listEditLocksCore` returns every live lock, and `steal` takes one — superadmin
|
|
211
|
+
only, enforced in the database. Neither is needed for the feature to work; they
|
|
212
|
+
are for the afternoon somebody's laptop dies mid-edit and two minutes is too
|
|
213
|
+
long to wait.
|