@prenta/admin 2.7.0 → 2.8.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 (163) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/design-system/tokens.css +17 -0
  3. package/design-system/tokens.json +35 -1
  4. package/design-system/tokens.scss +15 -0
  5. package/design-system/tokens.tailwind.cjs +14 -0
  6. package/dist/AdminRoot.d.ts.map +1 -1
  7. package/dist/AdminRoot.js +2 -2
  8. package/dist/AdminRoot.js.map +1 -1
  9. package/dist/__tests__/lib/batch-wizard.test.d.ts +2 -0
  10. package/dist/__tests__/lib/batch-wizard.test.d.ts.map +1 -0
  11. package/dist/__tests__/lib/batch-wizard.test.js +144 -0
  12. package/dist/__tests__/lib/batch-wizard.test.js.map +1 -0
  13. package/dist/__tests__/lib/job-drawer.test.js +2 -0
  14. package/dist/__tests__/lib/job-drawer.test.js.map +1 -1
  15. package/dist/__tests__/lib/job-view.test.js +100 -3
  16. package/dist/__tests__/lib/job-view.test.js.map +1 -1
  17. package/dist/__tests__/lib/jobs-service.test.js +56 -2
  18. package/dist/__tests__/lib/jobs-service.test.js.map +1 -1
  19. package/dist/__tests__/views/appearance-chrome.render.test.d.ts +2 -0
  20. package/dist/__tests__/views/appearance-chrome.render.test.d.ts.map +1 -0
  21. package/dist/__tests__/views/appearance-chrome.render.test.js +688 -0
  22. package/dist/__tests__/views/appearance-chrome.render.test.js.map +1 -0
  23. package/dist/__tests__/views/header-footer-form.test.d.ts +2 -0
  24. package/dist/__tests__/views/header-footer-form.test.d.ts.map +1 -0
  25. package/dist/__tests__/views/header-footer-form.test.js +266 -0
  26. package/dist/__tests__/views/header-footer-form.test.js.map +1 -0
  27. package/dist/__tests__/views/jobs-drawer.render.test.js +442 -2
  28. package/dist/__tests__/views/jobs-drawer.render.test.js.map +1 -1
  29. package/dist/__tests__/views/jobs-list.render.test.js +7 -5
  30. package/dist/__tests__/views/jobs-list.render.test.js.map +1 -1
  31. package/dist/__tests__/views/jobs-wizard.render.test.d.ts +2 -0
  32. package/dist/__tests__/views/jobs-wizard.render.test.d.ts.map +1 -0
  33. package/dist/__tests__/views/jobs-wizard.render.test.js +588 -0
  34. package/dist/__tests__/views/jobs-wizard.render.test.js.map +1 -0
  35. package/dist/lib/api.d.ts +2 -0
  36. package/dist/lib/api.d.ts.map +1 -1
  37. package/dist/lib/api.js +8 -1
  38. package/dist/lib/api.js.map +1 -1
  39. package/dist/lib/batch-wizard.d.ts +84 -0
  40. package/dist/lib/batch-wizard.d.ts.map +1 -0
  41. package/dist/lib/batch-wizard.js +122 -0
  42. package/dist/lib/batch-wizard.js.map +1 -0
  43. package/dist/lib/chrome-service.d.ts +69 -0
  44. package/dist/lib/chrome-service.d.ts.map +1 -0
  45. package/dist/lib/chrome-service.js +108 -0
  46. package/dist/lib/chrome-service.js.map +1 -0
  47. package/dist/lib/job-view.d.ts +43 -0
  48. package/dist/lib/job-view.d.ts.map +1 -1
  49. package/dist/lib/job-view.js +57 -1
  50. package/dist/lib/job-view.js.map +1 -1
  51. package/dist/lib/jobs-service.d.ts +19 -1
  52. package/dist/lib/jobs-service.d.ts.map +1 -1
  53. package/dist/lib/jobs-service.js +41 -0
  54. package/dist/lib/jobs-service.js.map +1 -1
  55. package/dist/prenta-admin.css +1 -1
  56. package/dist/views/AppearanceChrome.d.ts +22 -4
  57. package/dist/views/AppearanceChrome.d.ts.map +1 -1
  58. package/dist/views/AppearanceChrome.js +451 -15
  59. package/dist/views/AppearanceChrome.js.map +1 -1
  60. package/dist/views/Jobs/CancelJobDialog.d.ts +7 -1
  61. package/dist/views/Jobs/CancelJobDialog.d.ts.map +1 -1
  62. package/dist/views/Jobs/CancelJobDialog.js +13 -3
  63. package/dist/views/Jobs/CancelJobDialog.js.map +1 -1
  64. package/dist/views/Jobs/DrawerReview.d.ts +7 -0
  65. package/dist/views/Jobs/DrawerReview.d.ts.map +1 -1
  66. package/dist/views/Jobs/DrawerReview.js +1 -1
  67. package/dist/views/Jobs/DrawerReview.js.map +1 -1
  68. package/dist/views/Jobs/JobDrawer.d.ts +23 -3
  69. package/dist/views/Jobs/JobDrawer.d.ts.map +1 -1
  70. package/dist/views/Jobs/JobDrawer.js +30 -5
  71. package/dist/views/Jobs/JobDrawer.js.map +1 -1
  72. package/dist/views/Jobs/JobsTable.d.ts +4 -2
  73. package/dist/views/Jobs/JobsTable.d.ts.map +1 -1
  74. package/dist/views/Jobs/JobsTable.js +6 -5
  75. package/dist/views/Jobs/JobsTable.js.map +1 -1
  76. package/dist/views/Jobs/NewBatchJob.d.ts +34 -0
  77. package/dist/views/Jobs/NewBatchJob.d.ts.map +1 -0
  78. package/dist/views/Jobs/NewBatchJob.js +282 -0
  79. package/dist/views/Jobs/NewBatchJob.js.map +1 -0
  80. package/dist/views/Jobs/WorkerStrip.d.ts +3 -1
  81. package/dist/views/Jobs/WorkerStrip.d.ts.map +1 -1
  82. package/dist/views/Jobs/WorkerStrip.js +2 -2
  83. package/dist/views/Jobs/WorkerStrip.js.map +1 -1
  84. package/dist/views/Jobs/focus.d.ts +6 -0
  85. package/dist/views/Jobs/focus.d.ts.map +1 -1
  86. package/dist/views/Jobs/focus.js +10 -0
  87. package/dist/views/Jobs/focus.js.map +1 -1
  88. package/dist/views/Jobs.d.ts +12 -3
  89. package/dist/views/Jobs.d.ts.map +1 -1
  90. package/dist/views/Jobs.js +109 -31
  91. package/dist/views/Jobs.js.map +1 -1
  92. package/dist/views/header-footer/AnnouncementTab.d.ts +15 -0
  93. package/dist/views/header-footer/AnnouncementTab.d.ts.map +1 -0
  94. package/dist/views/header-footer/AnnouncementTab.js +43 -0
  95. package/dist/views/header-footer/AnnouncementTab.js.map +1 -0
  96. package/dist/views/header-footer/ChromeField.d.ts +25 -0
  97. package/dist/views/header-footer/ChromeField.d.ts.map +1 -0
  98. package/dist/views/header-footer/ChromeField.js +99 -0
  99. package/dist/views/header-footer/ChromeField.js.map +1 -0
  100. package/dist/views/header-footer/ChromePreview.d.ts +31 -0
  101. package/dist/views/header-footer/ChromePreview.d.ts.map +1 -0
  102. package/dist/views/header-footer/ChromePreview.js +195 -0
  103. package/dist/views/header-footer/ChromePreview.js.map +1 -0
  104. package/dist/views/header-footer/DeveloperTab.d.ts +17 -0
  105. package/dist/views/header-footer/DeveloperTab.d.ts.map +1 -0
  106. package/dist/views/header-footer/DeveloperTab.js +98 -0
  107. package/dist/views/header-footer/DeveloperTab.js.map +1 -0
  108. package/dist/views/header-footer/RegionTab.d.ts +27 -0
  109. package/dist/views/header-footer/RegionTab.d.ts.map +1 -0
  110. package/dist/views/header-footer/RegionTab.js +80 -0
  111. package/dist/views/header-footer/RegionTab.js.map +1 -0
  112. package/dist/views/header-footer/chrome-form.d.ts +137 -0
  113. package/dist/views/header-footer/chrome-form.d.ts.map +1 -0
  114. package/dist/views/header-footer/chrome-form.js +518 -0
  115. package/dist/views/header-footer/chrome-form.js.map +1 -0
  116. package/dist/views/header-footer/code-samples.d.ts +30 -0
  117. package/dist/views/header-footer/code-samples.d.ts.map +1 -0
  118. package/dist/views/header-footer/code-samples.js +127 -0
  119. package/dist/views/header-footer/code-samples.js.map +1 -0
  120. package/dist/views/header-footer/controls.d.ts +57 -0
  121. package/dist/views/header-footer/controls.d.ts.map +1 -0
  122. package/dist/views/header-footer/controls.js +43 -0
  123. package/dist/views/header-footer/controls.js.map +1 -0
  124. package/package.json +3 -3
  125. package/src/AdminRoot.tsx +16 -2
  126. package/src/__tests__/lib/batch-wizard.test.ts +199 -0
  127. package/src/__tests__/lib/job-drawer.test.ts +2 -0
  128. package/src/__tests__/lib/job-view.test.ts +123 -2
  129. package/src/__tests__/lib/jobs-service.test.ts +72 -3
  130. package/src/__tests__/views/appearance-chrome.render.test.tsx +818 -0
  131. package/src/__tests__/views/header-footer-form.test.ts +333 -0
  132. package/src/__tests__/views/jobs-drawer.render.test.tsx +508 -2
  133. package/src/__tests__/views/jobs-list.render.test.tsx +8 -5
  134. package/src/__tests__/views/jobs-wizard.render.test.tsx +707 -0
  135. package/src/lib/api.ts +12 -1
  136. package/src/lib/batch-wizard.ts +188 -0
  137. package/src/lib/chrome-service.ts +200 -0
  138. package/src/lib/job-view.ts +95 -1
  139. package/src/lib/jobs-service.ts +56 -1
  140. package/src/styles/prenta-tokens.css +17 -0
  141. package/src/styles/theme.css +15 -0
  142. package/src/views/AppearanceChrome.tsx +704 -46
  143. package/src/views/Jobs/CancelJobDialog.tsx +20 -2
  144. package/src/views/Jobs/DrawerReview.tsx +1 -1
  145. package/src/views/Jobs/JobDrawer.tsx +83 -3
  146. package/src/views/Jobs/JobsTable.tsx +11 -4
  147. package/src/views/Jobs/NewBatchJob.tsx +857 -0
  148. package/src/views/Jobs/WorkerStrip.tsx +16 -11
  149. package/src/views/Jobs/focus.ts +11 -0
  150. package/src/views/Jobs.tsx +163 -49
  151. package/src/views/header-footer/AnnouncementTab.tsx +231 -0
  152. package/src/views/header-footer/ChromeField.tsx +358 -0
  153. package/src/views/header-footer/ChromePreview.tsx +497 -0
  154. package/src/views/header-footer/DeveloperTab.tsx +331 -0
  155. package/src/views/header-footer/RegionTab.tsx +398 -0
  156. package/src/views/header-footer/chrome-form.ts +603 -0
  157. package/src/views/header-footer/code-samples.ts +146 -0
  158. package/src/views/header-footer/controls.tsx +166 -0
  159. package/dist/views/Jobs/placeholders.d.ts +0 -6
  160. package/dist/views/Jobs/placeholders.d.ts.map +0 -1
  161. package/dist/views/Jobs/placeholders.js +0 -20
  162. package/dist/views/Jobs/placeholders.js.map +0 -1
  163. package/src/views/Jobs/placeholders.tsx +0 -66
@@ -0,0 +1,603 @@
1
+ /**
2
+ * Pure helpers for Appearance → Header & Footer: form values built from the
3
+ * region schema (never from a fixed field list), save bodies, the page
4
+ * override chips, and the draft chrome the preview renders.
5
+ */
6
+ import type {
7
+ Announcement,
8
+ AnnouncementTone,
9
+ ChromePageOverrides,
10
+ ChromeRegionName,
11
+ FieldDef,
12
+ RegionSchema,
13
+ RegionSchemaSource,
14
+ RegionValues,
15
+ } from '@prenta/core'
16
+
17
+ import type {
18
+ AnnouncementSaveBody,
19
+ ChromeMenuItem,
20
+ ChromeMenuOption,
21
+ RegionSaveBody,
22
+ } from '../../lib/chrome-service.js'
23
+
24
+ /** The announcement message limit (DATA_CONTRACTS §2). */
25
+ export const ANNOUNCEMENT_MAX = 110
26
+ /** The link text limit `PUT /chrome/announcement` enforces. */
27
+ export const ANNOUNCEMENT_LINK_MAX = 40
28
+ /** How many menus a list field holds when the schema sets no `max` (core's `MAX_MENU_LIST`). */
29
+ const MENU_LIST_MAX = 20
30
+
31
+ /**
32
+ * The menu pickers a list field shows: three to start (the kit's footer
33
+ * columns), one more than the list holds as it fills, up to `max`, and never
34
+ * fewer than are saved, so every saved menu can be cleared.
35
+ */
36
+ export function menuListSlots(def: { max?: number }, length: number): number {
37
+ const max = def.max ?? MENU_LIST_MAX
38
+ return Math.max(length, Math.min(max, Math.max(3, length + 1)))
39
+ }
40
+
41
+ export const ANNOUNCEMENT_TONES: ReadonlyArray<{ id: AnnouncementTone; label: string }> = [
42
+ { id: 'brand', label: 'Brand' },
43
+ { id: 'dark', label: 'Dark' },
44
+ { id: 'alert', label: 'Alert' },
45
+ ]
46
+
47
+ export const SOCIAL_PLATFORMS = ['LinkedIn', 'Facebook', 'Instagram', 'X', 'YouTube', 'TikTok']
48
+
49
+ export interface RegionForm {
50
+ variant: string
51
+ fields: Record<string, unknown>
52
+ }
53
+
54
+ type Rec = Record<string, unknown>
55
+
56
+ function isRec(value: unknown): value is Rec {
57
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
58
+ }
59
+
60
+ function str(value: unknown): string {
61
+ return typeof value === 'string' ? value : ''
62
+ }
63
+
64
+ /** A schema's fields in the order the site wrote them, own properties only. */
65
+ export function fieldEntries(schema: Pick<RegionSchema, 'fields'>): Array<[string, FieldDef]> {
66
+ return Object.keys(schema.fields)
67
+ .filter((key) => Object.hasOwn(schema.fields, key))
68
+ .map((key) => [key, schema.fields[key]!])
69
+ }
70
+
71
+ /** Optional fields store `{ on, ...value }` and show a switch. */
72
+ export function isToggled(def: FieldDef): boolean {
73
+ if (def.type === 'menu') return Boolean(def.optional) && !def.list
74
+ return (def.type === 'link' || def.type === 'text') && Boolean(def.optional)
75
+ }
76
+
77
+ /** The field types this admin can edit. A newer schema may send others. */
78
+ const KNOWN_TYPES = new Set([
79
+ 'brand',
80
+ 'menu',
81
+ 'link',
82
+ 'text',
83
+ 'textarea',
84
+ 'select',
85
+ 'boolean',
86
+ 'social',
87
+ ])
88
+
89
+ export function isKnownField(def: FieldDef): boolean {
90
+ return KNOWN_TYPES.has((def as { type: string }).type)
91
+ }
92
+
93
+ /** `topMenu` → "Top menu", for fields without a `label`. */
94
+ export function humanizeKey(key: string): string {
95
+ const words = key
96
+ .replace(/[_-]+/g, ' ')
97
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
98
+ .trim()
99
+ .toLowerCase()
100
+ return words ? words[0]!.toUpperCase() + words.slice(1) : key
101
+ }
102
+
103
+ export function fieldLabel(key: string, def: FieldDef): string {
104
+ return def.label?.trim() || humanizeKey(key)
105
+ }
106
+
107
+ /** The help line under a field's label: the schema's, or what the CMS fills in. */
108
+ export function fieldHelp(def: FieldDef): string | null {
109
+ if (def.help?.trim()) return def.help.trim()
110
+ if (def.type === 'text' && def.tokens?.includes('{year}')) return '{year} updates automatically.'
111
+ if (def.type === 'menu' && def.list) {
112
+ return `Up to ${def.max ?? MENU_LIST_MAX} menus. Column headings use the menu name.`
113
+ }
114
+ return null
115
+ }
116
+
117
+ /** What a field shows when nothing is saved, in the stored shape. */
118
+ function defaultValue(def: FieldDef): unknown {
119
+ switch (def.type) {
120
+ case 'menu':
121
+ if (def.list) return def.default ? [def.default] : []
122
+ return def.optional ? { on: false, value: def.default ?? '' } : (def.default ?? '')
123
+ case 'link':
124
+ return def.optional ? { on: false, label: '', url: '' } : { label: '', url: '' }
125
+ case 'text':
126
+ return def.optional ? { on: false, value: '' } : ''
127
+ case 'textarea':
128
+ return ''
129
+ case 'select':
130
+ return def.default ?? def.options[0] ?? ''
131
+ case 'boolean':
132
+ return def.default ?? false
133
+ case 'social':
134
+ return []
135
+ default:
136
+ return undefined
137
+ }
138
+ }
139
+
140
+ /** A saved value in the shape the field edits, or its default when it doesn't fit. */
141
+ function formValue(def: FieldDef, saved: unknown): unknown {
142
+ if (saved === undefined) return defaultValue(def)
143
+ switch (def.type) {
144
+ case 'menu':
145
+ if (def.list) {
146
+ return Array.isArray(saved) ? saved.filter((slug) => typeof slug === 'string') : []
147
+ }
148
+ if (def.optional) {
149
+ return isRec(saved) && typeof saved.on === 'boolean'
150
+ ? { on: saved.on, value: str(saved.value) }
151
+ : defaultValue(def)
152
+ }
153
+ return typeof saved === 'string' ? saved : defaultValue(def)
154
+ case 'link': {
155
+ const link = isRec(saved) ? { label: str(saved.label), url: str(saved.url) } : null
156
+ if (!link) return defaultValue(def)
157
+ return def.optional ? { on: isRec(saved) && saved.on === true, ...link } : link
158
+ }
159
+ case 'text':
160
+ if (def.optional) {
161
+ return isRec(saved) && typeof saved.on === 'boolean'
162
+ ? { on: saved.on, value: str(saved.value) }
163
+ : defaultValue(def)
164
+ }
165
+ return typeof saved === 'string' ? saved : ''
166
+ case 'textarea':
167
+ return typeof saved === 'string' ? saved : ''
168
+ case 'select':
169
+ return typeof saved === 'string' && def.options.includes(saved) ? saved : defaultValue(def)
170
+ case 'boolean':
171
+ return typeof saved === 'boolean' ? saved : defaultValue(def)
172
+ case 'social':
173
+ return Array.isArray(saved)
174
+ ? saved.filter(isRec).map((item) => ({ platform: str(item.platform), url: str(item.url) }))
175
+ : []
176
+ default:
177
+ // A type this admin doesn't know: keep what's saved and send it back.
178
+ return saved
179
+ }
180
+ }
181
+
182
+ /** The form for a region: the saved variant and every field, defaults filled in. */
183
+ export function regionForm(schema: RegionSchema, values: RegionValues | null): RegionForm {
184
+ const variants = Object.keys(schema.variants)
185
+ const variant =
186
+ values && Object.hasOwn(schema.variants, values.variant) ? values.variant : (variants[0] ?? '')
187
+ const fields: Rec = {}
188
+ for (const [key, def] of fieldEntries(schema)) {
189
+ if (def.type === 'brand') continue
190
+ const saved = values && Object.hasOwn(values.fields, key) ? values.fields[key] : undefined
191
+ const value = formValue(def, saved)
192
+ if (value !== undefined) fields[key] = value
193
+ }
194
+ return { variant, fields }
195
+ }
196
+
197
+ /**
198
+ * The value `PUT /chrome/:region` stores for one field. A required menu or
199
+ * link left empty is left out (the site then gets its default or null), and
200
+ * empty rows are dropped from lists.
201
+ */
202
+ function saveValue(def: FieldDef, value: unknown): unknown {
203
+ switch (def.type) {
204
+ case 'menu':
205
+ if (def.list) {
206
+ return Array.isArray(value)
207
+ ? [...new Set(value.filter((slug): slug is string => typeof slug === 'string' && !!slug))]
208
+ : []
209
+ }
210
+ if (def.optional) return value
211
+ return typeof value === 'string' && value ? value : null
212
+ case 'link':
213
+ if (def.optional) return value
214
+ return isRec(value) && (str(value.url).trim() || str(value.label).trim()) ? value : null
215
+ case 'social':
216
+ return Array.isArray(value)
217
+ ? value.filter((row) => isRec(row) && (str(row.url).trim() || str(row.platform).trim()))
218
+ : []
219
+ default:
220
+ return value
221
+ }
222
+ }
223
+
224
+ /**
225
+ * The `PUT /chrome/:region` body. A field with nothing saved whose value is
226
+ * still its default is left out, so the site keeps following the default in
227
+ * its code if the developer changes it later.
228
+ */
229
+ export function regionSaveBody(
230
+ schema: RegionSchema,
231
+ form: RegionForm,
232
+ saved: Record<string, unknown> = {},
233
+ ): RegionSaveBody {
234
+ const fields: Rec = {}
235
+ for (const [key, def] of fieldEntries(schema)) {
236
+ if (def.type === 'brand' || !Object.hasOwn(form.fields, key)) continue
237
+ if (!Object.hasOwn(saved, key) && sameValue(form.fields[key], defaultValue(def))) continue
238
+ const value = saveValue(def, form.fields[key])
239
+ if (value !== null && value !== undefined) fields[key] = value
240
+ }
241
+ return { variant: form.variant, fields }
242
+ }
243
+
244
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
245
+ const PHONE = /^[+()0-9.\-\s]{3,40}$/
246
+ const LINK_PROBLEM = 'Links can be a site path or an http(s), mailto or tel link.'
247
+
248
+ function linkProblem(value: unknown, required: boolean): string | null {
249
+ if (!isRec(value)) return null
250
+ const url = str(value.url).trim()
251
+ if (str(value.label).length > 80) return 'Keep the button text to 80 characters.'
252
+ if (!url) return required ? 'Add the link URL.' : null
253
+ return isSafeLinkUrl(url) ? null : LINK_PROBLEM
254
+ }
255
+
256
+ /**
257
+ * Why a field's value can't be saved, or null. Mirrors the checks
258
+ * `PUT /chrome/:region` makes, so the form says what's wrong before a 400.
259
+ */
260
+ export function fieldProblem(def: FieldDef, value: unknown): string | null {
261
+ switch (def.type) {
262
+ case 'menu':
263
+ if (def.list) {
264
+ const max = def.max ?? MENU_LIST_MAX
265
+ return Array.isArray(value) && value.filter(Boolean).length > max
266
+ ? `Pick at most ${max} menus.`
267
+ : null
268
+ }
269
+ if (def.optional && isRec(value) && value.on && !str(value.value)) {
270
+ return 'Pick a menu, or turn this off.'
271
+ }
272
+ return null
273
+ case 'link':
274
+ if (def.optional) return isRec(value) ? linkProblem(value, value.on === true) : null
275
+ return isRec(value) && (str(value.label).trim() || str(value.url).trim())
276
+ ? linkProblem(value, true)
277
+ : null
278
+ case 'text': {
279
+ const text = def.optional ? (isRec(value) ? str(value.value) : '') : str(value)
280
+ const max = def.max ?? 500
281
+ if (text.length > max) return `Keep it to ${max} characters.`
282
+ const trimmed = text.trim()
283
+ if (trimmed && def.format === 'email' && !EMAIL.test(trimmed))
284
+ return 'Enter an email address.'
285
+ if (trimmed && def.format === 'tel' && !PHONE.test(trimmed)) return 'Enter a phone number.'
286
+ return null
287
+ }
288
+ case 'textarea': {
289
+ const max = def.max ?? 2000
290
+ return str(value).length > max ? `Keep it to ${max} characters.` : null
291
+ }
292
+ case 'social':
293
+ if (!Array.isArray(value)) return null
294
+ for (const row of value) {
295
+ if (!isRec(row) || !(str(row.url).trim() || str(row.platform).trim())) continue
296
+ if (!str(row.platform).trim()) return 'Pick a platform for each link.'
297
+ const problem = linkProblem({ label: '', url: row.url }, true)
298
+ if (problem) return problem
299
+ }
300
+ return null
301
+ default:
302
+ return null
303
+ }
304
+ }
305
+
306
+ /** Every field that can't be saved yet, by key. */
307
+ export function regionProblems(schema: RegionSchema, form: RegionForm): Record<string, string> {
308
+ const problems: Record<string, string> = {}
309
+ for (const [key, def] of fieldEntries(schema)) {
310
+ const problem = fieldProblem(def, form.fields[key])
311
+ if (problem) problems[key] = problem
312
+ }
313
+ return problems
314
+ }
315
+
316
+ /** Forms are built in schema order, so JSON compares them faithfully. */
317
+ export function sameValue(a: unknown, b: unknown): boolean {
318
+ return JSON.stringify(a) === JSON.stringify(b)
319
+ }
320
+
321
+ // ─── Announcement ───────────────────────────────────────────────────────
322
+
323
+ export interface AnnouncementForm {
324
+ on: boolean
325
+ text: string
326
+ linkLabel: string
327
+ linkUrl: string
328
+ tone: AnnouncementTone
329
+ dismissible: boolean
330
+ scheduled: boolean
331
+ /** `datetime-local` values, in the editor's time zone. */
332
+ startsAt: string
333
+ endsAt: string
334
+ /**
335
+ * The saved times. A `datetime-local` value can't tell the two 1:30s of a
336
+ * clock-change night apart, so an untouched field sends these back as they were.
337
+ */
338
+ savedStartsAt: string | null
339
+ savedEndsAt: string | null
340
+ }
341
+
342
+ function pad(value: number): string {
343
+ return String(value).padStart(2, '0')
344
+ }
345
+
346
+ /** An ISO time as a `datetime-local` value in the browser's zone. */
347
+ export function toLocalInput(iso: string | null): string {
348
+ if (!iso) return ''
349
+ const at = new Date(iso)
350
+ if (Number.isNaN(at.getTime())) return ''
351
+ return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}T${pad(at.getHours())}:${pad(at.getMinutes())}`
352
+ }
353
+
354
+ /**
355
+ * A `datetime-local` value (local time, no zone) as the ISO time with a zone
356
+ * the API needs, or null when it's empty or not a time.
357
+ */
358
+ export function fromLocalInput(value: string, saved: string | null = null): string | null {
359
+ if (!value) return null
360
+ if (saved && toLocalInput(saved) === value) return saved
361
+ const at = new Date(value)
362
+ return Number.isNaN(at.getTime()) ? null : at.toISOString()
363
+ }
364
+
365
+ export function announcementForm(announcement: Announcement): AnnouncementForm {
366
+ return {
367
+ on: announcement.on,
368
+ text: announcement.text,
369
+ linkLabel: announcement.link?.label ?? '',
370
+ linkUrl: announcement.link?.url ?? '',
371
+ tone: announcement.tone,
372
+ dismissible: announcement.dismissible,
373
+ scheduled: announcement.schedule !== null,
374
+ startsAt: toLocalInput(announcement.schedule?.startsAt ?? null),
375
+ endsAt: toLocalInput(announcement.schedule?.endsAt ?? null),
376
+ savedStartsAt: announcement.schedule?.startsAt ?? null,
377
+ savedEndsAt: announcement.schedule?.endsAt ?? null,
378
+ }
379
+ }
380
+
381
+ export function announcementSaveBody(form: AnnouncementForm): AnnouncementSaveBody {
382
+ const url = form.linkUrl.trim()
383
+ const startsAt = form.scheduled ? fromLocalInput(form.startsAt, form.savedStartsAt) : null
384
+ const endsAt = form.scheduled ? fromLocalInput(form.endsAt, form.savedEndsAt) : null
385
+ return {
386
+ on: form.on,
387
+ text: form.text,
388
+ link: url || form.linkLabel.trim() ? { label: form.linkLabel, url } : null,
389
+ tone: form.tone,
390
+ dismissible: form.dismissible,
391
+ schedule: startsAt || endsAt ? { startsAt, endsAt } : null,
392
+ }
393
+ }
394
+
395
+ /** Why the announcement can't be saved yet, or null. Mirrors the API's checks. */
396
+ export function announcementProblem(form: AnnouncementForm): string | null {
397
+ if (form.text.length > ANNOUNCEMENT_MAX) {
398
+ return `Keep the message to ${ANNOUNCEMENT_MAX} characters.`
399
+ }
400
+ if (form.on && !form.text.trim()) return 'Add a message before turning the bar on.'
401
+ if (form.linkLabel.length > ANNOUNCEMENT_LINK_MAX) {
402
+ return `Keep the link text to ${ANNOUNCEMENT_LINK_MAX} characters.`
403
+ }
404
+ if (form.linkLabel.trim() && !form.linkUrl.trim())
405
+ return 'Add the link URL, or clear the link text.'
406
+ if (form.linkUrl.trim() && !isSafeLinkUrl(form.linkUrl.trim())) {
407
+ return 'Links can be a site path or an http(s), mailto or tel link.'
408
+ }
409
+ if (form.scheduled) {
410
+ const starts = fromLocalInput(form.startsAt, form.savedStartsAt)
411
+ const ends = fromLocalInput(form.endsAt, form.savedEndsAt)
412
+ if (!starts && !ends) return 'Pick when the bar starts or ends, or turn the schedule off.'
413
+ if (starts && ends && Date.parse(ends) <= Date.parse(starts)) {
414
+ return 'The end has to be after the start.'
415
+ }
416
+ }
417
+ return null
418
+ }
419
+
420
+ /** Links an editor may set (the API's `isSafeLinkUrl`). */
421
+ function isSafeLinkUrl(url: string): boolean {
422
+ if (url.length > 2048) return false
423
+ if (url.startsWith('/') || url.startsWith('#') || url.startsWith('?')) return true
424
+ return /^(https?:\/\/|mailto:|tel:)/i.test(url)
425
+ }
426
+
427
+ // ─── Page overrides ─────────────────────────────────────────────────────
428
+
429
+ /**
430
+ * The mono chips for one page's override of one region, as the kit writes
431
+ * them: `header:minimal`, `header.cta: off`, `footer: none`.
432
+ */
433
+ export function overrideChips(page: ChromePageOverrides, region: ChromeRegionName): string[] {
434
+ const override = page[region]
435
+ if (override === undefined) return []
436
+ if (override === null) return [`${region}: none`]
437
+ const chips: string[] = []
438
+ if (override.variant) chips.push(`${region}:${override.variant}`)
439
+ for (const [key, value] of Object.entries(override.fields ?? {})) {
440
+ chips.push(isRec(value) && value.on === false ? `${region}.${key}: off` : `${region}.${key}`)
441
+ }
442
+ return chips.length > 0 ? chips : [`${region}: inherits`]
443
+ }
444
+
445
+ // ─── Source card ────────────────────────────────────────────────────────
446
+
447
+ /** `./src/components/site/Header.astro` → `src/components/site/Header.astro`. */
448
+ export function componentPath(component: string): string {
449
+ return component.replace(/^\.\//, '')
450
+ }
451
+
452
+ export function shortCommit(commit: string): string {
453
+ return /^[0-9a-f]{8,}$/i.test(commit) ? commit.slice(0, 7) : commit
454
+ }
455
+
456
+ /**
457
+ * "View in repo ↗". `prenta push chrome` stores the repo as `owner/name`
458
+ * without its host, so this assumes GitHub; a full http(s) URL passed with
459
+ * `--repo` is used as it is.
460
+ */
461
+ export function repoFileUrl(source: RegionSchemaSource, component: string): string | null {
462
+ const repo = source.repo.trim()
463
+ const path = componentPath(component).split('/').filter(Boolean).map(encodeURIComponent).join('/')
464
+ const ref = encodeURIComponent(source.commit || source.branch || 'HEAD')
465
+ if (/^[\w.-]+\/[\w.-]+$/.test(repo)) {
466
+ return `https://github.com/${repo}/blob/${ref}/${path}`
467
+ }
468
+ if (/^https?:\/\//i.test(repo)) {
469
+ try {
470
+ const url = new URL(repo.replace(/\.git$/, ''))
471
+ url.pathname = `${url.pathname.replace(/\/+$/, '')}/blob/${ref}/${path}`
472
+ return url.toString()
473
+ } catch {
474
+ return null
475
+ }
476
+ }
477
+ return null
478
+ }
479
+
480
+ /** "just now", "5 minutes ago", "2 hours ago", "3 days ago", then the date. */
481
+ export function timeAgo(iso: string | null | undefined, now: number): string {
482
+ if (!iso) return 'never'
483
+ const at = Date.parse(iso)
484
+ if (!Number.isFinite(at)) return 'never'
485
+ const seconds = Math.max(0, Math.round((now - at) / 1000))
486
+ const plural = (n: number, unit: string) => `${n} ${unit}${n === 1 ? '' : 's'} ago`
487
+ if (seconds < 60) return 'just now'
488
+ if (seconds < 3600) return plural(Math.floor(seconds / 60), 'minute')
489
+ if (seconds < 86_400) return plural(Math.floor(seconds / 3600), 'hour')
490
+ if (seconds < 30 * 86_400) return plural(Math.floor(seconds / 86_400), 'day')
491
+ return new Date(at).toLocaleDateString(undefined, {
492
+ day: 'numeric',
493
+ month: 'short',
494
+ year: 'numeric',
495
+ })
496
+ }
497
+
498
+ // ─── Draft chrome for the preview ───────────────────────────────────────
499
+
500
+ /** A menu as the site's components receive it (`ChromeMenu`). */
501
+ interface PreviewMenu {
502
+ id: string
503
+ name: string
504
+ items: Array<{ label: string; url: string; target?: '_blank' | '_self'; children?: unknown[] }>
505
+ }
506
+
507
+ function publicItems(items: ChromeMenuItem[]): PreviewMenu['items'] {
508
+ return items.map((item) => ({
509
+ label: item.label,
510
+ url: item.url,
511
+ ...(item.target ? { target: item.target } : {}),
512
+ ...(item.children.length > 0 ? { children: publicItems(item.children) } : {}),
513
+ }))
514
+ }
515
+
516
+ function publicMenu(menus: ChromeMenuOption[], slug: unknown): PreviewMenu | null {
517
+ const menu = typeof slug === 'string' && slug ? menus.find((m) => m.slug === slug) : undefined
518
+ return menu ? { id: menu.slug, name: menu.name, items: publicItems(menu.items) } : null
519
+ }
520
+
521
+ export interface PreviewContext {
522
+ menus: ChromeMenuOption[]
523
+ siteTitle: string
524
+ now: Date
525
+ }
526
+
527
+ /**
528
+ * One field as `/public/chrome` would send it, so a Preview URL page can
529
+ * render the site's real components with the draft. Brand logos aren't
530
+ * resolved here (null); the site name is.
531
+ */
532
+ function publicField(def: FieldDef, value: unknown, ctx: PreviewContext): unknown {
533
+ switch (def.type) {
534
+ case 'brand':
535
+ return def.key === 'logo' ? null : ctx.siteTitle
536
+ case 'menu':
537
+ if (def.list) {
538
+ return (Array.isArray(value) ? value : [])
539
+ .map((slug) => publicMenu(ctx.menus, slug))
540
+ .filter(Boolean)
541
+ }
542
+ if (def.optional) return isRec(value) && value.on ? publicMenu(ctx.menus, value.value) : null
543
+ return publicMenu(ctx.menus, value || def.default)
544
+ case 'link': {
545
+ if (def.optional && !(isRec(value) && value.on)) return null
546
+ return isRec(value) && str(value.url)
547
+ ? { label: str(value.label), url: str(value.url) }
548
+ : null
549
+ }
550
+ case 'text': {
551
+ const year = String(ctx.now.getFullYear())
552
+ const fill = (text: string) =>
553
+ def.tokens?.includes('{year}') ? text.split('{year}').join(year) : text
554
+ if (def.optional) return isRec(value) && value.on ? fill(str(value.value)) : null
555
+ return fill(str(value))
556
+ }
557
+ default:
558
+ return value
559
+ }
560
+ }
561
+
562
+ export interface PreviewChrome {
563
+ header?: { variant: string; fields: Rec } | null
564
+ footer?: { variant: string; fields: Rec } | null
565
+ announcement: {
566
+ id: string
567
+ text: string
568
+ link: { label: string; url: string } | null
569
+ tone: AnnouncementTone
570
+ dismissible: boolean
571
+ endsAt: string | null
572
+ } | null
573
+ }
574
+
575
+ /** The draft as the `/public/chrome` shape, for `postMessage` to a Preview URL page. */
576
+ export function previewChrome(
577
+ regions: Partial<Record<ChromeRegionName, { schema: RegionSchema; form: RegionForm }>>,
578
+ announcement: { id: string; form: AnnouncementForm } | null,
579
+ ctx: PreviewContext,
580
+ ): PreviewChrome {
581
+ const out: PreviewChrome = { announcement: null }
582
+ for (const [region, entry] of Object.entries(regions) as Array<
583
+ [ChromeRegionName, { schema: RegionSchema; form: RegionForm }]
584
+ >) {
585
+ const fields: Rec = {}
586
+ for (const [key, def] of fieldEntries(entry.schema)) {
587
+ fields[key] = publicField(def, entry.form.fields[key] ?? defaultValue(def), ctx)
588
+ }
589
+ out[region] = { variant: entry.form.variant, fields }
590
+ }
591
+ if (announcement && announcement.form.on && announcement.form.text.trim()) {
592
+ const body = announcementSaveBody(announcement.form)
593
+ out.announcement = {
594
+ id: announcement.id,
595
+ text: body.text,
596
+ link: body.link?.url ? body.link : null,
597
+ tone: body.tone,
598
+ dismissible: body.dismissible,
599
+ endsAt: body.schedule?.endsAt ?? null,
600
+ }
601
+ }
602
+ return out
603
+ }