@acms-subir/core 0.3.3-next.20260929113209 → 0.3.4

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 (289) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/legacy.mjs +1 -1
  4. package/cli/main.mjs +82 -8
  5. package/cli/mobile-doctor.mjs +430 -0
  6. package/cli/mobile.mjs +315 -0
  7. package/cli/native-sections.mjs +168 -0
  8. package/cli/preview.mjs +8 -4
  9. package/cli/sections.mjs +188 -0
  10. package/cli/themes.mjs +52 -8
  11. package/docs/mobile.md +527 -0
  12. package/docs/sections.md +203 -0
  13. package/docs/theme-authoring.md +19 -3
  14. package/integration.mjs +76 -5
  15. package/mobile-template/client/AGENTS.md +97 -0
  16. package/mobile-template/client/README.md +65 -0
  17. package/mobile-template/client/app/_layout.tsx +56 -0
  18. package/mobile-template/client/app/index.tsx +21 -0
  19. package/mobile-template/client/app.json +21 -0
  20. package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
  21. package/mobile-template/client/eas.json +21 -0
  22. package/mobile-template/client/env.example +3 -0
  23. package/mobile-template/client/gitignore +42 -0
  24. package/mobile-template/client/package.json +38 -0
  25. package/mobile-template/client/sections/index.ts +11 -0
  26. package/mobile-template/client/tsconfig.json +7 -0
  27. package/mobile-template/editor/AGENTS.md +66 -0
  28. package/mobile-template/editor/README.md +73 -0
  29. package/mobile-template/editor/app/_layout.tsx +76 -0
  30. package/mobile-template/editor/app/auth.tsx +13 -0
  31. package/mobile-template/editor/app/index.tsx +55 -0
  32. package/mobile-template/editor/app.json +21 -0
  33. package/mobile-template/editor/components/Screen.tsx +51 -0
  34. package/mobile-template/editor/components/SignIn.tsx +36 -0
  35. package/mobile-template/editor/components/TextTabs.tsx +27 -0
  36. package/mobile-template/editor/eas.json +21 -0
  37. package/mobile-template/editor/env.example +5 -0
  38. package/mobile-template/editor/gitignore +42 -0
  39. package/mobile-template/editor/lib/appearance.tsx +12 -0
  40. package/mobile-template/editor/lib/config.ts +17 -0
  41. package/mobile-template/editor/lib/format.ts +24 -0
  42. package/mobile-template/editor/lib/tools.ts +16 -0
  43. package/mobile-template/editor/package.json +40 -0
  44. package/mobile-template/editor/tools/README.md +2 -0
  45. package/mobile-template/editor/tsconfig.json +7 -0
  46. package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
  47. package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
  48. package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
  49. package/mobile-template/recipes/booking/recipe.json +17 -0
  50. package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
  51. package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
  52. package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
  53. package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
  54. package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
  55. package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
  56. package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
  57. package/mobile-template/recipes/editor-content/recipe.json +8 -0
  58. package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
  59. package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
  60. package/mobile-template/recipes/editor-media/recipe.json +8 -0
  61. package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
  62. package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
  63. package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
  64. package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
  65. package/mobile-template/recipes/editor-sections/recipe.json +8 -0
  66. package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
  67. package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
  68. package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
  69. package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
  70. package/mobile-template/recipes/editor-translations/recipe.json +8 -0
  71. package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
  72. package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
  73. package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
  74. package/mobile-template/recipes/editor-writer/recipe.json +8 -0
  75. package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
  76. package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
  77. package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
  78. package/mobile-template/recipes/events/recipe.json +15 -0
  79. package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
  80. package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
  81. package/mobile-template/recipes/news/recipe.json +13 -0
  82. package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
  83. package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
  84. package/mobile-template/recipes/push/files/lib/push.ts +15 -0
  85. package/mobile-template/recipes/push/recipe.json +20 -0
  86. package/package.json +17 -13
  87. package/section-library/README.md +16 -0
  88. package/section-library/contact/Contact.astro +49 -0
  89. package/section-library/contact/section.json +106 -0
  90. package/section-library/cta-band/CtaBand.astro +28 -0
  91. package/section-library/cta-band/section.json +51 -0
  92. package/section-library/feature-grid/FeatureGrid.astro +54 -0
  93. package/section-library/feature-grid/section.json +107 -0
  94. package/section-library/gallery/Gallery.astro +29 -0
  95. package/section-library/gallery/section.json +51 -0
  96. package/section-library/hero-banner/HeroBanner.astro +42 -0
  97. package/section-library/hero-banner/section.json +81 -0
  98. package/section-library/latest-posts/LatestPosts.astro +22 -0
  99. package/section-library/latest-posts/section.json +37 -0
  100. package/section-library/pricing/Pricing.astro +40 -0
  101. package/section-library/pricing/section.json +117 -0
  102. package/section-library/questions/Questions.astro +38 -0
  103. package/section-library/questions/section.json +69 -0
  104. package/section-library/service-times/ServiceTimes.astro +42 -0
  105. package/section-library/service-times/section.json +127 -0
  106. package/section-library/team/Team.astro +45 -0
  107. package/section-library/team/section.json +88 -0
  108. package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
  109. package/section-library/upcoming-events/section.json +51 -0
  110. package/src/actions/index.ts +205 -174
  111. package/src/components/AiSettings.tsx +45 -5
  112. package/src/components/DesignManager.tsx +112 -16
  113. package/src/components/MobileApp.tsx +392 -0
  114. package/src/components/ThemeSections.astro +40 -21
  115. package/src/components/ThemeSectionsEditor.tsx +757 -229
  116. package/src/components/booking/BookingFlow.astro +6 -3
  117. package/src/components/calendar/BookingsHead.astro +51 -0
  118. package/src/components/calendar/CalendarBoard.tsx +618 -0
  119. package/src/components/calendar/MiniMonth.tsx +54 -0
  120. package/src/components/calendar/NewEventDialog.tsx +44 -0
  121. package/src/components/calendar/QuickAdd.tsx +263 -0
  122. package/src/components/calendar/RoomHolds.tsx +203 -0
  123. package/src/components/calendar/WhereItLands.tsx +51 -0
  124. package/src/components/editor/EventPanel.tsx +23 -2
  125. package/src/components/editor/PostEditor.tsx +206 -14
  126. package/src/components/import/ImportExport.tsx +4 -1
  127. package/src/components/import/runner.ts +1 -0
  128. package/src/components/sections/SectionFields.tsx +560 -0
  129. package/src/components/sections/SectionPicker.tsx +120 -0
  130. package/src/components/sections/TranslationPanel.tsx +143 -0
  131. package/src/components/sections/checks.ts +64 -0
  132. package/src/layouts/Admin.astro +11 -7
  133. package/src/layouts/Site.astro +136 -21
  134. package/src/lib/agent-discovery.ts +11 -7
  135. package/src/lib/agent.ts +35 -2
  136. package/src/lib/ai-schema.ts +5 -0
  137. package/src/lib/ai.ts +5 -0
  138. package/src/lib/api.ts +12 -0
  139. package/src/lib/backup/content.ts +6 -1
  140. package/src/lib/booking/agent.ts +3 -2
  141. package/src/lib/booking/approval.ts +70 -0
  142. package/src/lib/booking/holds.ts +120 -0
  143. package/src/lib/booking/service.ts +37 -3
  144. package/src/lib/calendar/agent-schema.ts +9 -1
  145. package/src/lib/calendar/agent.ts +16 -1
  146. package/src/lib/calendar/board.ts +171 -0
  147. package/src/lib/calendar/config.ts +3 -0
  148. package/src/lib/calendar/grid.ts +169 -0
  149. package/src/lib/calendar/migration.ts +16 -0
  150. package/src/lib/calendar/render.ts +9 -7
  151. package/src/lib/calendar/schedule.ts +166 -0
  152. package/src/lib/content-service.ts +203 -0
  153. package/src/lib/design-service.ts +153 -5
  154. package/src/lib/drafts.ts +3 -3
  155. package/src/lib/editor/auth.ts +264 -0
  156. package/src/lib/editor/bookings.ts +60 -0
  157. package/src/lib/editor/config.ts +25 -0
  158. package/src/lib/editor/documents.ts +227 -0
  159. package/src/lib/editor/http.ts +48 -0
  160. package/src/lib/editor/me.ts +30 -0
  161. package/src/lib/editor/migration.ts +57 -0
  162. package/src/lib/editor/next.ts +11 -0
  163. package/src/lib/editor/openapi.ts +124 -0
  164. package/src/lib/editor/schemas.ts +91 -0
  165. package/src/lib/editor/sections.ts +96 -0
  166. package/src/lib/editor/store.ts +184 -0
  167. package/src/lib/editor/token-route.ts +74 -0
  168. package/src/lib/editor/translations.ts +163 -0
  169. package/src/lib/email/accounts.ts +2 -1
  170. package/src/lib/email/types.ts +2 -1
  171. package/src/lib/home-source.ts +34 -0
  172. package/src/lib/i18n.ts +34 -0
  173. package/src/lib/import/apply.ts +31 -2
  174. package/src/lib/import/bundle.ts +4 -0
  175. package/src/lib/import/export.ts +1 -0
  176. package/src/lib/import/ghost.ts +1 -0
  177. package/src/lib/media/upload.ts +83 -0
  178. package/src/lib/mobile/associations.ts +25 -0
  179. package/src/lib/mobile/calendar.ts +26 -14
  180. package/src/lib/mobile/config.mjs +59 -0
  181. package/src/lib/mobile/content.ts +18 -13
  182. package/src/lib/mobile/embed.ts +83 -0
  183. package/src/lib/mobile/http.ts +14 -6
  184. package/src/lib/mobile/locale.ts +18 -0
  185. package/src/lib/mobile/openapi.ts +111 -0
  186. package/src/lib/mobile/pages.ts +71 -0
  187. package/src/lib/mobile/route.ts +3 -3
  188. package/src/lib/mobile/schemas.ts +60 -0
  189. package/src/lib/mobile/site.ts +41 -0
  190. package/src/lib/push/api.ts +158 -0
  191. package/src/lib/push/config.ts +20 -0
  192. package/src/lib/push/expo.ts +76 -0
  193. package/src/lib/push/migration.ts +60 -0
  194. package/src/lib/push/service.ts +302 -0
  195. package/src/lib/push/store.ts +144 -0
  196. package/src/lib/push/types.ts +43 -0
  197. package/src/lib/repo/migrations.ts +14 -0
  198. package/src/lib/repo/sql.ts +65 -20
  199. package/src/lib/repo/types.ts +10 -4
  200. package/src/lib/roles.ts +5 -0
  201. package/src/lib/scheduler.ts +6 -1
  202. package/src/lib/schema.ts +76 -1
  203. package/src/lib/section-ai.ts +55 -0
  204. package/src/lib/section-targets.ts +62 -0
  205. package/src/lib/sections-agent.ts +146 -0
  206. package/src/lib/sections.ts +242 -0
  207. package/src/lib/site-chrome.ts +34 -0
  208. package/src/lib/theme-assets.ts +16 -0
  209. package/src/lib/theme.ts +33 -4
  210. package/src/lib/translate.ts +97 -0
  211. package/src/lib/translations.ts +39 -0
  212. package/src/middleware.ts +38 -2
  213. package/src/pages/.well-known/apple-app-site-association.ts +6 -0
  214. package/src/pages/.well-known/assetlinks.json.ts +6 -0
  215. package/src/pages/[...slug].astro +8 -3
  216. package/src/pages/admin/app-auth.astro +159 -0
  217. package/src/pages/admin/c/[collection]/[id].astro +9 -0
  218. package/src/pages/admin/calendar/bookings.astro +32 -26
  219. package/src/pages/admin/calendar/calendars.astro +4 -263
  220. package/src/pages/admin/calendar/events/[id].astro +9 -0
  221. package/src/pages/admin/calendar/events/index.astro +41 -83
  222. package/src/pages/admin/calendar/index.astro +43 -0
  223. package/src/pages/admin/calendar/resources/[id].astro +27 -5
  224. package/src/pages/admin/calendar/resources/index.astro +16 -17
  225. package/src/pages/admin/calendar/resources/link.astro +52 -12
  226. package/src/pages/admin/calendar/settings.astro +458 -0
  227. package/src/pages/admin/integrations.astro +5 -11
  228. package/src/pages/admin/login.astro +6 -2
  229. package/src/pages/admin/logout.ts +5 -2
  230. package/src/pages/admin/mobile.astro +18 -0
  231. package/src/pages/admin/pages/[id].astro +62 -0
  232. package/src/pages/admin/section-preview.astro +61 -0
  233. package/src/pages/admin/users.astro +107 -11
  234. package/src/pages/api/agent/docs.ts +6 -2
  235. package/src/pages/api/agent/index.ts +1 -1
  236. package/src/pages/api/ai/settings.ts +7 -4
  237. package/src/pages/api/design/ai.ts +15 -0
  238. package/src/pages/api/design/index.ts +12 -42
  239. package/src/pages/api/design/translate.ts +15 -0
  240. package/src/pages/api/live/thumb.ts +1 -1
  241. package/src/pages/api/mcp.ts +2 -2
  242. package/src/pages/api/media/upload.ts +10 -78
  243. package/src/pages/api/push/admin.ts +27 -0
  244. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
  245. package/src/pages/api/v1/admin/bookings/index.ts +6 -0
  246. package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
  247. package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
  248. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
  249. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
  250. package/src/pages/api/v1/admin/documents/index.ts +13 -0
  251. package/src/pages/api/v1/admin/me.ts +6 -0
  252. package/src/pages/api/v1/admin/media/index.ts +32 -0
  253. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
  254. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
  255. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
  256. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
  257. package/src/pages/api/v1/admin/sections/index.ts +6 -0
  258. package/src/pages/api/v1/auth/revoke.ts +5 -0
  259. package/src/pages/api/v1/auth/token.ts +5 -0
  260. package/src/pages/api/v1/devices/[token].ts +20 -0
  261. package/src/pages/api/v1/devices/index.ts +17 -0
  262. package/src/pages/api/v1/openapi.json.ts +7 -0
  263. package/src/pages/api/v1/pages/[...slug].ts +7 -0
  264. package/src/pages/api/v1/pages/home.ts +7 -0
  265. package/src/pages/api/v1/push/topics.ts +7 -0
  266. package/src/pages/api/v1/site.ts +7 -0
  267. package/src/pages/api/v1/theme.ts +7 -0
  268. package/src/pages/blog/[slug].astro +8 -2
  269. package/src/pages/blog/index.astro +11 -4
  270. package/src/pages/book/[resource].ics.ts +7 -1
  271. package/src/pages/embed/section/[...path].astro +42 -0
  272. package/src/pages/events/[slug].astro +7 -2
  273. package/src/pages/index.astro +11 -5
  274. package/src/pages/sitemap.xml.ts +8 -0
  275. package/src/styles/admin.css +117 -0
  276. package/src/styles/cards.css +5 -1
  277. package/src/styles/editor.css +57 -0
  278. package/src/styles/mobile-app.css +151 -0
  279. package/src/styles/schedule.css +325 -0
  280. package/src/styles/sections.css +21 -12
  281. package/src/styles/theme-editor.css +255 -0
  282. package/src/theme-sdk/contract.d.mts +2193 -89
  283. package/src/theme-sdk/contract.mjs +77 -20
  284. package/src/theme-sdk/fields.d.mts +88 -0
  285. package/src/theme-sdk/fields.mjs +330 -0
  286. package/src/theme-sdk/index.ts +14 -2
  287. package/src/theme-sdk/tokens.d.mts +11 -0
  288. package/src/virtual.d.ts +4 -0
  289. package/src/pages/admin/calendar/index.ts +0 -4
@@ -105,9 +105,9 @@ const dotStyle = (e: DisplayEvent) => (e.k ? ` style="--cal-dot:${e.k}"` : '');
105
105
  const chipHtml = (e: DisplayEvent, i: number) =>
106
106
  `<button type="button" class="pc-cal-event${e.x ? ' is-cancelled' : ''}" data-ev="${i}" data-cal="${e.c}"${dotStyle(e)}><span class="t">${esc(e.tm)}</span><b>${esc(e.t)}</b></button>`;
107
107
 
108
- function monthGridHtml(y: number, m: number, todayN: number, byDay: Map<number, number[]>, events: DisplayEvent[], dows: string[], monthsShort: string[]): string {
108
+ function monthGridHtml(y: number, m: number, todayN: number, byDay: Map<number, number[]>, events: DisplayEvent[], dows: string[], monthsShort: string[], ws: number): string {
109
109
  const first = dayNum(y, m, 1);
110
- const start = first - dowOfDay(first);
110
+ const start = first - ((dowOfDay(first) - ws + 7) % 7);
111
111
  let h = dows.map((d) => `<div class="pc-cal-dow">${esc(d)}</div>`).join('');
112
112
  for (let i = 0; i < 42; i++) {
113
113
  const n = start + i;
@@ -149,7 +149,7 @@ var body=root.querySelector('.pc-cal-body'),titleEl=root.querySelector('.pc-cal-
149
149
  var D=864e5;
150
150
  function dnum(a){return Math.round(Date.UTC(a[0],a[1]-1,a[2])/D)}
151
151
  function fday(n){var d=new Date(n*D);return[d.getUTCFullYear(),d.getUTCMonth()+1,d.getUTCDate()]}
152
- function dow(n){return((n+4)%7+7)%7}
152
+ function dow(n){return((n+4-(P.ws||0))%7+7)%7}
153
153
  var byDay={};P.events.forEach(function(e,i){var n0=dnum(e.d);for(var k=0;k<Math.min(e.n,60);k++){(byDay[n0+k]=byDay[n0+k]||[]).push(i)}});
154
154
  function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
155
155
  function chip(i){var e=P.events[i];return '<button type="button" class="pc-cal-event'+(e.x?' is-cancelled':'')+'" data-ev="'+i+'" data-cal="'+e.c+'"'+(e.k?' style="--cal-dot:'+e.k+'"':'')+'><span class="t">'+esc(e.tm)+'</span><b>'+esc(e.t)+'</b></button>'}
@@ -246,7 +246,9 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
246
246
  new Intl.DateTimeFormat(locale, { ...o, timeZone: 'UTC' }).format(ms);
247
247
  const months = Array.from({ length: 12 }, (_, i) => utcLabel({ month: 'long' }, Date.UTC(2026, i, 1)));
248
248
  const monthsShort = Array.from({ length: 12 }, (_, i) => utcLabel({ month: 'short' }, Date.UTC(2026, i, 1)));
249
- const dows = Array.from({ length: 7 }, (_, i) => utcLabel({ weekday: 'short' }, Date.UTC(2026, 7, 2 + i))); // Aug 2 2026 = Sunday
249
+ // Calendar settings → Defaults → "Weeks start on" (0 Sunday, 1 Monday)
250
+ const ws = settings.calendar?.weekStart ?? 0;
251
+ const dows = Array.from({ length: 7 }, (_, i) => utcLabel({ weekday: 'short' }, Date.UTC(2026, 7, 2 + ws + i))); // Aug 2 2026 = Sunday
250
252
 
251
253
  const focusOk = opts.focus && (opts.focus.y * 12 + opts.focus.m) >= minY * 12 + minM && (opts.focus.y * 12 + opts.focus.m) <= maxY * 12 + maxM;
252
254
  const [fy, fm] = focusOk ? [opts.focus!.y, opts.focus!.m] : [today.y, today.m];
@@ -254,7 +256,7 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
254
256
  let bodyHtml: string;
255
257
  let titleText: string;
256
258
  if (opts.view === 'week') {
257
- const weekStart = todayN - dowOfDay(todayN);
259
+ const weekStart = todayN - ((dowOfDay(todayN) - ws + 7) % 7);
258
260
  const wa = fromDayNum(weekStart);
259
261
  const wb = fromDayNum(weekStart + 6);
260
262
  titleText = `${monthsShort[wa.m - 1]} ${wa.d} – ${wa.m === wb.m ? '' : `${monthsShort[wb.m - 1]} `}${wb.d}, ${wb.y}`;
@@ -267,7 +269,7 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
267
269
  if (!order.length && opts.emptyText) bodyHtml = `<p class="pc-cal-empty">${esc(opts.emptyText)}</p>`;
268
270
  } else {
269
271
  titleText = `${months[fm - 1]} ${fy}`;
270
- bodyHtml = monthGridHtml(fy, fm, todayN, byDay, events, dows, monthsShort);
272
+ bodyHtml = monthGridHtml(fy, fm, todayN, byDay, events, dows, monthsShort, ws);
271
273
  }
272
274
 
273
275
  const navButtons =
@@ -287,7 +289,7 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
287
289
  month: [fy, fm],
288
290
  min: [minY, minM],
289
291
  max: [maxY, maxM],
290
- months, monthsShort, dows, events,
292
+ months, monthsShort, dows, events, ws,
291
293
  details: 'Details', cancelled: 'Cancelled',
292
294
  };
293
295
  const json = JSON.stringify(payload).replace(/</g, '\\u003c');
@@ -0,0 +1,166 @@
1
+ // Writes behind the admin Schedule: quick add, drag to move (a whole series
2
+ // or one occurrence), undo, duplicate. Everything goes through the same
3
+ // save/apply path as the event editor, so hooks, feeds, search and webhooks
4
+ // see the same changes. A move applies at once, even on a published event —
5
+ // the editor's "stage until Update" flow is for content edits, not dates.
6
+ import type { ContentRepository } from '../repo/types';
7
+ import type { CalEvent } from '../schema';
8
+ import { slugify } from '../schema';
9
+ import { ApiError } from '../api';
10
+ import { applyEventUpdate, saveEvent, type SaveHooks } from '../content-service';
11
+ import { addWallDays, formatWall, parseWall } from './events';
12
+ import { rruleMap, WEEKDAY_CODES, weekdayOf } from './rrule';
13
+ import { eventDeleted } from './hooks';
14
+ import { waitUntilFrom } from '../webhooks';
15
+
16
+ async function uniqueSlug(repo: ContentRepository, title: string) {
17
+ const base = slugify(title) || 'event';
18
+ let slug = base;
19
+ for (let n = 2; await repo.getEventBySlug(slug); n++) slug = `${base}-${n}`;
20
+ return slug;
21
+ }
22
+
23
+ const live = (s: CalEvent['status']) => s === 'published' || s === 'scheduled';
24
+
25
+ // Save on a live event without staging: stage, then apply just this change.
26
+ // Refuses when the event already has unrelated unpublished edits, so a drag
27
+ // never publishes someone's half-finished copy.
28
+ async function saveNow(repo: ContentRepository, e: CalEvent, data: Parameters<typeof saveEvent>[2], hooks: SaveHooks) {
29
+ if (live(e.status) && e.draft) {
30
+ throw new ApiError(409, 'This event has unpublished changes. Open it and update or discard them first.');
31
+ }
32
+ const saved = await saveEvent(repo, e.id, data, hooks);
33
+ return live(e.status) ? applyEventUpdate(repo, e.id, hooks) : saved;
34
+ }
35
+
36
+ export interface QuickEvent {
37
+ title: string;
38
+ start: string; // YYYY-MM-DD or YYYY-MM-DDTHH:mm
39
+ end: string;
40
+ allDay: boolean;
41
+ calendarId: string;
42
+ rrule: string;
43
+ location: string;
44
+ publish: boolean;
45
+ // bookable rooms/people this event uses (held while it happens)
46
+ roomIds?: string[];
47
+ }
48
+
49
+ export async function quickCreate(repo: ContentRepository, q: QuickEvent, hooks: SaveHooks): Promise<CalEvent> {
50
+ const title = q.title.trim().slice(0, 300) || 'Untitled event';
51
+ const cal = q.calendarId ? await repo.getCalendar(q.calendarId) : null;
52
+ const created = await repo.createEvent({
53
+ title, slug: await uniqueSlug(repo, title), start: q.start, allDay: q.allDay, status: 'draft',
54
+ calendarIds: cal?.kind === 'native' ? [cal.id] : [],
55
+ location: { name: q.location.trim().slice(0, 300), address: '', url: '' },
56
+ });
57
+ if (q.roomIds?.length) await saveEvent(repo, created.id, { roomHold: { resourceIds: q.roomIds, span: 'event', before: 0, after: 0, whileDraft: false, keep: [] } }, hooks);
58
+ // times + repeat go through the editor's validation (end defaults, pairs)
59
+ await saveEvent(repo, created.id, { start: q.start, end: q.end || undefined, allDay: q.allDay, rrule: q.rrule }, hooks);
60
+ return q.publish ? saveEvent(repo, created.id, { status: 'published' }, hooks) : (await repo.getEvent(created.id))!;
61
+ }
62
+
63
+ // Shift a recurrence rule by whole days so "weekly on Wednesday" moved two
64
+ // days later becomes "weekly on Friday" (and nth-weekday / day-of-month
65
+ // rules follow the new first date). UNTIL moves with the series.
66
+ export function shiftRrule(rrule: string, days: number, newStart: string): string {
67
+ if (!rrule || !days) return rrule;
68
+ const p = rruleMap(rrule);
69
+ if (p.FREQ === 'WEEKLY' && p.BYDAY) {
70
+ p.BYDAY = p.BYDAY.split(',').map((c) => {
71
+ const i = (WEEKDAY_CODES as readonly string[]).indexOf(c);
72
+ return i < 0 ? c : WEEKDAY_CODES[(((i + days) % 7) + 7) % 7];
73
+ }).join(',');
74
+ }
75
+ if (p.FREQ === 'MONTHLY') {
76
+ const wd = weekdayOf(newStart);
77
+ if (p.BYDAY && /^-?\d(SU|MO|TU|WE|TH|FR|SA)$/.test(p.BYDAY)) p.BYDAY = `${wd.nth >= 5 || p.BYDAY.startsWith('-') ? -1 : wd.nth}${WEEKDAY_CODES[wd.dow]}`;
78
+ else if (p.BYMONTHDAY && !p.BYMONTHDAY.includes(',')) p.BYMONTHDAY = String(wd.day);
79
+ }
80
+ if (p.UNTIL && /^\d{8}$/.test(p.UNTIL)) {
81
+ const u = parseWall(`${p.UNTIL.slice(0, 4)}-${p.UNTIL.slice(4, 6)}-${p.UNTIL.slice(6, 8)}`);
82
+ if (u) p.UNTIL = formatWall(addWallDays(u, days)).replace(/-/g, '');
83
+ }
84
+ const order = ['FREQ', 'INTERVAL', 'BYDAY', 'BYMONTHDAY', 'BYMONTH', 'COUNT', 'UNTIL', 'WKST'];
85
+ return Object.entries(p).sort(([a], [b]) => order.indexOf(a) - order.indexOf(b)).map(([k, v]) => `${k}=${v}`).join(';');
86
+ }
87
+
88
+ const shift = (wall: string, days: number) => {
89
+ const w = parseWall(wall);
90
+ return w ? formatWall(addWallDays(w, days)) : wall;
91
+ };
92
+
93
+ export type MoveUndo =
94
+ | { kind: 'series'; id: string; days: number }
95
+ | { kind: 'split'; id: string; seriesId: string; occurrence: string };
96
+
97
+ // Move a whole event (and every repeat) by `days`
98
+ export async function moveSeries(repo: ContentRepository, id: string, days: number, hooks: SaveHooks): Promise<{ event: CalEvent; undo: MoveUndo }> {
99
+ const e = await repo.getEvent(id);
100
+ if (!e) throw new ApiError(404, 'Event not found.');
101
+ if (!Number.isInteger(days) || Math.abs(days) > 3660) throw new ApiError(400, 'Pick a day to move it to.');
102
+ const start = shift(e.start, days);
103
+ const event = await saveNow(repo, e, {
104
+ start, end: shift(e.end, days), allDay: e.allDay,
105
+ ...(e.rrule ? { rrule: shiftRrule(e.rrule, days, start), exdates: e.exdates.map((x) => shift(x, days)) } : {}),
106
+ }, hooks);
107
+ return { event, undo: { kind: 'series', id, days: -days } };
108
+ }
109
+
110
+ // Move one occurrence of a repeating event: skip that date in the series and
111
+ // add a one-off copy on the new date. No schema change, and the copy is a
112
+ // normal event the editor can open.
113
+ export async function moveOccurrence(repo: ContentRepository, id: string, occurrence: string, days: number, hooks: SaveHooks): Promise<{ event: CalEvent; undo: MoveUndo }> {
114
+ const e = await repo.getEvent(id);
115
+ if (!e) throw new ApiError(404, 'Event not found.');
116
+ if (!e.rrule) return moveSeries(repo, id, days, hooks);
117
+ const occ = parseWall(occurrence);
118
+ const first = parseWall(e.start);
119
+ if (!occ || !first) throw new ApiError(400, 'That date isn’t part of this event.');
120
+ if (e.exdates.includes(occurrence)) throw new ApiError(409, 'That date was already removed from the series.');
121
+ // keep the occurrence's own length (first occurrence's start → end)
122
+ const toMs = (w: { y: number; m: number; d: number; hh: number; mm: number }) => Date.UTC(w.y, w.m - 1, w.d, w.hh, w.mm);
123
+ const lenMs = toMs(parseWall(e.end) ?? first) - toMs(first);
124
+ const newStart = addWallDays(occ, days);
125
+ const t = new Date(toMs(newStart) + lenMs);
126
+ const pad = (n: number) => String(n).padStart(2, '0');
127
+ const endDay = `${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}`;
128
+ const endStr = e.allDay ? endDay : `${endDay}T${pad(t.getUTCHours())}:${pad(t.getUTCMinutes())}`;
129
+
130
+ await saveNow(repo, e, { exdates: [...e.exdates, occurrence] }, hooks);
131
+ const copy = await repo.createEvent({
132
+ title: e.title, slug: await uniqueSlug(repo, e.title), start: formatWall(newStart), end: endStr, allDay: e.allDay,
133
+ timezone: e.timezone, status: 'draft', excerpt: e.excerpt, bodyJson: e.bodyJson, bodyHtml: e.bodyHtml, cover: e.cover, coverAlt: e.coverAlt,
134
+ location: e.location, cta: e.cta, calendarIds: e.calendarIds, fields: e.fields, tags: e.tags, cancelled: e.cancelled, rsvp: e.rsvp,
135
+ template: e.template, featured: false,
136
+ });
137
+ const event = live(e.status) ? await saveEvent(repo, copy.id, { status: 'published' }, hooks) : copy;
138
+ return { event, undo: { kind: 'split', id: copy.id, seriesId: e.id, occurrence } };
139
+ }
140
+
141
+ export async function undoMove(repo: ContentRepository, undo: MoveUndo, hooks: SaveHooks): Promise<void> {
142
+ if (undo.kind === 'series') {
143
+ await moveSeries(repo, undo.id, undo.days, hooks);
144
+ return;
145
+ }
146
+ const series = await repo.getEvent(undo.seriesId);
147
+ const copy = await repo.getEvent(undo.id);
148
+ if (copy) {
149
+ await repo.deleteEvent(copy.id);
150
+ await eventDeleted(repo, copy, { waitUntil: waitUntilFrom(hooks.locals) });
151
+ }
152
+ if (series) await saveNow(repo, series, { exdates: series.exdates.filter((x) => x !== undo.occurrence) }, hooks);
153
+ }
154
+
155
+ export async function duplicateEvent(repo: ContentRepository, id: string): Promise<CalEvent> {
156
+ const e = await repo.getEvent(id);
157
+ if (!e) throw new ApiError(404, 'Event not found.');
158
+ const src = e.draft ? { ...e, ...(e.draft as Partial<CalEvent>) } : e;
159
+ const title = `${src.title} (copy)`;
160
+ return repo.createEvent({
161
+ title, slug: await uniqueSlug(repo, title), start: src.start, end: src.end, allDay: src.allDay, timezone: src.timezone,
162
+ rrule: src.rrule, exdates: src.exdates, status: 'draft', excerpt: src.excerpt, bodyJson: src.bodyJson, bodyHtml: src.bodyHtml,
163
+ cover: src.cover, coverAlt: src.coverAlt, location: src.location, cta: src.cta, calendarIds: src.calendarIds, fields: src.fields,
164
+ tags: src.tags, rsvp: src.rsvp, template: src.template,
165
+ });
166
+ }
@@ -0,0 +1,203 @@
1
+ import type { ContentRepository } from './repo/types';
2
+ import type { CalEvent, Page, Post, WidgetInstance } from './schema';
3
+ import { slugify } from './schema';
4
+ import { ApiError } from './api';
5
+ import { CONTENT, collectionUrl } from './content';
6
+ import { CALENDAR } from './calendar/config';
7
+ import { isValidTimeZone, normalizeEventTimes } from './calendar/events';
8
+ import { eventChanged } from './calendar/hooks';
9
+ import { normalizePageLocale } from './i18n';
10
+ import { siteOrigin } from './seo';
11
+ import { waitUntilFrom } from './webhooks';
12
+
13
+ // Saving posts, pages and events: the one implementation behind the admin
14
+ // editor (astro:actions) and the editor-app API (/api/v1/admin/documents).
15
+ // A live (published/scheduled) document never takes content edits directly:
16
+ // they stage into its pending draft until Update. Status transitions stay
17
+ // deliberate, immediate writes. Errors are ApiErrors; the actions translate
18
+ // them to ActionErrors with the same messages.
19
+
20
+ export interface SaveHooks {
21
+ /** the request URL (origin fallback for event side effects) */
22
+ url: URL;
23
+ locals?: unknown;
24
+ /** new redirects must fire at once in this isolate */
25
+ onRedirect?: () => void;
26
+ }
27
+
28
+ export type PostSave = Parameters<ContentRepository['updatePost']>[1];
29
+ export type PageSave = Omit<Parameters<ContentRepository['updatePage']>[1], 'blocks'>;
30
+ export type EventSave = Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'endAt' | 'createdAt' | 'updatedAt' | 'sourceUid' | 'publishedAt'>>;
31
+
32
+ const live = (status: string) => status === 'published' || status === 'scheduled';
33
+
34
+ export async function savePost(repo: ContentRepository, id: string, input: PostSave, hooks: SaveHooks): Promise<Post> {
35
+ const data = { ...input };
36
+ if (data.slug !== undefined) data.slug = slugify(data.slug);
37
+ if (data.collection !== undefined && !CONTENT.collections[data.collection]) throw new ApiError(400, `Unknown collection "${data.collection}".`);
38
+ const before = await repo.getPost(id);
39
+ if (!before) throw new ApiError(404, 'Post not found.');
40
+ const transitions = data.status !== undefined && data.status !== before.status;
41
+ if (live(before.status) && !transitions) {
42
+ const { status: _status, scheduledAt: _scheduledAt, authorId: _authorId, ...staged } = data;
43
+ await repo.savePostDraft(id, staged);
44
+ return (await repo.getPost(id))!;
45
+ }
46
+ await repo.updatePost(id, data);
47
+ // any new tag names become registry entries with slugs for archives
48
+ if (data.tags !== undefined) await repo.ensureTags(data.tags);
49
+ const post = await repo.getPost(id);
50
+ if (!post) throw new ApiError(404, 'Post not found.');
51
+ // a published post whose slug or collection changed leaves a 301 behind
52
+ if (before.status === 'published' && (before.slug !== post.slug || before.collection !== post.collection)) {
53
+ await repo.createRedirect({ fromPath: collectionUrl(before.collection, before.slug), toPath: collectionUrl(post.collection, post.slug) }).catch(() => {});
54
+ hooks.onRedirect?.();
55
+ }
56
+ return post;
57
+ }
58
+
59
+ /** Update: the pending draft goes live (tag registry, slug-change 301 happen now) */
60
+ export async function applyPostUpdate(repo: ContentRepository, id: string, hooks: SaveHooks): Promise<Post> {
61
+ const before = await repo.getPost(id);
62
+ if (!before) throw new ApiError(404, 'Post not found.');
63
+ await repo.applyPostDraft(id);
64
+ const post = (await repo.getPost(id))!;
65
+ if (post.tags.length) await repo.ensureTags(post.tags);
66
+ if (before.status === 'published' && (before.slug !== post.slug || before.collection !== post.collection)) {
67
+ await repo.createRedirect({ fromPath: collectionUrl(before.collection, before.slug), toPath: collectionUrl(post.collection, post.slug) }).catch(() => {});
68
+ hooks.onRedirect?.();
69
+ }
70
+ return post;
71
+ }
72
+
73
+ export async function savePage(repo: ContentRepository, id: string, input: PageSave, blocks: WidgetInstance[] | undefined, hooks: SaveHooks): Promise<Page> {
74
+ const data = { ...input };
75
+ if (data.slug !== undefined) data.slug = slugify(data.slug);
76
+ // default locale stores as '', unknown codes fall back to it
77
+ if (data.locale !== undefined) data.locale = normalizePageLocale(data.locale);
78
+ const before = await repo.getPage(id);
79
+ if (!before) throw new ApiError(404, 'Page not found.');
80
+ const transitions = data.status !== undefined && data.status !== before.status;
81
+ if (live(before.status) && !transitions) {
82
+ const { status: _status, ...staged } = data;
83
+ await repo.savePageDraft(id, { ...staged, blocks });
84
+ return (await repo.getPage(id))!;
85
+ }
86
+ await repo.updatePage(id, { ...data, blocks });
87
+ const page = await repo.getPage(id);
88
+ if (!page) throw new ApiError(404, 'Page not found.');
89
+ if (before.status === 'published' && before.slug !== page.slug && !page.isHome) {
90
+ await repo.createRedirect({ fromPath: `/${before.slug}`, toPath: `/${page.slug}` }).catch(() => {});
91
+ hooks.onRedirect?.();
92
+ }
93
+ return page;
94
+ }
95
+
96
+ export async function applyPageUpdate(repo: ContentRepository, id: string, hooks: SaveHooks): Promise<Page> {
97
+ const before = await repo.getPage(id);
98
+ if (!before) throw new ApiError(404, 'Page not found.');
99
+ await repo.applyPageDraft(id);
100
+ const page = (await repo.getPage(id))!;
101
+ if (before.status === 'published' && before.slug !== page.slug && !page.isHome) {
102
+ await repo.createRedirect({ fromPath: `/${before.slug}`, toPath: `/${page.slug}` }).catch(() => {});
103
+ hooks.onRedirect?.();
104
+ }
105
+ return page;
106
+ }
107
+
108
+ // Validate + normalize an event save. Times are checked as a PAIR against the
109
+ // merged (staged) state so a half-edited range can't slip through.
110
+ export async function cleanEventInput(repo: ContentRepository, data: EventSave, before: CalEvent): Promise<EventSave> {
111
+ const out: EventSave = { ...data };
112
+ if (out.slug !== undefined) {
113
+ const base = slugify(out.slug || out.title || before.title);
114
+ let slug = base;
115
+ for (let n = 2; ; n++) {
116
+ const clash = await repo.getEventBySlug(slug);
117
+ if (!clash || clash.id === before.id) break;
118
+ slug = `${base}-${n}`;
119
+ }
120
+ out.slug = slug;
121
+ }
122
+ if (out.timezone !== undefined && out.timezone !== '' && !isValidTimeZone(out.timezone)) {
123
+ throw new ApiError(400, `“${out.timezone}” isn’t a timezone we recognize.`);
124
+ }
125
+ if (out.start !== undefined || out.end !== undefined || out.allDay !== undefined) {
126
+ const merged = before.draft ? { ...before, ...(before.draft as Partial<CalEvent>) } : before;
127
+ try {
128
+ const t = normalizeEventTimes({ start: out.start ?? merged.start, end: out.end ?? merged.end, allDay: out.allDay ?? merged.allDay });
129
+ out.start = t.start;
130
+ out.end = t.end;
131
+ } catch (err) {
132
+ throw new ApiError(400, err instanceof Error ? err.message : 'Invalid time.');
133
+ }
134
+ }
135
+ if (out.roomHold !== undefined) {
136
+ // only rooms/people that exist; order kept, duplicates dropped
137
+ const known = new Set((await repo.listResources()).map((r) => r.id));
138
+ out.roomHold = { ...out.roomHold, resourceIds: [...new Set(out.roomHold.resourceIds.filter((id) => known.has(id)))] };
139
+ }
140
+ if (out.calendarIds !== undefined) {
141
+ const known = new Set((await repo.listCalendars()).filter((c) => c.kind === 'native').map((c) => c.id));
142
+ out.calendarIds = [...new Set(out.calendarIds.filter((c) => known.has(c)))];
143
+ }
144
+ if (out.fields !== undefined) {
145
+ const allowed = new Map(CALENDAR.eventFields.map((f) => [f.key, f]));
146
+ out.fields = Object.fromEntries(
147
+ Object.entries(out.fields)
148
+ .filter(([k, v]) => allowed.has(k) && v !== '')
149
+ .filter(([k, v]) => allowed.get(k)!.type !== 'select' || allowed.get(k)!.options.includes(v))
150
+ );
151
+ }
152
+ if (out.cta !== undefined) {
153
+ const href = out.cta.href.trim();
154
+ if (href && !/^(https?:\/\/|\/(?!\/)|mailto:|tel:|#)/i.test(href)) {
155
+ throw new ApiError(400, 'The button link must start with https://, /, mailto:, or tel:.');
156
+ }
157
+ out.cta = { ...out.cta, href };
158
+ }
159
+ if (out.location?.url) {
160
+ if (!/^https?:\/\//i.test(out.location.url.trim())) throw new ApiError(400, 'The map or meeting link must start with https://.');
161
+ }
162
+ if (out.tags !== undefined) out.tags = [...new Set(out.tags.map((t) => t.trim()).filter(Boolean))];
163
+ if (out.status === 'scheduled' && !(out.scheduledAt && out.scheduledAt > Date.now())) {
164
+ throw new ApiError(400, 'Pick a future publish time to schedule.');
165
+ }
166
+ if (out.status === 'published') out.scheduledAt = null;
167
+ return out;
168
+ }
169
+
170
+ async function eventSideEffects(repo: ContentRepository, hooks: SaveHooks, before: CalEvent, after: CalEvent, applied = false) {
171
+ const settings = await repo.getSettings();
172
+ await eventChanged(repo, before, after, {
173
+ origin: siteOrigin(settings, hooks.url), waitUntil: waitUntilFrom(hooks.locals), applied, onRedirect: hooks.onRedirect,
174
+ });
175
+ }
176
+
177
+ export async function saveEvent(repo: ContentRepository, id: string, data: EventSave, hooks: SaveHooks): Promise<CalEvent> {
178
+ const before = await repo.getEvent(id);
179
+ if (!before) throw new ApiError(404, 'Event not found.');
180
+ const clean = await cleanEventInput(repo, data, before);
181
+ // live events stage edits until Update, exactly like posts
182
+ const transitions = clean.status !== undefined && clean.status !== before.status;
183
+ if (live(before.status) && !transitions) {
184
+ const { status: _s, scheduledAt: _sa, ...staged } = clean;
185
+ await repo.saveEventDraft(id, staged);
186
+ return (await repo.getEvent(id))!;
187
+ }
188
+ await repo.updateEvent(id, clean);
189
+ if (clean.tags !== undefined) await repo.ensureTags(clean.tags);
190
+ const event = (await repo.getEvent(id))!;
191
+ await eventSideEffects(repo, hooks, before, event);
192
+ return event;
193
+ }
194
+
195
+ export async function applyEventUpdate(repo: ContentRepository, id: string, hooks: SaveHooks): Promise<CalEvent> {
196
+ const before = await repo.getEvent(id);
197
+ if (!before) throw new ApiError(404, 'Event not found.');
198
+ await repo.applyEventDraft(id);
199
+ const event = (await repo.getEvent(id))!;
200
+ if (event.tags.length) await repo.ensureTags(event.tags);
201
+ await eventSideEffects(repo, hooks, before, event, true);
202
+ return event;
203
+ }
@@ -1,10 +1,16 @@
1
1
  import { z } from 'zod';
2
2
  import { SectionPages, sectionIssues } from '../theme-sdk/contract.mjs';
3
- import { ApiError, requireSession } from './api';
3
+ import { migrateSection } from './sections';
4
+ import type { Theme } from './theme';
5
+ import { ApiError, sessionRole } from './api';
4
6
  import { hashAgentKey } from './ai-config';
5
7
  import { pickDesignDraft } from './drafts';
6
8
  import type { ContentRepository } from './repo/types';
7
9
  import type { Settings, ApiScope } from './schema';
10
+ import { diffDesign, summarizeChanges } from './designDiff';
11
+ import { shareEpoch } from './drafts';
12
+ import { createDesignToken } from './preview';
13
+ import { ThemeTokens } from '../theme-sdk/contract.mjs';
8
14
 
9
15
  export const DESIGN_SCOPES = ['design:read', 'design:write-draft', 'design:publish', 'media:read', 'media:write'] as const;
10
16
  export const DesignPatch = z.object({
@@ -20,6 +26,8 @@ export const DesignRequest = z.discriminatedUnion('operation', [
20
26
  z.object({ operation:z.literal('restore'),revision:z.number().int().nonnegative(),id:z.string().max(100) }).strict(),
21
27
  z.object({ operation:z.literal('share'),revision:z.number().int().nonnegative(),days:z.number().int().min(1).max(30).default(7) }).strict(),
22
28
  z.object({ operation:z.literal('revoke'),revision:z.number().int().nonnegative() }).strict(),
29
+ z.object({ operation:z.literal('migrate'),revision:z.number().int().nonnegative() }).strict(),
30
+ z.object({ operation:z.literal('publishSection'),revision:z.number().int().nonnegative(),pageId:z.string().min(1).max(100),sectionId:z.string().min(1).max(100) }).strict(),
23
31
  ]);
24
32
  export async function designIdentity(request: Request, cookies: {get(name:string):{value:string}|undefined}, repo:ContentRepository, scope:ApiScope | readonly ApiScope[]) {
25
33
  if (request.headers.has('authorization')) {
@@ -33,16 +41,49 @@ export async function designIdentity(request: Request, cookies: {get(name:string
33
41
  if (!required.some(s=>key.scopes.includes(s))) throw new ApiError(403,`Required scope: ${required.join(' or ')}`);
34
42
  return { actor:`key:${key.id}`,scopes:key.scopes };
35
43
  }
36
- await requireSession(request,cookies,repo,true);
37
- return {actor:'owner',scopes:[...DESIGN_SCOPES]};
44
+ // editors may edit section content (never layout, theme or locked fields)
45
+ const who=await sessionRole(request,cookies,repo);
46
+ if(who.role!=='owner')return {actor:`editor:${who.id}`,role:'editor' as const,scopes:['design:read','design:write-draft','design:publish']};
47
+ return {actor:'owner',role:'owner' as const,scopes:[...DESIGN_SCOPES]};
38
48
  }
39
49
  export function publicDesign(settings:Settings) { return {...pickDesignDraft(settings), sectionPages:settings.sectionPages}; }
40
- export function validateSectionDesign(settings:Settings, theme:{sections:Parameters<typeof sectionIssues>[1]}) {
50
+ export function validateSectionDesign(settings:Settings, theme:Pick<Theme,'sections'|'migrations'>) {
51
+ // a section with a theme migration renders upgraded, so validate it upgraded
41
52
  const issues=Object.entries(settings.sectionPages).flatMap(([pageId,layout]) => layout.mode === 'sections'
42
- ? sectionIssues({...layout,sections:layout.sections.filter(s=>!s.hidden)},theme.sections).map(issue=>({pageId,...issue})) : []);
53
+ ? sectionIssues({...layout,sections:layout.sections.filter(s=>!s.hidden).map(s=>migrateSection(theme,s))},theme.sections).map(issue=>({pageId,...issue})) : []);
43
54
  if (issues.length) throw new ApiError(409,`Resolve section compatibility before publishing: ${issues.slice(0,5).map(i=>i.message).join('; ')}`);
44
55
  }
45
56
 
57
+ type Pages = z.infer<typeof SectionPages>;
58
+ /** persist theme migrations: every instance older than its definition is upgraded */
59
+ export function migrateLayouts(pages: Pages, theme: Pick<Theme,'sections'|'migrations'>): { pages: Pages; upgraded: number } {
60
+ let upgraded = 0;
61
+ const out = Object.fromEntries(Object.entries(pages).map(([id, layout]) => [id, { ...layout, sections: layout.sections.map((s) => {
62
+ const next = migrateSection(theme, s);
63
+ if (next !== s) upgraded++;
64
+ return next;
65
+ }) }]));
66
+ return { pages: out, upgraded };
67
+ }
68
+
69
+ /**
70
+ * "Publish just this section": the live layout takes the staged copy of one
71
+ * section; everything else stays staged. Returns the next live + draft maps.
72
+ */
73
+ export function publishOneSection(live: Pages, staged: Pages, pageId: string, sectionId: string): { live: Pages; staged: Pages | null } {
74
+ const draftLayout = staged[pageId];
75
+ const section = draftLayout?.sections.find((s) => s.id === sectionId);
76
+ if (!section) throw new ApiError(404, 'That section is not in the draft. Save it first.');
77
+ const liveLayout = live[pageId];
78
+ if (!liveLayout || liveLayout.mode !== 'sections' || !liveLayout.sections.some((s) => s.id === sectionId)) {
79
+ throw new ApiError(409, 'This section is new on the page. Publish the whole design to add it.');
80
+ }
81
+ const nextLive: Pages = { ...live, [pageId]: { ...liveLayout, sections: liveLayout.sections.map((s) => (s.id === sectionId ? structuredClone(section) : s)) } };
82
+ // nothing left to stage for sections once draft and live agree
83
+ const same = JSON.stringify(nextLive) === JSON.stringify(staged);
84
+ return { live: nextLive, staged: same ? null : staged };
85
+ }
86
+
46
87
  export function validateDesignValues(patch: z.infer<typeof DesignPatch>) {
47
88
  const allowed = new Set(['bg','ink','muted','line','accent','panel','soft','width','wideWidth','button','scheme','radius','elevation','density','header','footer','blogLayout','fontPairing','homeMode','darkBg','darkInk','darkMuted','darkLine','darkAccent','darkPanel','darkSoft','postCover','postDate','postReadingTime','postByline','postAuthorCard','postShare','postRelated','postProgress','postToc']);
48
89
  for (const [key,value] of Object.entries(patch.designOverrides ?? {})) {
@@ -56,3 +97,110 @@ export function validateDesignValues(patch: z.infer<typeof DesignPatch>) {
56
97
  if(value && !/^(https?:\/\/|mailto:|tel:|\/(?!\/)|#)/i.test(value.trim())) throw new ApiError(400,'Use an HTTP(S) or site-relative URL.');
57
98
  }
58
99
  }
100
+
101
+ type Layout = Pages[string];
102
+ type Inst = Layout['sections'][number];
103
+ const same = (a: unknown, b: unknown) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
104
+ /**
105
+ * Editors change content only: every page keeps its sections, order, type,
106
+ * style, visibility, schedule and lock, and nothing locked (a locked section,
107
+ * or a field marked `locked` in the theme) changes. Throws 403 otherwise.
108
+ */
109
+ export function assertContentOnly(before: Pages, after: Pages, defs: Pick<Theme, 'sections'>['sections']) {
110
+ const deny = (why: string) => { throw new ApiError(403, `Only an owner can ${why}.`); };
111
+ const ids = (p: Pages) => Object.keys(p).filter((k) => p[k]).sort();
112
+ if (!same(ids(before), ids(after))) deny('add or remove page layouts');
113
+ for (const pageId of Object.keys(after)) {
114
+ const a: Layout = before[pageId], b: Layout = after[pageId];
115
+ if (a.mode !== b.mode) deny('switch a page between sections and page content');
116
+ if (!same(a.sections.map((s) => s.id), b.sections.map((s) => s.id))) deny('add, remove or reorder sections');
117
+ b.sections.forEach((s: Inst, i: number) => {
118
+ const o: Inst = a.sections[i];
119
+ for (const k of ['type', 'version', 'variant', 'hidden', 'showFrom', 'showUntil', 'locked'] as const) {
120
+ if (!same(o[k], s[k])) deny(k === 'variant' ? 'change a section’s style' : k === 'hidden' ? 'hide or show sections' : k.startsWith('show') ? 'schedule sections' : k === 'locked' ? 'lock or unlock sections' : 'change a section’s type');
121
+ }
122
+ if (o.locked && (!same(o.data, s.data) || !same(o.translations, s.translations))) deny(`edit the locked “${defs[s.type]?.name ?? s.type}” section`);
123
+ const def = defs[s.type];
124
+ const keys = new Set([...Object.keys(o.data), ...Object.keys(s.data)]);
125
+ for (const key of keys) {
126
+ // a focal point belongs to its image field (same lock)
127
+ const owner = key.endsWith('__focus') && def?.fields[key.slice(0, -7)] ? key.slice(0, -7) : key;
128
+ const f = def?.fields[owner] as { locked?: boolean; type?: string; fields?: Record<string, { locked?: boolean }> } | undefined;
129
+ if (!f) { if (!same(o.data[key], s.data[key])) deny('change saved values the theme no longer uses'); continue; }
130
+ if (f.locked && !same(o.data[key], s.data[key])) deny(`change the locked “${(f as { label?: string }).label ?? key}” field`);
131
+ if (f.type === 'list' && f.fields) {
132
+ const lockedSubs = Object.entries(f.fields).filter(([, sf]) => sf.locked).map(([k]) => k);
133
+ if (lockedSubs.length) {
134
+ const rowsA = (o.data[key] ?? []) as Record<string, unknown>[], rowsB = (s.data[key] ?? []) as Record<string, unknown>[];
135
+ if (rowsA.length !== rowsB.length || rowsB.some((r, j) => lockedSubs.some((k) => !same(r?.[k], rowsA[j]?.[k])))) deny('change locked list fields');
136
+ }
137
+ }
138
+ }
139
+ });
140
+ }
141
+ }
142
+
143
+ /**
144
+ * One design operation (stage, publish, publishSection, …) with the role
145
+ * rules shared by the Design screen, scoped keys and editor apps: editors
146
+ * only stage section content (assertContentOnly) and publish single
147
+ * sections; everything else is owner/key work. Revision mismatches are 409.
148
+ */
149
+ export async function runDesignOperation(repo: ContentRepository, identity: { actor: string; role?: 'owner' | 'editor' }, input: z.infer<typeof DesignRequest>, origin: string): Promise<{ ok: true; revision: number } | { url: string; expiresAt: number; revision: number }> {
150
+ // lazy: the theme registry pulls in Astro templates, which callers that only need the schemas shouldn't load
151
+ const { getTheme, listThemes } = await import('./theme');
152
+ const current=await repo.getSettings();
153
+ if(current.designRevision!==input.revision)throw new ApiError(409,'Design changed. Pull the latest revision before saving.');
154
+ if(identity.role==='editor'){
155
+ if(input.operation!=='stage'&&input.operation!=='publishSection')throw new ApiError(403,'Only an owner can do that. Editors change section content and publish single sections.');
156
+ if(input.operation==='stage'){
157
+ if(Object.keys(input.patch).some(k=>k!=='sectionPages')||!input.patch.sectionPages)throw new ApiError(403,'Editors can only change section content.');
158
+ const theme=getTheme(String(current.draftDesign?.activeTheme??current.activeTheme));
159
+ assertContentOnly((current.draftDesign?.sectionPages??current.sectionPages) as typeof current.sectionPages,input.patch.sectionPages as typeof current.sectionPages,theme.sections);
160
+ }
161
+ }
162
+ if(input.operation==='stage') {
163
+ validateDesignValues(input.patch);
164
+ const draft={...current.draftDesign,...input.patch};
165
+ if(input.patch.activeTheme && !listThemes().some(t=>t.slug===input.patch.activeTheme))throw new ApiError(400,'Theme is not installed. Install locally, build and deploy its code first.');
166
+ const theme=getTheme(String(draft.activeTheme??current.activeTheme));
167
+ ThemeTokens.parse({...theme.tokens,...draft.designOverrides as object});
168
+ const pageIds=new Set((await repo.listPages()).map(p=>p.id));
169
+ if(input.patch.sectionPages && Object.keys(input.patch.sectionPages).some(id=>!pageIds.has(id)))throw new ApiError(400,'A section layout refers to a missing page.');
170
+ await repo.setDesignDraft(draft,input.revision,identity.actor);
171
+ } else if(input.operation==='publish') {
172
+ const settings={...current,...pickDesignDraft(current.draftDesign??{})};
173
+ validateSectionDesign(settings,getTheme(settings.activeTheme));
174
+ await repo.applyDesignDraft({expectedRevision:input.revision,author:identity.actor,summary:summarizeChanges(diffDesign(current,await repo.listNavItems(),current.draftDesign))});
175
+ } else if(input.operation==='publishSection') {
176
+ const staged=(current.draftDesign?.sectionPages ?? current.sectionPages) as typeof current.sectionPages;
177
+ if(identity.role==='editor'){
178
+ const a=current.sectionPages[input.pageId]?.sections.find(s=>s.id===input.sectionId),b=staged[input.pageId]?.sections.find(s=>s.id===input.sectionId);
179
+ if(a&&b&&(['type','variant','hidden','showFrom','showUntil','locked'] as const).some(k=>JSON.stringify(a[k]??null)!==JSON.stringify(b[k]??null)))
180
+ throw new ApiError(403,'This section has style or visibility changes from an owner. Ask an owner to publish it.');
181
+ }
182
+ const next=publishOneSection(current.sectionPages,staged,input.pageId,input.sectionId);
183
+ validateSectionDesign({...current,sectionPages:next.live},getTheme(current.activeTheme));
184
+ const name=next.live[input.pageId].sections.find(s=>s.id===input.sectionId)?.type ?? 'section';
185
+ await repo.publishSectionPages(next.live,next.staged,{expectedRevision:input.revision,author:identity.actor,summary:`Section: ${name}`});
186
+ } else if(input.operation==='migrate') {
187
+ const theme=getTheme(String(current.draftDesign?.activeTheme ?? current.activeTheme));
188
+ const {pages,upgraded}=migrateLayouts((current.draftDesign?.sectionPages ?? current.sectionPages) as typeof current.sectionPages,theme);
189
+ if(!upgraded)throw new ApiError(409,'No sections need upgrading.');
190
+ await repo.setDesignDraft({...current.draftDesign,sectionPages:pages},input.revision,identity.actor);
191
+ } else if(input.operation==='discard') await repo.setDesignDraft(null,input.revision,identity.actor);
192
+ else if(input.operation==='restore') {
193
+ const version=await repo.getDesignVersion(input.id);if(!version)throw new ApiError(404,'Version not found.');
194
+ await repo.setDesignDraft({...version.snapshot,shareEpoch:crypto.randomUUID()},input.revision,identity.actor);
195
+ } else {
196
+ if(!current.draftDesign)throw new ApiError(409,'There is no draft to share.');
197
+ if(input.operation==='revoke')await repo.setDesignDraft({...current.draftDesign,shareEpoch:crypto.randomUUID()},input.revision,identity.actor);
198
+ else {
199
+ if(shareEpoch(current.draftDesign)==='legacy-disabled')throw new ApiError(409,'Save the draft before sharing.');
200
+ const {token,expiresAt}=createDesignToken(shareEpoch(current.draftDesign),input.days*86400000);
201
+ return {url:`${origin}/?preview=${token}`,expiresAt,revision:input.revision};
202
+ }
203
+ }
204
+ // Write-only credentials receive no private content inventory.
205
+ return {ok:true as const,revision:(await repo.getSettings()).designRevision};
206
+ }
package/src/lib/drafts.ts CHANGED
@@ -12,12 +12,12 @@ import type { CalEvent, NavItem, Page, Post, Settings } from './schema';
12
12
  export const POST_DRAFT_KEYS = [
13
13
  'title', 'slug', 'collection', 'body', 'bodyJson', 'bodyHtml', 'excerpt',
14
14
  'tags', 'cover', 'coverAlt', 'seoTitle', 'seoDesc', 'featured',
15
- 'authorIds', 'template', 'publishedAt',
15
+ 'authorIds', 'template', 'publishedAt', 'translations',
16
16
  ] as const;
17
17
 
18
18
  export const PAGE_DRAFT_KEYS = [
19
19
  'title', 'slug', 'locale', 'isHome', 'seoTitle', 'seoDesc', 'template',
20
- 'bodyJson', 'bodyHtml', 'blocks',
20
+ 'bodyJson', 'bodyHtml', 'blocks', 'translations',
21
21
  ] as const;
22
22
 
23
23
  // events stage everything the editor can change, times and recurrence
@@ -25,7 +25,7 @@ export const PAGE_DRAFT_KEYS = [
25
25
  export const EVENT_DRAFT_KEYS = [
26
26
  'title', 'slug', 'bodyJson', 'bodyHtml', 'excerpt', 'tags', 'cover', 'coverAlt',
27
27
  'seoTitle', 'seoDesc', 'featured', 'template', 'start', 'end', 'allDay',
28
- 'timezone', 'rrule', 'exdates', 'cancelled', 'location', 'cta', 'calendarIds', 'fields', 'rsvp',
28
+ 'timezone', 'rrule', 'exdates', 'cancelled', 'location', 'cta', 'calendarIds', 'fields', 'rsvp', 'translations', 'roomHold',
29
29
  ] as const;
30
30
 
31
31
  type PostDraftKey = (typeof POST_DRAFT_KEYS)[number];