@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
@@ -0,0 +1,377 @@
1
+ /**
2
+ * What the public sees, and until when.
3
+ *
4
+ * One module answers both questions so the page route, the sitemap and every
5
+ * cache along the way cannot disagree about whether a page is live. The rule is
6
+ * a single sentence:
7
+ *
8
+ * the newest active publication whose `publishAt` has arrived governs, and
9
+ * the page is public while that row's `unpublishAt` has not.
10
+ *
11
+ * The second half is why an older row never comes back into force behind a
12
+ * newer one. Without it, "hide this at midnight" would mean "show the previous
13
+ * version again at midnight", and `unpublishAt` would hide nothing at all on a
14
+ * page that has been published more than once.
15
+ *
16
+ * `pages.status` is not consulted here, by design. It records editorial intent
17
+ * — is this page finished? — and a page that is finished is not thereby public:
18
+ * a version scheduled for next week, a countdown target waiting for its
19
+ * deadline and a page whose window has closed are all finished and all hidden.
20
+ */
21
+
22
+ /** The fields of a `page_publications` row these rules need. */
23
+ export type PublicationWindow = {
24
+ versionId: string | null;
25
+ publishAt: string | null;
26
+ unpublishAt?: string | null;
27
+ /** Tie-break for two rows published at the same instant; newest wins. */
28
+ publishedAt?: string | null;
29
+ };
30
+
31
+ /**
32
+ * The same rows as a database hands them over.
33
+ *
34
+ * Both the public reader and the backoffice select these columns, so the
35
+ * translation lives here rather than once per caller — a second copy of it is
36
+ * a second chance to read `publish_at` into the wrong field.
37
+ */
38
+ export function toPublicationWindows(rows: unknown): PublicationWindow[] {
39
+ return ((rows ?? []) as Array<{
40
+ version_id?: string | null;
41
+ publish_at?: string | null;
42
+ unpublish_at?: string | null;
43
+ published_at?: string | null;
44
+ }>).map((row) => ({
45
+ versionId: row.version_id ?? null,
46
+ publishAt: row.publish_at ?? null,
47
+ unpublishAt: row.unpublish_at ?? null,
48
+ publishedAt: row.published_at ?? null,
49
+ }));
50
+ }
51
+
52
+ /**
53
+ * An instant we cannot read is a window we cannot honour, and an unhonoured
54
+ * window shows nothing: an unreadable `publishAt` has not arrived, and an
55
+ * unreadable `unpublishAt` has already passed. Both fail towards hiding
56
+ * content, which is the only direction that is safe to be wrong in.
57
+ */
58
+ const instant = (value: string | null | undefined): number | null => {
59
+ if (!value) return null;
60
+ const ms = Date.parse(value);
61
+ return Number.isFinite(ms) ? ms : null;
62
+ };
63
+
64
+ /**
65
+ * The row deciding what this page shows right now, or null when none has
66
+ * started yet. Rows may arrive in any order; the newest start wins.
67
+ */
68
+ export function governingPublication<T extends PublicationWindow>(
69
+ rows: readonly T[],
70
+ now: number = Date.now(),
71
+ ): T | null {
72
+ let best: T | null = null;
73
+ let bestAt = Number.NEGATIVE_INFINITY;
74
+ let bestTie = Number.NEGATIVE_INFINITY;
75
+ for (const row of rows) {
76
+ const at = instant(row.publishAt);
77
+ if (at === null || at > now) continue;
78
+ const tie = instant(row.publishedAt) ?? Number.NEGATIVE_INFINITY;
79
+ if (at > bestAt || (at === bestAt && tie > bestTie)) {
80
+ best = row;
81
+ bestAt = at;
82
+ bestTie = tie;
83
+ }
84
+ }
85
+ return best;
86
+ }
87
+
88
+ /** Is the governing row still open? Closed at its `unpublishAt`, not after it. */
89
+ function isOpen(row: PublicationWindow, now: number): boolean {
90
+ if (row.unpublishAt == null) return true;
91
+ const end = instant(row.unpublishAt);
92
+ return end !== null && now < end;
93
+ }
94
+
95
+ /**
96
+ * The version the public may read, or null when this page is not public.
97
+ *
98
+ * Visible at `publishAt` to the millisecond; hidden at `unpublishAt` to the
99
+ * millisecond, so a window is closed at the instant it ends rather than one
100
+ * tick later.
101
+ */
102
+ export function publicVersionId(
103
+ rows: readonly PublicationWindow[],
104
+ now: number = Date.now(),
105
+ ): string | null {
106
+ const governing = governingPublication(rows, now);
107
+ if (!governing || !isOpen(governing, now)) return null;
108
+ return governing.versionId ?? null;
109
+ }
110
+
111
+ /**
112
+ * What the backoffice should say about this page, in the visitor's terms.
113
+ *
114
+ * `pages.status` cannot answer this and never could: it is one editor's note
115
+ * about whether the page is finished. These five states come from the same
116
+ * rows and the same rule the public reader uses, so a page the screen calls
117
+ * published is a page a visitor can open, and a page it calls hidden is one
118
+ * they cannot.
119
+ */
120
+ export type PublicationState =
121
+ /** Nothing open and nothing scheduled — invisible until somebody publishes. */
122
+ | "draft"
123
+ /** Invisible now; a window opens later. */
124
+ | "scheduled"
125
+ /** Public, with no end date. */
126
+ | "published"
127
+ /** Public now, and already scheduled to disappear. */
128
+ | "ending"
129
+ /** Was public; the window has closed and nothing else is scheduled. */
130
+ | "expired";
131
+
132
+ export function publicationStateAt(
133
+ rows: readonly PublicationWindow[],
134
+ now: number = Date.now(),
135
+ ): PublicationState {
136
+ const governing = governingPublication(rows, now);
137
+ if (governing && isOpen(governing, now)) {
138
+ return governing.unpublishAt ? "ending" : "published";
139
+ }
140
+ /*
141
+ * Not public. Which kind of hidden matters to the editor: "not yet" and "not
142
+ * any more" call for different actions, and neither is the "never published"
143
+ * a status column would report for all three.
144
+ */
145
+ const waiting = rows.some((row) => {
146
+ const at = instant(row.publishAt);
147
+ return at !== null && at > now;
148
+ });
149
+ if (waiting) return "scheduled";
150
+ return governing ? "expired" : "draft";
151
+ }
152
+
153
+ /** Can a visitor open this page right now? */
154
+ export function isPubliclyVisible(state: PublicationState): boolean {
155
+ return state === "published" || state === "ending";
156
+ }
157
+
158
+ export const PUBLICATION_STATE_LABELS: Record<PublicationState, string> = {
159
+ draft: "Hidden",
160
+ scheduled: "Scheduled to publish",
161
+ published: "Published",
162
+ ending: "Scheduled to hide",
163
+ expired: "Hidden after expiry",
164
+ };
165
+
166
+ /** Longer wording for tooltips and screen-reader descriptions. */
167
+ export const PUBLICATION_STATE_DESCRIPTIONS: Record<PublicationState, string> = {
168
+ draft: "Not visible on the public website. Staff can still edit and preview it.",
169
+ scheduled: "Not visible yet. It appears by itself when its publication window opens.",
170
+ published: "Visible on the public website right now.",
171
+ ending: "Visible now, and scheduled to disappear when its window closes.",
172
+ expired: "Not visible any more — its publication window has closed.",
173
+ };
174
+
175
+ /**
176
+ * What happens at the next boundary — not merely when it happens.
177
+ *
178
+ * A single state plus a date cannot say what a page with a replacement lined up
179
+ * is doing. "Live until the 20th, with version 4 taking over on the 10th" came
180
+ * out as `ending` with the 10th attached, which the badge then read aloud as
181
+ * "Scheduled to hide — September 10": the one day the page is certainly *not*
182
+ * hidden. Worse the other way round, an open-ended publication with a
183
+ * replacement due showed a plain "Published" and said nothing about the update
184
+ * at all.
185
+ *
186
+ * So the two questions are answered separately. {@link publicationStateAt} says
187
+ * what a visitor gets now; this says what changes next and into what.
188
+ */
189
+ export type PublicationTransitionKind =
190
+ /** A hidden page becomes public. */
191
+ | "publish"
192
+ /** A public page stays public, showing a different version. */
193
+ | "replace"
194
+ /** A public page becomes hidden. */
195
+ | "hide";
196
+
197
+ export type PublicationTransition = {
198
+ kind: PublicationTransitionKind;
199
+ /** When it happens, in milliseconds since the epoch. */
200
+ at: number;
201
+ /** The version the page shows afterwards; null for a hide. */
202
+ versionId: string | null;
203
+ };
204
+
205
+ /**
206
+ * The next change to this page's public answer, or null when nothing is due.
207
+ *
208
+ * Two things can change it: a window that has not opened yet, and the end of
209
+ * the one governing now. Whichever comes first wins, and a start that lands
210
+ * exactly on an end wins the tie — at that instant the newer row is the one
211
+ * that governs, so the page carries on with the new version rather than
212
+ * blinking out of existence.
213
+ */
214
+ export function nextPublicationTransition(
215
+ rows: readonly PublicationWindow[],
216
+ now: number = Date.now(),
217
+ ): PublicationTransition | null {
218
+ let start: { at: number; row: PublicationWindow } | null = null;
219
+ for (const row of rows) {
220
+ const at = instant(row.publishAt);
221
+ if (at === null || at <= now) continue;
222
+ if (start === null || at < start.at) start = { at, row };
223
+ }
224
+
225
+ const governing = governingPublication(rows, now);
226
+ const live = governing !== null && isOpen(governing, now);
227
+ const end = live ? instant(governing.unpublishAt) : null;
228
+ const hideAt = end !== null && end > now ? end : null;
229
+
230
+ if (start !== null && (hideAt === null || start.at <= hideAt)) {
231
+ return {
232
+ kind: live ? "replace" : "publish",
233
+ at: start.at,
234
+ versionId: start.row.versionId ?? null,
235
+ };
236
+ }
237
+ if (hideAt !== null) return { kind: "hide", at: hideAt, versionId: null };
238
+ return null;
239
+ }
240
+
241
+ /**
242
+ * The next instant this page's public answer can change, or null when nothing
243
+ * is scheduled and it will go on saying the same thing indefinitely.
244
+ *
245
+ * How long an answer may be cached — the reason a twenty-second cache cannot
246
+ * outlive a publication.
247
+ */
248
+ export function nextPublicationBoundary(
249
+ rows: readonly PublicationWindow[],
250
+ now: number = Date.now(),
251
+ ): number | null {
252
+ return nextPublicationTransition(rows, now)?.at ?? null;
253
+ }
254
+
255
+ /**
256
+ * What a badge should say, given both answers.
257
+ *
258
+ * A replacement is neither a publish nor a hide, and saying either of those is
259
+ * worse than saying nothing: an editor who reads "Scheduled to hide" on a page
260
+ * that will still be there tomorrow republishes it for no reason, and one who
261
+ * reads a bare "Published" on a page about to change under them never learns
262
+ * there is an update queued at all.
263
+ */
264
+ export function publicationSummary(
265
+ state: PublicationState,
266
+ transition: PublicationTransitionKind | null | undefined,
267
+ ): { label: string; description: string; showsAt: boolean } {
268
+ if (transition === "replace") {
269
+ return {
270
+ label: "Published — update scheduled",
271
+ description:
272
+ "Visible on the public website right now. A newer version is scheduled " +
273
+ "to take over; the page does not disappear.",
274
+ showsAt: true,
275
+ };
276
+ }
277
+ return {
278
+ label: PUBLICATION_STATE_LABELS[state],
279
+ description: PUBLICATION_STATE_DESCRIPTIONS[state],
280
+ showsAt: state === "scheduled" || state === "ending",
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Longest a single timer is trusted for.
286
+ *
287
+ * `setTimeout` overflows past about 24.8 days and fires at once, so a page
288
+ * scheduled for next year would refetch in a loop. Anything further away than
289
+ * this is waited for in stages, and the clock is re-read each time — which also
290
+ * keeps a screen left open over a laptop's sleep from waking up with a timer
291
+ * measured against a clock that has moved on.
292
+ */
293
+ export const MAX_BOUNDARY_TIMEOUT_MS = 6 * 60 * 60 * 1000;
294
+
295
+ /**
296
+ * How long to wait before re-reading a page whose next boundary is `at`.
297
+ *
298
+ * Null when there is nothing to wait for. Zero when the boundary has already
299
+ * passed, so a screen that was open across it corrects itself immediately
300
+ * rather than at the next navigation.
301
+ */
302
+ export function boundaryTimeoutMs(
303
+ at: string | number | null | undefined,
304
+ now: number = Date.now(),
305
+ ): number | null {
306
+ if (at == null) return null;
307
+ const when = typeof at === "number" ? at : instant(at);
308
+ if (when === null) return null;
309
+ return Math.max(0, Math.min(MAX_BOUNDARY_TIMEOUT_MS, when - now));
310
+ }
311
+
312
+ /**
313
+ * First wait after a boundary refresh that has not yet changed anything.
314
+ *
315
+ * A boundary that has passed makes `boundaryTimeoutMs` return zero, and it goes
316
+ * on returning zero until fresh data moves the boundary — which is a network
317
+ * round trip away, and may never arrive at all if the read fails. Re-arming on
318
+ * that zero is a refetch loop with no delay in it, so the wait after a refresh
319
+ * has been asked for comes from here instead.
320
+ */
321
+ export const BOUNDARY_RETRY_MIN_MS = 30_000;
322
+
323
+ /** Longest wait between those retries; a stale screen still recovers. */
324
+ export const BOUNDARY_RETRY_MAX_MS = 5 * 60 * 1000;
325
+
326
+ /**
327
+ * How long to wait before asking again, after `attempt` refreshes have been
328
+ * issued for a boundary that is still in the past.
329
+ *
330
+ * Doubling, capped. The first attempt is the ordinary case — the answer is in
331
+ * flight and about to arrive — and the cap is what keeps a screen whose host
332
+ * is down from turning into a poll every 30 seconds forever, while still
333
+ * recovering within five minutes of the host coming back.
334
+ */
335
+ export function boundaryRetryMs(attempt: number): number {
336
+ const n = Math.max(1, Math.floor(attempt));
337
+ return Math.min(BOUNDARY_RETRY_MAX_MS, BOUNDARY_RETRY_MIN_MS * 2 ** (n - 1));
338
+ }
339
+
340
+ /**
341
+ * How long a cached public answer may still be served.
342
+ *
343
+ * `revalidateAt` is the boundary above, carried back from the server; the
344
+ * default is the ordinary freshness window a site is happy with. A missing
345
+ * boundary means nothing is scheduled, so the default stands.
346
+ *
347
+ * A page that is not public has no boundary to offer — there is no row to read
348
+ * one from — so a missing page is never cached. That costs a query on the rare
349
+ * 404 and buys the guarantee that a page appears the moment its window opens.
350
+ */
351
+ export function publishedStaleTime(
352
+ revalidateAt: string | number | null | undefined,
353
+ defaultMs: number,
354
+ now: number = Date.now(),
355
+ ): number {
356
+ if (revalidateAt == null) return defaultMs;
357
+ const at = typeof revalidateAt === "number" ? revalidateAt : instant(revalidateAt);
358
+ if (at === null) return defaultMs;
359
+ return Math.max(0, Math.min(defaultMs, at - now));
360
+ }
361
+
362
+ /**
363
+ * The same ceiling for a fetched page, including the case where there is none.
364
+ *
365
+ * A page that is not public carries no boundary — there is no open row to read
366
+ * one from — so it is never held: the answer "no such page" must give way the
367
+ * instant a window opens, and the query that discovers that costs one round
368
+ * trip on a route nobody is looking at anyway.
369
+ */
370
+ export function publishedPageStaleTime(
371
+ page: { revalidateAt?: string | null } | null | undefined,
372
+ defaultMs: number,
373
+ now: number = Date.now(),
374
+ ): number {
375
+ if (page == null) return 0;
376
+ return publishedStaleTime(page.revalidateAt, defaultMs, now);
377
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * The editorial status of a page: the vocabulary and the slug rules around it.
3
+ *
4
+ * `pages.status` records the last editorial decision somebody made about a
5
+ * page — publish it, or take it back to a draft. It does **not** say whether a
6
+ * visitor can reach the page, and no screen may present it as if it did: a
7
+ * draft whose scheduled window has opened is on the public website, and a
8
+ * published page whose window has closed is not. Publication rows answer that
9
+ * question, through `publicationStateAt` in `cms/page-publication`, and the
10
+ * backoffice shows both — what visitors get, and what an editor last decided.
11
+ *
12
+ * There is no one-click publish toggle anywhere in the UI: both directions are
13
+ * confirmed, using the wording below so every screen asks the same question.
14
+ */
15
+
16
+ import type { PublicationState, PublicationTransitionKind } from "./page-publication";
17
+
18
+ export const PAGE_STATUSES = ["draft", "published"] as const;
19
+ export type PageStatus = (typeof PAGE_STATUSES)[number];
20
+
21
+ /** New pages start unpublished. Duplicates do too, whatever the source was. */
22
+ export const DEFAULT_PAGE_STATUS: PageStatus = "draft";
23
+
24
+ /** Read an untrusted status column. Anything unknown is treated as a draft. */
25
+ export function normalizePageStatus(raw: unknown): PageStatus {
26
+ return raw === "published" ? "published" : "draft";
27
+ }
28
+
29
+ export const PAGE_STATUS_LABELS: Record<PageStatus, string> = {
30
+ draft: "Draft",
31
+ published: "Published",
32
+ };
33
+
34
+ /**
35
+ * What each editorial status means — and, just as importantly, what it does not
36
+ * mean. Screens show these next to the page's actual public state, never
37
+ * instead of it.
38
+ */
39
+ export const PAGE_STATUS_DESCRIPTIONS: Record<PageStatus, string> = {
40
+ draft:
41
+ "Editorial status: the last decision was to keep this page as a draft. " +
42
+ "It does not decide what visitors see — a scheduled publication window can " +
43
+ "still make a draft public.",
44
+ published:
45
+ "Editorial status: the last decision was to publish this page. " +
46
+ "It does not decide what visitors see — a page whose publication window has " +
47
+ "closed is published and still unreachable.",
48
+ };
49
+
50
+ /** Confirmation copy. Both directions ask; neither is a silent toggle. */
51
+ export const PUBLISH_CONFIRMATION =
52
+ "Are you sure you want to publish this page? It will become visible on the public website.";
53
+ export const UNPUBLISH_CONFIRMATION =
54
+ "Are you sure you want to unpublish this page? It will no longer be visible on the public website.";
55
+ /** Publishing over a window that has not opened yet cancels the schedule. */
56
+ export const PUBLISH_OVER_SCHEDULE_CONFIRMATION =
57
+ "This page is already scheduled to appear on its own. Publishing now makes it " +
58
+ "visible immediately and cancels that schedule. Are you sure?";
59
+ /** Unpublishing something that is only public because of a window. */
60
+ export const UNPUBLISH_SCHEDULED_CONFIRMATION =
61
+ "Are you sure you want to unpublish this page? It is visible on the public " +
62
+ "website right now, and unpublishing also cancels the window it is showing in.";
63
+ /** Publishing over an update that is already queued to take over. */
64
+ export const PUBLISH_OVER_REPLACEMENT_CONFIRMATION =
65
+ "An updated version of this page is already scheduled to take over. Publishing " +
66
+ "now makes the current draft visible immediately and cancels that update. " +
67
+ "Are you sure?";
68
+ /** Unpublishing a page that has something scheduled behind it. */
69
+ export const UNPUBLISH_OVER_SCHEDULE_CONFIRMATION =
70
+ "Are you sure you want to unpublish this page? It also cancels the publication " +
71
+ "already scheduled for it, which will not happen by itself afterwards.";
72
+
73
+ /**
74
+ * The question to ask before a status change, from what a visitor gets now and
75
+ * what was going to happen next.
76
+ *
77
+ * Both directions retire every active publication row, scheduled ones
78
+ * included — an editor pressing Publish to fix a typo should not silently
79
+ * throw away the update a colleague queued for Monday. The wording lives here
80
+ * so the pages list and the builder ask the same question about the same page.
81
+ */
82
+ export function statusChangeConfirmation(
83
+ next: PageStatus,
84
+ publication: PublicationState,
85
+ transition: PublicationTransitionKind | null | undefined,
86
+ ): string {
87
+ /*
88
+ * A queued transition is one that publishing or unpublishing now would throw
89
+ * away. `transition` is the precise answer; where a host has not been updated
90
+ * to send it, "scheduled" means exactly "a publish is queued and nothing is
91
+ * live", so the older field answers the same question for that one state.
92
+ */
93
+ const queued =
94
+ transition === "publish" ||
95
+ transition === "replace" ||
96
+ (transition == null && publication === "scheduled");
97
+ if (next === "published") {
98
+ if (transition === "replace") return PUBLISH_OVER_REPLACEMENT_CONFIRMATION;
99
+ if (queued) return PUBLISH_OVER_SCHEDULE_CONFIRMATION;
100
+ return PUBLISH_CONFIRMATION;
101
+ }
102
+ if (queued) return UNPUBLISH_OVER_SCHEDULE_CONFIRMATION;
103
+ return publication === "ending" ? UNPUBLISH_SCHEDULED_CONFIRMATION : UNPUBLISH_CONFIRMATION;
104
+ }
105
+
106
+ /**
107
+ * A page key from free text, following the existing rule: lowercase letters,
108
+ * digits, dash and underscore (`/^[a-z0-9_-]+$/`, as `cmsKey` enforces).
109
+ */
110
+ export function slugifyPageKey(input: string): string {
111
+ return input
112
+ .normalize("NFD")
113
+ .replace(/[\u0300-\u036f]/g, "")
114
+ .toLowerCase()
115
+ .replace(/[^a-z0-9_-]+/g, "-")
116
+ .replace(/-{2,}/g, "-")
117
+ .replace(/^-+|-+$/g, "")
118
+ .slice(0, 60);
119
+ }
120
+
121
+ /** True when a key satisfies the platform's page-key rule. */
122
+ export function isValidPageKey(key: string): boolean {
123
+ return /^[a-z0-9_-]+$/.test(key);
124
+ }
125
+
126
+ /**
127
+ * A key not already taken. `about-us` becomes `about-us-2`, then `-3`, and so
128
+ * on — the same shape `uniqueFileName` uses for media, so the two read alike.
129
+ */
130
+ export function uniquePageKey(base: string, taken: Iterable<string>): string {
131
+ const used = new Set(taken);
132
+ const seed = slugifyPageKey(base) || "page";
133
+ if (!used.has(seed)) return seed;
134
+ for (let n = 2; n < 1000; n += 1) {
135
+ const candidate = `${seed}-${n}`;
136
+ if (!used.has(candidate)) return candidate;
137
+ }
138
+ return `${seed}-${Date.now()}`;
139
+ }
140
+
141
+ /**
142
+ * The name a duplicate is proposed with, e.g. "About us (copy)".
143
+ *
144
+ * Copying a copy counts up rather than stacking suffixes, so the third copy is
145
+ * "About us (copy) 3" and never "About us (copy) 2 (copy)".
146
+ */
147
+ export function duplicateNameFor(name: string): string {
148
+ const trimmed = name.trim() || "Page";
149
+ const repeat = /^(.*\(copy\))(?: (\d+))?$/.exec(trimmed);
150
+ if (repeat) return `${repeat[1]} ${Number(repeat[2] ?? "1") + 1}`;
151
+ return `${trimmed} (copy)`;
152
+ }