@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.
- package/CHANGELOG.md +105 -0
- package/cli/common.mjs +2 -2
- package/cli/legacy.mjs +1 -1
- package/cli/main.mjs +82 -8
- package/cli/mobile-doctor.mjs +430 -0
- package/cli/mobile.mjs +315 -0
- package/cli/native-sections.mjs +168 -0
- package/cli/preview.mjs +8 -4
- package/cli/sections.mjs +188 -0
- package/cli/themes.mjs +52 -8
- package/docs/mobile.md +527 -0
- package/docs/sections.md +203 -0
- package/docs/theme-authoring.md +19 -3
- package/integration.mjs +76 -5
- package/mobile-template/client/AGENTS.md +97 -0
- package/mobile-template/client/README.md +65 -0
- package/mobile-template/client/app/_layout.tsx +56 -0
- package/mobile-template/client/app/index.tsx +21 -0
- package/mobile-template/client/app.json +21 -0
- package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
- package/mobile-template/client/eas.json +21 -0
- package/mobile-template/client/env.example +3 -0
- package/mobile-template/client/gitignore +42 -0
- package/mobile-template/client/package.json +38 -0
- package/mobile-template/client/sections/index.ts +11 -0
- package/mobile-template/client/tsconfig.json +7 -0
- package/mobile-template/editor/AGENTS.md +66 -0
- package/mobile-template/editor/README.md +73 -0
- package/mobile-template/editor/app/_layout.tsx +76 -0
- package/mobile-template/editor/app/auth.tsx +13 -0
- package/mobile-template/editor/app/index.tsx +55 -0
- package/mobile-template/editor/app.json +21 -0
- package/mobile-template/editor/components/Screen.tsx +51 -0
- package/mobile-template/editor/components/SignIn.tsx +36 -0
- package/mobile-template/editor/components/TextTabs.tsx +27 -0
- package/mobile-template/editor/eas.json +21 -0
- package/mobile-template/editor/env.example +5 -0
- package/mobile-template/editor/gitignore +42 -0
- package/mobile-template/editor/lib/appearance.tsx +12 -0
- package/mobile-template/editor/lib/config.ts +17 -0
- package/mobile-template/editor/lib/format.ts +24 -0
- package/mobile-template/editor/lib/tools.ts +16 -0
- package/mobile-template/editor/package.json +40 -0
- package/mobile-template/editor/tools/README.md +2 -0
- package/mobile-template/editor/tsconfig.json +7 -0
- package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
- package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
- package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
- package/mobile-template/recipes/booking/recipe.json +17 -0
- package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
- package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
- package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
- package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
- package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
- package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
- package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
- package/mobile-template/recipes/editor-content/recipe.json +8 -0
- package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
- package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
- package/mobile-template/recipes/editor-media/recipe.json +8 -0
- package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
- package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
- package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
- package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
- package/mobile-template/recipes/editor-sections/recipe.json +8 -0
- package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
- package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
- package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
- package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
- package/mobile-template/recipes/editor-translations/recipe.json +8 -0
- package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
- package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
- package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
- package/mobile-template/recipes/editor-writer/recipe.json +8 -0
- package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
- package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
- package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
- package/mobile-template/recipes/events/recipe.json +15 -0
- package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
- package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
- package/mobile-template/recipes/news/recipe.json +13 -0
- package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
- package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
- package/mobile-template/recipes/push/files/lib/push.ts +15 -0
- package/mobile-template/recipes/push/recipe.json +20 -0
- package/package.json +17 -13
- package/section-library/README.md +16 -0
- package/section-library/contact/Contact.astro +49 -0
- package/section-library/contact/section.json +106 -0
- package/section-library/cta-band/CtaBand.astro +28 -0
- package/section-library/cta-band/section.json +51 -0
- package/section-library/feature-grid/FeatureGrid.astro +54 -0
- package/section-library/feature-grid/section.json +107 -0
- package/section-library/gallery/Gallery.astro +29 -0
- package/section-library/gallery/section.json +51 -0
- package/section-library/hero-banner/HeroBanner.astro +42 -0
- package/section-library/hero-banner/section.json +81 -0
- package/section-library/latest-posts/LatestPosts.astro +22 -0
- package/section-library/latest-posts/section.json +37 -0
- package/section-library/pricing/Pricing.astro +40 -0
- package/section-library/pricing/section.json +117 -0
- package/section-library/questions/Questions.astro +38 -0
- package/section-library/questions/section.json +69 -0
- package/section-library/service-times/ServiceTimes.astro +42 -0
- package/section-library/service-times/section.json +127 -0
- package/section-library/team/Team.astro +45 -0
- package/section-library/team/section.json +88 -0
- package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
- package/section-library/upcoming-events/section.json +51 -0
- package/src/actions/index.ts +205 -174
- package/src/components/AiSettings.tsx +45 -5
- package/src/components/DesignManager.tsx +112 -16
- package/src/components/MobileApp.tsx +392 -0
- package/src/components/ThemeSections.astro +40 -21
- package/src/components/ThemeSectionsEditor.tsx +757 -229
- package/src/components/booking/BookingFlow.astro +6 -3
- package/src/components/calendar/BookingsHead.astro +51 -0
- package/src/components/calendar/CalendarBoard.tsx +618 -0
- package/src/components/calendar/MiniMonth.tsx +54 -0
- package/src/components/calendar/NewEventDialog.tsx +44 -0
- package/src/components/calendar/QuickAdd.tsx +263 -0
- package/src/components/calendar/RoomHolds.tsx +203 -0
- package/src/components/calendar/WhereItLands.tsx +51 -0
- package/src/components/editor/EventPanel.tsx +23 -2
- package/src/components/editor/PostEditor.tsx +206 -14
- package/src/components/import/ImportExport.tsx +4 -1
- package/src/components/import/runner.ts +1 -0
- package/src/components/sections/SectionFields.tsx +560 -0
- package/src/components/sections/SectionPicker.tsx +120 -0
- package/src/components/sections/TranslationPanel.tsx +143 -0
- package/src/components/sections/checks.ts +64 -0
- package/src/layouts/Admin.astro +11 -7
- package/src/layouts/Site.astro +136 -21
- package/src/lib/agent-discovery.ts +11 -7
- package/src/lib/agent.ts +35 -2
- package/src/lib/ai-schema.ts +5 -0
- package/src/lib/ai.ts +5 -0
- package/src/lib/api.ts +12 -0
- package/src/lib/backup/content.ts +6 -1
- package/src/lib/booking/agent.ts +3 -2
- package/src/lib/booking/approval.ts +70 -0
- package/src/lib/booking/holds.ts +120 -0
- package/src/lib/booking/service.ts +37 -3
- package/src/lib/calendar/agent-schema.ts +9 -1
- package/src/lib/calendar/agent.ts +16 -1
- package/src/lib/calendar/board.ts +171 -0
- package/src/lib/calendar/config.ts +3 -0
- package/src/lib/calendar/grid.ts +169 -0
- package/src/lib/calendar/migration.ts +16 -0
- package/src/lib/calendar/render.ts +9 -7
- package/src/lib/calendar/schedule.ts +166 -0
- package/src/lib/content-service.ts +203 -0
- package/src/lib/design-service.ts +153 -5
- package/src/lib/drafts.ts +3 -3
- package/src/lib/editor/auth.ts +264 -0
- package/src/lib/editor/bookings.ts +60 -0
- package/src/lib/editor/config.ts +25 -0
- package/src/lib/editor/documents.ts +227 -0
- package/src/lib/editor/http.ts +48 -0
- package/src/lib/editor/me.ts +30 -0
- package/src/lib/editor/migration.ts +57 -0
- package/src/lib/editor/next.ts +11 -0
- package/src/lib/editor/openapi.ts +124 -0
- package/src/lib/editor/schemas.ts +91 -0
- package/src/lib/editor/sections.ts +96 -0
- package/src/lib/editor/store.ts +184 -0
- package/src/lib/editor/token-route.ts +74 -0
- package/src/lib/editor/translations.ts +163 -0
- package/src/lib/email/accounts.ts +2 -1
- package/src/lib/email/types.ts +2 -1
- package/src/lib/home-source.ts +34 -0
- package/src/lib/i18n.ts +34 -0
- package/src/lib/import/apply.ts +31 -2
- package/src/lib/import/bundle.ts +4 -0
- package/src/lib/import/export.ts +1 -0
- package/src/lib/import/ghost.ts +1 -0
- package/src/lib/media/upload.ts +83 -0
- package/src/lib/mobile/associations.ts +25 -0
- package/src/lib/mobile/calendar.ts +26 -14
- package/src/lib/mobile/config.mjs +59 -0
- package/src/lib/mobile/content.ts +18 -13
- package/src/lib/mobile/embed.ts +83 -0
- package/src/lib/mobile/http.ts +14 -6
- package/src/lib/mobile/locale.ts +18 -0
- package/src/lib/mobile/openapi.ts +111 -0
- package/src/lib/mobile/pages.ts +71 -0
- package/src/lib/mobile/route.ts +3 -3
- package/src/lib/mobile/schemas.ts +60 -0
- package/src/lib/mobile/site.ts +41 -0
- package/src/lib/push/api.ts +158 -0
- package/src/lib/push/config.ts +20 -0
- package/src/lib/push/expo.ts +76 -0
- package/src/lib/push/migration.ts +60 -0
- package/src/lib/push/service.ts +302 -0
- package/src/lib/push/store.ts +144 -0
- package/src/lib/push/types.ts +43 -0
- package/src/lib/repo/migrations.ts +14 -0
- package/src/lib/repo/sql.ts +65 -20
- package/src/lib/repo/types.ts +10 -4
- package/src/lib/roles.ts +5 -0
- package/src/lib/scheduler.ts +6 -1
- package/src/lib/schema.ts +76 -1
- package/src/lib/section-ai.ts +55 -0
- package/src/lib/section-targets.ts +62 -0
- package/src/lib/sections-agent.ts +146 -0
- package/src/lib/sections.ts +242 -0
- package/src/lib/site-chrome.ts +34 -0
- package/src/lib/theme-assets.ts +16 -0
- package/src/lib/theme.ts +33 -4
- package/src/lib/translate.ts +97 -0
- package/src/lib/translations.ts +39 -0
- package/src/middleware.ts +38 -2
- package/src/pages/.well-known/apple-app-site-association.ts +6 -0
- package/src/pages/.well-known/assetlinks.json.ts +6 -0
- package/src/pages/[...slug].astro +8 -3
- package/src/pages/admin/app-auth.astro +159 -0
- package/src/pages/admin/c/[collection]/[id].astro +9 -0
- package/src/pages/admin/calendar/bookings.astro +32 -26
- package/src/pages/admin/calendar/calendars.astro +4 -263
- package/src/pages/admin/calendar/events/[id].astro +9 -0
- package/src/pages/admin/calendar/events/index.astro +41 -83
- package/src/pages/admin/calendar/index.astro +43 -0
- package/src/pages/admin/calendar/resources/[id].astro +27 -5
- package/src/pages/admin/calendar/resources/index.astro +16 -17
- package/src/pages/admin/calendar/resources/link.astro +52 -12
- package/src/pages/admin/calendar/settings.astro +458 -0
- package/src/pages/admin/integrations.astro +5 -11
- package/src/pages/admin/login.astro +6 -2
- package/src/pages/admin/logout.ts +5 -2
- package/src/pages/admin/mobile.astro +18 -0
- package/src/pages/admin/pages/[id].astro +62 -0
- package/src/pages/admin/section-preview.astro +61 -0
- package/src/pages/admin/users.astro +107 -11
- package/src/pages/api/agent/docs.ts +6 -2
- package/src/pages/api/agent/index.ts +1 -1
- package/src/pages/api/ai/settings.ts +7 -4
- package/src/pages/api/design/ai.ts +15 -0
- package/src/pages/api/design/index.ts +12 -42
- package/src/pages/api/design/translate.ts +15 -0
- package/src/pages/api/live/thumb.ts +1 -1
- package/src/pages/api/mcp.ts +2 -2
- package/src/pages/api/media/upload.ts +10 -78
- package/src/pages/api/push/admin.ts +27 -0
- package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
- package/src/pages/api/v1/admin/bookings/index.ts +6 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
- package/src/pages/api/v1/admin/documents/index.ts +13 -0
- package/src/pages/api/v1/admin/me.ts +6 -0
- package/src/pages/api/v1/admin/media/index.ts +32 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
- package/src/pages/api/v1/admin/sections/index.ts +6 -0
- package/src/pages/api/v1/auth/revoke.ts +5 -0
- package/src/pages/api/v1/auth/token.ts +5 -0
- package/src/pages/api/v1/devices/[token].ts +20 -0
- package/src/pages/api/v1/devices/index.ts +17 -0
- package/src/pages/api/v1/openapi.json.ts +7 -0
- package/src/pages/api/v1/pages/[...slug].ts +7 -0
- package/src/pages/api/v1/pages/home.ts +7 -0
- package/src/pages/api/v1/push/topics.ts +7 -0
- package/src/pages/api/v1/site.ts +7 -0
- package/src/pages/api/v1/theme.ts +7 -0
- package/src/pages/blog/[slug].astro +8 -2
- package/src/pages/blog/index.astro +11 -4
- package/src/pages/book/[resource].ics.ts +7 -1
- package/src/pages/embed/section/[...path].astro +42 -0
- package/src/pages/events/[slug].astro +7 -2
- package/src/pages/index.astro +11 -5
- package/src/pages/sitemap.xml.ts +8 -0
- package/src/styles/admin.css +117 -0
- package/src/styles/cards.css +5 -1
- package/src/styles/editor.css +57 -0
- package/src/styles/mobile-app.css +151 -0
- package/src/styles/schedule.css +325 -0
- package/src/styles/sections.css +21 -12
- package/src/styles/theme-editor.css +255 -0
- package/src/theme-sdk/contract.d.mts +2193 -89
- package/src/theme-sdk/contract.mjs +77 -20
- package/src/theme-sdk/fields.d.mts +88 -0
- package/src/theme-sdk/fields.mjs +330 -0
- package/src/theme-sdk/index.ts +14 -2
- package/src/theme-sdk/tokens.d.mts +11 -0
- package/src/virtual.d.ts +4 -0
- 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
37
|
-
|
|
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:
|
|
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];
|