@acms-subir/core 0.3.3 → 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 +36 -0
- package/cli/preview.mjs +8 -4
- package/cli/sections.mjs +4 -2
- package/docs/mobile.md +1 -1
- package/docs/theme-authoring.md +19 -3
- package/integration.mjs +21 -4
- package/package.json +14 -12
- package/src/actions/index.ts +186 -3
- 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 +32 -4
- package/src/layouts/Admin.astro +5 -6
- package/src/layouts/Site.astro +29 -2
- 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 +5 -0
- package/src/lib/drafts.ts +1 -1
- package/src/lib/mobile/calendar.ts +10 -6
- package/src/lib/mobile/http.ts +6 -0
- package/src/lib/repo/migrations.ts +3 -0
- package/src/lib/repo/sql.ts +28 -16
- package/src/lib/repo/types.ts +2 -2
- package/src/lib/scheduler.ts +3 -1
- package/src/lib/schema.ts +49 -0
- package/src/lib/sections.ts +33 -8
- package/src/lib/site-chrome.ts +34 -0
- package/src/lib/theme-assets.ts +16 -0
- package/src/lib/theme.ts +17 -3
- package/src/middleware.ts +6 -2
- package/src/pages/admin/calendar/bookings.astro +32 -26
- package/src/pages/admin/calendar/calendars.astro +4 -263
- 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/book/[resource].ics.ts +7 -1
- package/src/styles/admin.css +48 -0
- package/src/styles/cards.css +5 -1
- package/src/styles/editor.css +36 -0
- package/src/styles/schedule.css +325 -0
- package/src/styles/sections.css +19 -16
- package/src/theme-sdk/fields.mjs +5 -1
- package/src/theme-sdk/index.ts +5 -1
- package/src/virtual.d.ts +3 -0
- package/src/pages/admin/calendar/index.ts +0 -4
|
@@ -8,9 +8,10 @@ import { availabilityContext } from '../booking/service';
|
|
|
8
8
|
import { addDaysYmd, describeLink, linkLengths, localMidnight, slotsForDay, ymd } from '../booking/availability';
|
|
9
9
|
import { siteOrigin } from '../seo';
|
|
10
10
|
import { safeUrl } from './content';
|
|
11
|
-
import { pageQuery, isoDay, queryParams } from './http';
|
|
11
|
+
import { pageQuery, isoDay, isoDayOrInstant, queryParams } from './http';
|
|
12
12
|
import { localizeDoc } from '../translations';
|
|
13
13
|
import { localeQuery, mobileLocale, localizedUrl } from './locale';
|
|
14
|
+
import { confirmsInstantly, linkApproval } from '../booking/approval';
|
|
14
15
|
|
|
15
16
|
export function requireCalendar(bookings = false) {
|
|
16
17
|
if (!CALENDAR.enabled || (bookings && !bookingsEnabled())) throw new ApiError(404, 'This feature is disabled.');
|
|
@@ -21,7 +22,7 @@ export async function listCalendars(repo: ContentRepository, url: URL) {
|
|
|
21
22
|
const origin = siteOrigin(await repo.getSettings(), url);
|
|
22
23
|
return { calendars: (await repo.listCalendars()).filter(c => c.visibility === 'public').map(c => calendarToJson(c, origin)) };
|
|
23
24
|
}
|
|
24
|
-
export const eventsQuery = z.object({ ...pageQuery, locale: localeQuery, from:
|
|
25
|
+
export const eventsQuery = z.object({ ...pageQuery, locale: localeQuery, from: isoDayOrInstant.optional(), to: isoDayOrInstant.optional(), calendar: z.string().min(1).max(200).optional(), q: z.string().max(100).optional() }).strict();
|
|
25
26
|
export async function listEvents(repo: ContentRepository, url: URL) {
|
|
26
27
|
requireCalendar();
|
|
27
28
|
const q = eventsQuery.parse(queryParams(url));
|
|
@@ -29,8 +30,11 @@ export async function listEvents(repo: ContentRepository, url: URL) {
|
|
|
29
30
|
const [settings, calendars, events] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listPublishedEvents()]);
|
|
30
31
|
// days are the site's calendar days (a New York site's "today" starts at midnight there, not UTC)
|
|
31
32
|
const tz = settings.timezone || 'UTC';
|
|
32
|
-
|
|
33
|
-
const
|
|
33
|
+
// a plain day is the site's midnight; a date-time is that exact instant
|
|
34
|
+
const at = (v: string) => (v.length === 10 ? localMidnight(v, tz) : Date.parse(v));
|
|
35
|
+
const fromDay = q.from ?? ymd(Date.now(), tz);
|
|
36
|
+
const from = at(fromDay);
|
|
37
|
+
const to = q.to ? at(q.to) : fromDay.length === 10 ? localMidnight(addDaysYmd(fromDay, 30), tz) : from + 30 * 86400000;
|
|
34
38
|
if (to <= from || to - from > 90 * 86400000 + 3600000) throw new ApiError(400, 'Use an event window of 1 to 90 days.');
|
|
35
39
|
const all = new Map(calendars.map(c => [c.id, c]));
|
|
36
40
|
const cal = q.calendar ? calendars.find(c => c.slug === q.calendar && c.visibility !== 'private') : null;
|
|
@@ -67,7 +71,7 @@ export async function listResources(repo: ContentRepository, url: URL) {
|
|
|
67
71
|
photo: safeUrl(r.photo, origin), timezone: r.timezone || settings.timezone || 'UTC', url: origin + resourceUrl(r.slug),
|
|
68
72
|
links: links.filter(l => l.resourceId === r.id && l.active && l.visibility === 'public').map(l => ({
|
|
69
73
|
id: l.id, slug: l.slug, name: l.name, description: l.description, mode: l.mode, lengths: linkLengths(l), rules: describeLink(l),
|
|
70
|
-
askPhone: l.askPhone, autoConfirm: l
|
|
74
|
+
askPhone: l.askPhone, autoConfirm: confirmsInstantly(linkApproval(l, settings)), url: origin + bookingLinkUrl(r.slug, l.slug),
|
|
71
75
|
questions: l.questions.map(({ id, label, type, required, options }) => ({ id, label, type, required, options })),
|
|
72
76
|
})),
|
|
73
77
|
})), pagination: { page: q.page, limit: q.limit, nextPage: visible.length > start + q.limit ? q.page + 1 : null } };
|
|
@@ -85,7 +89,7 @@ export async function getAvailability(repo: ContentRepository, url: URL) {
|
|
|
85
89
|
const ctx = await availabilityContext(repo, resource, link, settings);
|
|
86
90
|
const timezone = resource.timezone || settings.timezone || 'UTC';
|
|
87
91
|
const from = q.from ?? ymd(Date.now(), timezone);
|
|
88
|
-
return { timezone, mode: link.mode, lengths: linkLengths(link), autoConfirm: link
|
|
92
|
+
return { timezone, mode: link.mode, lengths: linkLengths(link), autoConfirm: confirmsInstantly(linkApproval(link, settings)),
|
|
89
93
|
days: Array.from({ length: q.days }, (_, i) => addDaysYmd(from, i)).map(date => ({ date,
|
|
90
94
|
slots: slotsForDay(ctx, date, q.minutes).map(s => ({ start: new Date(s.start).toISOString(), end: new Date(s.end).toISOString(), minutes: s.minutes })),
|
|
91
95
|
})),
|
package/src/lib/mobile/http.ts
CHANGED
|
@@ -57,6 +57,12 @@ export const pageQuery = {
|
|
|
57
57
|
export const isoDay = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine((s) => {
|
|
58
58
|
const n = Date.parse(s); return Number.isFinite(n) && new Date(n).toISOString().slice(0, 10) === s;
|
|
59
59
|
}, 'Use a valid calendar date.');
|
|
60
|
+
// a site-local day ("2026-10-01" = midnight in the site timezone) or an exact
|
|
61
|
+
// ISO 8601 instant with a zone ("2026-10-01T18:00:00Z", "…T13:00:00-05:00")
|
|
62
|
+
export const isoDayOrInstant = z.string().max(40).refine((s) => {
|
|
63
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return isoDay.safeParse(s).success;
|
|
64
|
+
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})$/.test(s) && Number.isFinite(Date.parse(s));
|
|
65
|
+
}, 'Use a date (YYYY-MM-DD) or an ISO date-time with a timezone (2026-10-01T18:00:00Z).');
|
|
60
66
|
|
|
61
67
|
export function methodNotAllowed(allow = 'GET, HEAD, OPTIONS') {
|
|
62
68
|
const res = failure(new ApiError(405, 'Method not allowed.'));
|
|
@@ -4,6 +4,7 @@ import { EMAIL_MIGRATION } from '../email/migration';
|
|
|
4
4
|
import { WEBHOOK_MIGRATION } from '../webhook-sql';
|
|
5
5
|
import { PUSH_MIGRATION } from '../push/migration';
|
|
6
6
|
import { EDITOR_MIGRATION, EDITOR_GRACE_MIGRATION } from '../editor/migration';
|
|
7
|
+
import { CALENDAR_SETTINGS_MIGRATION, ROOM_HOLD_MIGRATION } from '../calendar/migration';
|
|
7
8
|
// Versioned schema migrations shared by the two SQLite-dialect adapters
|
|
8
9
|
// (D1 and libSQL). Applied versions are recorded in the `migrations` table;
|
|
9
10
|
// each adapter's runner applies whatever is newer, in order, at startup.
|
|
@@ -512,6 +513,8 @@ export const MIGRATIONS: Migration[] = [
|
|
|
512
513
|
PUSH_MIGRATION,
|
|
513
514
|
EDITOR_MIGRATION,
|
|
514
515
|
EDITOR_GRACE_MIGRATION,
|
|
516
|
+
CALENDAR_SETTINGS_MIGRATION,
|
|
517
|
+
ROOM_HOLD_MIGRATION,
|
|
515
518
|
];
|
|
516
519
|
|
|
517
520
|
export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -144,7 +144,7 @@ function rowToEvent(r: Row): CalEvent {
|
|
|
144
144
|
start: r.starts, end: r.ends ?? '', allDay: Boolean(r.all_day), timezone: r.timezone ?? '',
|
|
145
145
|
rrule: r.rrule ?? '', exdates: json(r.exdates, []), cancelled: Boolean(r.cancelled),
|
|
146
146
|
location: json(r.location, {}), cta: json(r.cta, {}), calendarIds: json(r.calendar_ids, []),
|
|
147
|
-
fields: json(r.fields, {}), sourceUid: r.source_uid ?? '', rsvp: json(r.rsvp, {}),
|
|
147
|
+
fields: json(r.fields, {}), sourceUid: r.source_uid ?? '', rsvp: json(r.rsvp, {}), roomHold: json(r.room_hold, {}),
|
|
148
148
|
startAt: Number(r.start_at), endAt: Number(r.end_at), draft: parseDraft(r.draft), translations: parseTranslations(r.translations),
|
|
149
149
|
publishedAt: r.published_at === null || r.published_at === undefined ? null : Number(r.published_at),
|
|
150
150
|
scheduledAt: r.scheduled_at === null || r.scheduled_at === undefined ? null : Number(r.scheduled_at),
|
|
@@ -161,6 +161,14 @@ function rowToResource(r: Row): Resource {
|
|
|
161
161
|
createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
162
162
|
});
|
|
163
163
|
}
|
|
164
|
+
// Callers that still speak the old on/off flag (REST, agent tools, older
|
|
165
|
+
// code) set autoConfirm without an approval mode: map it so "off" really
|
|
166
|
+
// holds bookings. On create, "on" follows the site default (which starts as
|
|
167
|
+
// automatic); on update it pins the link to automatic.
|
|
168
|
+
function legacyApproval<T extends { autoConfirm?: boolean; approval?: BookingLink['approval'] }>(data: T, update = false): T {
|
|
169
|
+
if (data.approval !== undefined || data.autoConfirm === undefined) return data;
|
|
170
|
+
return { ...data, approval: data.autoConfirm ? (update ? 'auto' : 'default') : 'manual' };
|
|
171
|
+
}
|
|
164
172
|
function rowToLink2(r: Row): BookingLink {
|
|
165
173
|
return BookingLink.parse({
|
|
166
174
|
id: r.id, resourceId: r.resource_id, slug: r.slug, name: r.name, description: r.description ?? '', mode: r.mode ?? 'slots',
|
|
@@ -168,6 +176,7 @@ function rowToLink2(r: Row): BookingLink {
|
|
|
168
176
|
step: Number(r.step), bufferBefore: Number(r.buffer_before), bufferAfter: Number(r.buffer_after),
|
|
169
177
|
minNotice: Number(r.min_notice), maxAdvance: Number(r.max_advance), perDayCap: Number(r.per_day_cap),
|
|
170
178
|
hours: r.hours ? json(r.hours, null) : null, autoConfirm: Boolean(r.auto_confirm), visibility: r.visibility ?? 'public',
|
|
179
|
+
approval: r.approval ?? 'default', approvalRules: json(r.approval_rules, {}),
|
|
171
180
|
askPhone: Boolean(r.ask_phone), questions: json(r.questions, []), confirmMessage: r.confirm_message ?? '',
|
|
172
181
|
redirectUrl: r.redirect_url ?? '', active: Boolean(r.active), order: Number(r.ord ?? 0),
|
|
173
182
|
createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
@@ -811,8 +820,8 @@ export class SqlRepository implements ContentRepository {
|
|
|
811
820
|
await this.driver.run(
|
|
812
821
|
`INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
|
|
813
822
|
starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
|
|
814
|
-
published_at, scheduled_at, created_at, updated_at, rsvp)
|
|
815
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
823
|
+
published_at, scheduled_at, created_at, updated_at, rsvp, room_hold)
|
|
824
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
816
825
|
[
|
|
817
826
|
id, data.slug, data.title, data.bodyJson ?? '', data.bodyHtml ?? '', data.excerpt ?? '', data.cover ?? '', data.coverAlt ?? '',
|
|
818
827
|
data.seoTitle ?? '', data.seoDesc ?? '', data.status ?? 'draft', data.featured ? 1 : 0, JSON.stringify(data.tags ?? []), data.template ?? '',
|
|
@@ -820,7 +829,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
820
829
|
data.cancelled ? 1 : 0, JSON.stringify(data.location ?? {}), JSON.stringify(data.cta ?? {}), JSON.stringify(data.calendarIds ?? []),
|
|
821
830
|
JSON.stringify(data.fields ?? {}), data.sourceUid ?? '', startAt, endAt,
|
|
822
831
|
data.publishedAt ?? (data.status === 'published' ? now : null), data.scheduledAt ?? null, now, now,
|
|
823
|
-
JSON.stringify(data.rsvp ?? {}),
|
|
832
|
+
JSON.stringify(data.rsvp ?? {}), JSON.stringify(data.roomHold ?? {}),
|
|
824
833
|
]
|
|
825
834
|
);
|
|
826
835
|
await this.syncEventIndex(id);
|
|
@@ -845,7 +854,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
845
854
|
if (v !== undefined) { sets.push(`${col} = ?`); args.push(v); }
|
|
846
855
|
}
|
|
847
856
|
const jsonCols: Record<string, string> = {
|
|
848
|
-
tags: 'tags', exdates: 'exdates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp', translations: 'translations',
|
|
857
|
+
tags: 'tags', exdates: 'exdates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp', translations: 'translations', roomHold: 'room_hold',
|
|
849
858
|
};
|
|
850
859
|
for (const [k, col] of Object.entries(jsonCols)) {
|
|
851
860
|
const v = (data as Record<string, unknown>)[k];
|
|
@@ -936,10 +945,11 @@ export class SqlRepository implements ContentRepository {
|
|
|
936
945
|
return {
|
|
937
946
|
sql: `INSERT INTO booking_links (id, resource_id, slug, name, description, mode, duration, lengths, min_days, max_days, step, buffer_before,
|
|
938
947
|
buffer_after, min_notice, max_advance, per_day_cap, hours, auto_confirm, visibility, ask_phone, questions, confirm_message, redirect_url,
|
|
939
|
-
active, ord, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
948
|
+
active, ord, created_at, updated_at, approval, approval_rules) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
940
949
|
args: [l.id, l.resourceId, l.slug, l.name, l.description, l.mode, l.duration, JSON.stringify(l.lengths), l.minDays, l.maxDays, l.step,
|
|
941
950
|
l.bufferBefore, l.bufferAfter, l.minNotice, l.maxAdvance, l.perDayCap, l.hours ? JSON.stringify(l.hours) : null, l.autoConfirm ? 1 : 0,
|
|
942
|
-
l.visibility, l.askPhone ? 1 : 0, JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.createdAt, l.updatedAt
|
|
951
|
+
l.visibility, l.askPhone ? 1 : 0, JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.createdAt, l.updatedAt,
|
|
952
|
+
l.approval, JSON.stringify(l.approvalRules)],
|
|
943
953
|
};
|
|
944
954
|
}
|
|
945
955
|
private bookingInsert(b: Booking): SqlStatement {
|
|
@@ -1017,7 +1027,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
1017
1027
|
await this.ready;
|
|
1018
1028
|
const now = Date.now();
|
|
1019
1029
|
const next = await this.first('SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM booking_links WHERE resource_id = ?', [data.resourceId]);
|
|
1020
|
-
const l = BookingLink.parse({ ...data, id: crypto.randomUUID(), order: Number(next?.n ?? 0), createdAt: now, updatedAt: now });
|
|
1030
|
+
const l = BookingLink.parse({ ...legacyApproval(data), id: crypto.randomUUID(), order: Number(next?.n ?? 0), createdAt: now, updatedAt: now });
|
|
1021
1031
|
const st = this.linkInsert(l);
|
|
1022
1032
|
await this.driver.run(st.sql, st.args);
|
|
1023
1033
|
return l;
|
|
@@ -1026,14 +1036,14 @@ export class SqlRepository implements ContentRepository {
|
|
|
1026
1036
|
await this.ready;
|
|
1027
1037
|
const cur = await this.getBookingLink(id);
|
|
1028
1038
|
if (!cur) return;
|
|
1029
|
-
const l = BookingLink.parse({ ...cur, ...data, id, updatedAt: Date.now() });
|
|
1039
|
+
const l = BookingLink.parse({ ...cur, ...legacyApproval(data, true), id, updatedAt: Date.now() });
|
|
1030
1040
|
await this.driver.run(
|
|
1031
1041
|
`UPDATE booking_links SET slug = ?, name = ?, description = ?, mode = ?, duration = ?, lengths = ?, min_days = ?, max_days = ?, step = ?,
|
|
1032
1042
|
buffer_before = ?, buffer_after = ?, min_notice = ?, max_advance = ?, per_day_cap = ?, hours = ?, auto_confirm = ?, visibility = ?,
|
|
1033
|
-
ask_phone = ?, questions = ?, confirm_message = ?, redirect_url = ?, active = ?, ord = ?, updated_at = ? WHERE id = ?`,
|
|
1043
|
+
ask_phone = ?, questions = ?, confirm_message = ?, redirect_url = ?, active = ?, ord = ?, updated_at = ?, approval = ?, approval_rules = ? WHERE id = ?`,
|
|
1034
1044
|
[l.slug, l.name, l.description, l.mode, l.duration, JSON.stringify(l.lengths), l.minDays, l.maxDays, l.step, l.bufferBefore, l.bufferAfter,
|
|
1035
1045
|
l.minNotice, l.maxAdvance, l.perDayCap, l.hours ? JSON.stringify(l.hours) : null, l.autoConfirm ? 1 : 0, l.visibility, l.askPhone ? 1 : 0,
|
|
1036
|
-
JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.updatedAt, id]
|
|
1046
|
+
JSON.stringify(l.questions), l.confirmMessage, l.redirectUrl, l.active ? 1 : 0, l.order, l.updatedAt, l.approval, JSON.stringify(l.approvalRules), id]
|
|
1037
1047
|
);
|
|
1038
1048
|
}
|
|
1039
1049
|
async deleteBookingLink(id: string) {
|
|
@@ -1048,6 +1058,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
1048
1058
|
const args: unknown[] = [];
|
|
1049
1059
|
if (filter.resourceId) { where.push('resource_id = ?'); args.push(filter.resourceId); }
|
|
1050
1060
|
if (filter.eventId) { where.push('event_id = ?'); args.push(filter.eventId); }
|
|
1061
|
+
if (filter.email) { where.push('email = ?'); args.push(filter.email.toLowerCase()); }
|
|
1051
1062
|
if (filter.from !== undefined) { where.push('end_at > ?'); args.push(filter.from); }
|
|
1052
1063
|
if (filter.to !== undefined) { where.push('start_at < ?'); args.push(filter.to); }
|
|
1053
1064
|
if (filter.statuses?.length) { where.push(`status IN (${filter.statuses.map(() => '?').join(', ')})`); args.push(...filter.statuses); }
|
|
@@ -1614,14 +1625,14 @@ export class SqlRepository implements ContentRepository {
|
|
|
1614
1625
|
...(content.events ?? []).map((e) => ({
|
|
1615
1626
|
sql: `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
|
|
1616
1627
|
starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
|
|
1617
|
-
draft, published_at, scheduled_at, created_at, updated_at, rsvp)
|
|
1618
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1628
|
+
draft, published_at, scheduled_at, created_at, updated_at, rsvp, room_hold)
|
|
1629
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1619
1630
|
args: [
|
|
1620
1631
|
e.id, e.slug, e.title, e.bodyJson, e.bodyHtml, e.excerpt, e.cover, e.coverAlt, e.seoTitle, e.seoDesc, e.status, e.featured ? 1 : 0,
|
|
1621
1632
|
JSON.stringify(e.tags), e.template, e.start, e.end, e.allDay ? 1 : 0, e.timezone, e.rrule, JSON.stringify(e.exdates),
|
|
1622
1633
|
e.cancelled ? 1 : 0, JSON.stringify(e.location), JSON.stringify(e.cta), JSON.stringify(e.calendarIds), JSON.stringify(e.fields),
|
|
1623
1634
|
e.sourceUid, e.startAt, e.endAt, e.draft ? JSON.stringify(e.draft) : null, e.publishedAt, e.scheduledAt, e.createdAt, e.updatedAt,
|
|
1624
|
-
JSON.stringify(e.rsvp ?? {}),
|
|
1635
|
+
JSON.stringify(e.rsvp ?? {}), JSON.stringify(e.roomHold ?? {}),
|
|
1625
1636
|
],
|
|
1626
1637
|
})),
|
|
1627
1638
|
...(content.resources ?? []).map((r) => this.resourceInsert(r)),
|
|
@@ -1836,6 +1847,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
1836
1847
|
ai: JSON.parse(String(r?.ai ?? '{}')),
|
|
1837
1848
|
live: JSON.parse(String(r?.live ?? '{}')),
|
|
1838
1849
|
push: JSON.parse(String(r?.push ?? '{}')),
|
|
1850
|
+
calendar: JSON.parse(String(r?.calendar ?? '{}')),
|
|
1839
1851
|
agent: JSON.parse(String(r?.agent ?? '{}')),
|
|
1840
1852
|
draftsApiKey: r?.drafts_api_key ?? '',
|
|
1841
1853
|
draftsAllowedOrigins: JSON.parse(String(r?.drafts_allowed_origins ?? '[]')),
|
|
@@ -1852,9 +1864,9 @@ export class SqlRepository implements ContentRepository {
|
|
|
1852
1864
|
siteTitle:'site_title', tagline:'tagline', footerText:'footer_text', linksName:'links_name', linksBio:'links_bio', linksAvatar:'links_avatar',
|
|
1853
1865
|
activeTheme:'active_theme', designOverrides:'design_overrides', sectionPages:'section_pages', siteLogo:'site_logo', siteUrl:'site_url', siteFavicon:'site_favicon', siteCover:'site_cover',
|
|
1854
1866
|
announcement:'announcement', analytics:'analytics', headCode:'head_code', footerCode:'footer_code', timezone:'timezone', locale:'locale',
|
|
1855
|
-
draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live', push:'push',
|
|
1867
|
+
draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live', push:'push', calendar:'calendar',
|
|
1856
1868
|
};
|
|
1857
|
-
const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live','push']);
|
|
1869
|
+
const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live','push','calendar']);
|
|
1858
1870
|
const entries = Object.entries(data).filter(([k,v]) => columns[k] && v !== undefined);
|
|
1859
1871
|
if (!entries.length) return;
|
|
1860
1872
|
const design = entries.some(([k]) => ['activeTheme','designOverrides','sectionPages','siteLogo','siteFavicon','siteCover','draftDesign'].includes(k));
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -39,7 +39,7 @@ export type EventUpdate = Partial<
|
|
|
39
39
|
| 'title' | 'slug' | 'bodyJson' | 'bodyHtml' | 'excerpt' | 'cover' | 'coverAlt'
|
|
40
40
|
| 'seoTitle' | 'seoDesc' | 'status' | 'featured' | 'tags' | 'template'
|
|
41
41
|
| 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled'
|
|
42
|
-
| 'location' | 'cta' | 'calendarIds' | 'fields' | 'sourceUid' | 'rsvp' | 'translations'
|
|
42
|
+
| 'location' | 'cta' | 'calendarIds' | 'fields' | 'sourceUid' | 'rsvp' | 'translations' | 'roomHold'
|
|
43
43
|
| 'publishedAt' | 'scheduledAt'
|
|
44
44
|
>
|
|
45
45
|
>;
|
|
@@ -152,7 +152,7 @@ export interface ContentRepository {
|
|
|
152
152
|
// write that can fail on a race: it inserts atomically only if the held
|
|
153
153
|
// range is still free (resource bookings) or seats remain (RSVPs), and
|
|
154
154
|
// returns null when it lost.
|
|
155
|
-
listBookings(filter?: { resourceId?: string; eventId?: string; from?: number; to?: number; statuses?: BookingStatus[]; kinds?: Booking['kind'][] }): Promise<Booking[]>;
|
|
155
|
+
listBookings(filter?: { resourceId?: string; eventId?: string; email?: string; from?: number; to?: number; statuses?: BookingStatus[]; kinds?: Booking['kind'][] }): Promise<Booking[]>;
|
|
156
156
|
getBooking(id: string): Promise<Booking | null>;
|
|
157
157
|
getBookingByToken(token: string): Promise<Booking | null>;
|
|
158
158
|
createBooking(
|
package/src/lib/scheduler.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { syncDueLive } from './live/service';
|
|
|
2
2
|
import type { ContentRepository } from './repo/types';
|
|
3
3
|
import { processDueDeliveries } from './webhooks';
|
|
4
4
|
import { dispatchEmail } from './email/service';
|
|
5
|
-
import { sendDueReminders } from './booking/service';
|
|
5
|
+
import { decideOverdue, sendDueReminders } from './booking/service';
|
|
6
6
|
import { runPushJobs } from './push/service';
|
|
7
7
|
|
|
8
8
|
// All work is awaited on both hosts. Traffic-triggered fallbacks do not mark
|
|
@@ -14,6 +14,8 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
|
|
|
14
14
|
publish: () => repo.publishDuePosts(startedAt),
|
|
15
15
|
webhooks: () => processDueDeliveries(repo),
|
|
16
16
|
reminders: () => sendDueReminders(repo, { origin: settings.siteUrl, locals, now: startedAt }),
|
|
17
|
+
// pending bookings past their approval deadline (Calendar settings → Bookings)
|
|
18
|
+
approvals: () => decideOverdue(repo, { origin: settings.siteUrl, locals, now: startedAt }),
|
|
17
19
|
email: () => dispatchEmail(repo, locals, 10),
|
|
18
20
|
live: () => syncDueLive(repo, locals),
|
|
19
21
|
// queued first-publish notices, starting-soon reminders, receipts, retention
|
package/src/lib/schema.ts
CHANGED
|
@@ -314,6 +314,17 @@ export const EventCta = z.object({
|
|
|
314
314
|
});
|
|
315
315
|
export type EventCta = z.infer<typeof EventCta>;
|
|
316
316
|
|
|
317
|
+
export const RoomHold = z.object({
|
|
318
|
+
resourceIds: z.array(z.string()).default([]),
|
|
319
|
+
span: z.enum(['event', 'day']).default('event'),
|
|
320
|
+
before: z.number().int().min(0).max(1440).default(0), // setup minutes
|
|
321
|
+
after: z.number().int().min(0).max(1440).default(0), // cleanup minutes
|
|
322
|
+
whileDraft: z.boolean().default(false),
|
|
323
|
+
// bookings you chose to keep (share the room): no longer flagged as conflicts
|
|
324
|
+
keep: z.array(z.string()).default([]),
|
|
325
|
+
});
|
|
326
|
+
export type RoomHold = z.infer<typeof RoomHold>;
|
|
327
|
+
|
|
317
328
|
export const CalEvent = z.object({
|
|
318
329
|
id: z.string(),
|
|
319
330
|
slug: z.string(),
|
|
@@ -355,6 +366,10 @@ export const CalEvent = z.object({
|
|
|
355
366
|
autoConfirm: z.boolean().default(true),
|
|
356
367
|
maxSeats: z.number().int().default(4),
|
|
357
368
|
}).default({}),
|
|
369
|
+
// rooms/people this event uses: they can't be booked while it happens (or
|
|
370
|
+
// all day). Computed into availability, never stored as bookings. Drafts
|
|
371
|
+
// only hold when whileDraft is on; cancelled events never hold.
|
|
372
|
+
roomHold: RoomHold.default({}),
|
|
358
373
|
startAt: z.number(),
|
|
359
374
|
endAt: z.number(),
|
|
360
375
|
translations: DocTranslations,
|
|
@@ -444,6 +459,36 @@ export type BookingQuestion = z.infer<typeof BookingQuestion>;
|
|
|
444
459
|
export const BookingMode = z.enum(['slots', 'range', 'day']);
|
|
445
460
|
export type BookingMode = z.infer<typeof BookingMode>;
|
|
446
461
|
|
|
462
|
+
// Booking approval: "auto" confirms at once, "manual" holds every booking for
|
|
463
|
+
// a decision, "rules" confirms unless one of the rules flags it. A link can
|
|
464
|
+
// follow the site default ("default") or pick its own. The deadline decides
|
|
465
|
+
// pending bookings nobody answered (0 hours = keep waiting).
|
|
466
|
+
export const ApprovalMode = z.enum(['auto', 'rules', 'manual']);
|
|
467
|
+
export type ApprovalMode = z.infer<typeof ApprovalMode>;
|
|
468
|
+
export const ApprovalRules = z.object({
|
|
469
|
+
// 0 = rule off
|
|
470
|
+
longerThan: z.number().int().min(0).default(0), // minutes
|
|
471
|
+
firstTime: z.boolean().default(false),
|
|
472
|
+
moreSeats: z.number().int().min(0).default(0),
|
|
473
|
+
deadlineHours: z.number().int().min(0).max(24 * 60).default(0),
|
|
474
|
+
deadlineAction: z.enum(['approve', 'decline']).default('approve'),
|
|
475
|
+
});
|
|
476
|
+
export type ApprovalRules = z.infer<typeof ApprovalRules>;
|
|
477
|
+
|
|
478
|
+
// Site-wide calendar preferences (Calendar settings → Defaults / Bookings)
|
|
479
|
+
export const CalendarSettings = z.object({
|
|
480
|
+
// first column of every month grid: 0 = Sunday, 1 = Monday
|
|
481
|
+
weekStart: z.union([z.literal(0), z.literal(1)]).default(0),
|
|
482
|
+
// calendar new events land in (quick add, New event); '' = none
|
|
483
|
+
defaultCalendarId: z.string().default(''),
|
|
484
|
+
// '' = not chosen in the admin yet → acms({ calendar.bookings.approval }) → auto
|
|
485
|
+
approval: z.union([ApprovalMode, z.literal('')]).default(''),
|
|
486
|
+
approvalRules: ApprovalRules.default({}),
|
|
487
|
+
// show bookings on the Schedule grid
|
|
488
|
+
showBookings: z.boolean().default(true),
|
|
489
|
+
});
|
|
490
|
+
export type CalendarSettings = z.infer<typeof CalendarSettings>;
|
|
491
|
+
|
|
447
492
|
export const BookingLink = z.object({
|
|
448
493
|
id: z.string(),
|
|
449
494
|
resourceId: z.string(),
|
|
@@ -466,6 +511,9 @@ export const BookingLink = z.object({
|
|
|
466
511
|
hours: WeeklyHours.nullable().default(null),
|
|
467
512
|
// confirmed at once (and on the calendar) vs. held as pending for approval
|
|
468
513
|
autoConfirm: z.boolean().default(true),
|
|
514
|
+
// supersedes autoConfirm: 'default' follows Calendar settings → Bookings
|
|
515
|
+
approval: z.union([ApprovalMode, z.literal('default')]).default('default'),
|
|
516
|
+
approvalRules: ApprovalRules.default({}),
|
|
469
517
|
visibility: z.enum(['public', 'unlisted']).default('public'),
|
|
470
518
|
askPhone: z.boolean().default(false),
|
|
471
519
|
questions: z.array(BookingQuestion).default([]),
|
|
@@ -584,6 +632,7 @@ export function normalizeRedirectPath(input: string): string {
|
|
|
584
632
|
|
|
585
633
|
export const Settings = z.object({
|
|
586
634
|
live: LiveConfig.default({}),
|
|
635
|
+
calendar: CalendarSettings.default({}),
|
|
587
636
|
// push notification automation (lib/push); the feature itself is acms({ mobile: { push } })
|
|
588
637
|
push: PushSettings.default({}),
|
|
589
638
|
siteTitle: z.string().default('My Site'),
|
package/src/lib/sections.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import sanitizeHtml from 'sanitize-html';
|
|
2
2
|
import type { z } from 'zod';
|
|
3
3
|
import type { ContentRepository } from './repo/types';
|
|
4
|
-
import type
|
|
4
|
+
import { authorSlug, type Settings, type User } from './schema';
|
|
5
|
+
import { htmlToPlaintext } from './plaintext';
|
|
5
6
|
import type { SectionInstance as SectionInstanceSchema, SectionDefinition } from '../theme-sdk/contract.mjs';
|
|
6
7
|
import { normalizeData, safeUrl, SOURCE_LIMIT_MAX, isEmptyValue, safeFocus, focusKey } from '../theme-sdk/fields.mjs';
|
|
7
8
|
import type { Theme } from './theme';
|
|
9
|
+
import { themeAssetUrl } from './theme-assets';
|
|
8
10
|
import { CONTENT, collectionUrl } from './content';
|
|
9
11
|
import { formatDate, siteLocale } from './dates';
|
|
10
12
|
import { upcomingEvents } from './calendar/cards';
|
|
@@ -33,7 +35,9 @@ export interface EventItem {
|
|
|
33
35
|
/** "11:00 AM – 1:00 PM EDT" */ time: string; month: string; day: string; weekday: string;
|
|
34
36
|
cta: { label: string; href: string } | null;
|
|
35
37
|
}
|
|
36
|
-
export interface
|
|
38
|
+
export interface PostAuthor { name: string; url: string; avatar: string }
|
|
39
|
+
// excerpt: the post's own, else the first ~160 characters of its text
|
|
40
|
+
export interface PostItem { title: string; url: string; excerpt: string; image: string; imageAlt: string; date: string; publishedAt: number | null; tags: string[]; authors: PostAuthor[] }
|
|
37
41
|
export interface BookingItem { title: string; url: string; description: string; resource: string; minutes: number }
|
|
38
42
|
|
|
39
43
|
/** apply a theme migration when the stored instance is older than the definition */
|
|
@@ -103,10 +107,14 @@ function resolveLink(value: unknown, refs: RefMap, lp: Lp = same): LinkValue {
|
|
|
103
107
|
return { label: String(v.label ?? ''), href, external: /^https?:\/\//i.test(href) };
|
|
104
108
|
}
|
|
105
109
|
|
|
106
|
-
|
|
110
|
+
type Asset = (path: string) => string;
|
|
111
|
+
const noAsset: Asset = () => '';
|
|
112
|
+
function resolveScalar(field: FieldDef, value: unknown, refs: RefMap, lp: Lp = same, asset: Asset = noAsset): unknown {
|
|
107
113
|
switch (field.type) {
|
|
108
114
|
case 'richtext': return sanitizeRichText(String(value ?? ''));
|
|
109
|
-
|
|
115
|
+
// images may name a file the theme ships: "assets/hero.jpg" → its built URL
|
|
116
|
+
case 'image': return typeof value === 'string' && /^(\.\/)?assets\//.test(value) ? asset(value) : safeUrl(value);
|
|
117
|
+
case 'url': return safeUrl(value);
|
|
110
118
|
case 'link': return resolveLink(value, refs, lp);
|
|
111
119
|
default: return value;
|
|
112
120
|
}
|
|
@@ -145,9 +153,15 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
|
|
|
145
153
|
const collection = cfg.collection && CONTENT.collections[cfg.collection] ? cfg.collection : 'posts';
|
|
146
154
|
let posts = await repo.listPublishedPosts(collection);
|
|
147
155
|
if (cfg.tag) posts = posts.filter((p) => p.tags.some((t) => t.toLowerCase() === cfg.tag!.toLowerCase()));
|
|
148
|
-
|
|
149
|
-
|
|
156
|
+
const shown = posts.slice(0, limit);
|
|
157
|
+
// public byline only: name, profile URL, avatar — never emails or roles
|
|
158
|
+
const users = shown.some((p) => p.authorIds.length || p.authorId) ? new Map((await repo.listUsers()).map((u) => [u.id, u])) : new Map();
|
|
159
|
+
return shown.map((raw): PostItem => { const p = lang ? localizeDoc(raw, lang) : raw; return {
|
|
160
|
+
title: p.title, url: lp(collectionUrl(p.collection, p.slug)), excerpt: p.excerpt || autoExcerpt(p.bodyHtml), image: safeUrl(p.cover), imageAlt: p.coverAlt,
|
|
150
161
|
date: formatDate(p.publishedAt, settings, 'short'), publishedAt: p.publishedAt ?? null, tags: p.tags,
|
|
162
|
+
authors: (raw.authorIds.length ? raw.authorIds : raw.authorId ? [raw.authorId] : [])
|
|
163
|
+
.map((id) => users.get(id)).filter((u): u is User => Boolean(u))
|
|
164
|
+
.map((u) => ({ name: u.name, url: lp(`/author/${authorSlug(u)}`), avatar: safeUrl(u.avatar) })),
|
|
151
165
|
}; });
|
|
152
166
|
}
|
|
153
167
|
if (!bookingsEnabled()) return [];
|
|
@@ -160,6 +174,15 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
|
|
|
160
174
|
});
|
|
161
175
|
}
|
|
162
176
|
|
|
177
|
+
// first ~160 characters of the body, cut at a word, for posts without an excerpt
|
|
178
|
+
export function autoExcerpt(html: string, max = 160): string {
|
|
179
|
+
const text = htmlToPlaintext(html);
|
|
180
|
+
if (text.length <= max) return text;
|
|
181
|
+
const cut = text.slice(0, max + 1);
|
|
182
|
+
const at = cut.lastIndexOf(' ');
|
|
183
|
+
return `${(at > max * 0.6 ? cut.slice(0, at) : text.slice(0, max)).replace(/[\s,.;:!?—–-]+$/, '')}…`;
|
|
184
|
+
}
|
|
185
|
+
|
|
163
186
|
export interface ResolveContext { repo: ContentRepository; settings: Settings; now: number }
|
|
164
187
|
|
|
165
188
|
/** stored data → component props: defaults filled, refs/sources resolved, richtext sanitized */
|
|
@@ -170,6 +193,8 @@ export async function resolveSectionData(def: Definition, stored: Record<string,
|
|
|
170
193
|
const refMap = refs.size ? await resolveRefs(refs, ctx.repo) : new Map<string, string>();
|
|
171
194
|
const lang = isExtraLocale(ctx.settings.locale) ? ctx.settings.locale : '';
|
|
172
195
|
const lp: Lp = lang ? (p) => localePath(p, lang) : same;
|
|
196
|
+
// 'ink' = DEFAULT_THEME (importing it would load every theme template)
|
|
197
|
+
const asset: Asset = (p) => themeAssetUrl(ctx.settings.activeTheme || 'ink', p);
|
|
173
198
|
const out: Record<string, unknown> = {};
|
|
174
199
|
await Promise.all(Object.entries(def.fields).map(async ([key, field]) => {
|
|
175
200
|
const value = data[key];
|
|
@@ -178,13 +203,13 @@ export async function resolveSectionData(def: Definition, stored: Record<string,
|
|
|
178
203
|
out[key] = (value as Record<string, unknown>[]).map((row) => {
|
|
179
204
|
const item: Record<string, unknown> = {};
|
|
180
205
|
for (const [k, sub] of Object.entries(field.fields)) {
|
|
181
|
-
item[k] = resolveScalar(sub as FieldDef, row[k], refMap, lp);
|
|
206
|
+
item[k] = resolveScalar(sub as FieldDef, row[k], refMap, lp, asset);
|
|
182
207
|
if (sub.type === 'image') item[`${k}Focus`] = safeFocus(row[focusKey(k)]);
|
|
183
208
|
}
|
|
184
209
|
return item;
|
|
185
210
|
});
|
|
186
211
|
} else {
|
|
187
|
-
out[key] = resolveScalar(field, value, refMap, lp);
|
|
212
|
+
out[key] = resolveScalar(field, value, refMap, lp, asset);
|
|
188
213
|
// images also hand over their focal point, ready for object-position
|
|
189
214
|
if (field.type === 'image') out[`${key}Focus`] = safeFocus(stored[focusKey(key)]);
|
|
190
215
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The `site` prop given to a theme's templates/header.astro and
|
|
2
|
+
// templates/footer.astro. Everything is resolved for the current request:
|
|
3
|
+
// links are already localized, `current` marks the page you're on, languages
|
|
4
|
+
// carry their own names ("Español"), and social links carry inline SVG icons.
|
|
5
|
+
//
|
|
6
|
+
// Core behaviors keep working inside a custom header through data hooks:
|
|
7
|
+
// [data-search-open] opens the search dialog (when site.search is set)
|
|
8
|
+
// [data-scheme-toggle] flips light/dark (when site.schemeToggle is true)
|
|
9
|
+
// [data-menu-open] opens the phone menu sheet (built from site.links)
|
|
10
|
+
// [data-live-header] (hidden) becomes the "Live" link during a stream
|
|
11
|
+
export interface ChromeLink {
|
|
12
|
+
label: string;
|
|
13
|
+
url: string;
|
|
14
|
+
current: boolean;
|
|
15
|
+
external: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SiteChrome {
|
|
19
|
+
title: string;
|
|
20
|
+
tagline: string;
|
|
21
|
+
logo: string; // '' when the site has no logo
|
|
22
|
+
homeUrl: string;
|
|
23
|
+
links: ChromeLink[]; // header navigation
|
|
24
|
+
footerLinks: ChromeLink[];
|
|
25
|
+
social: (ChromeLink & { icon: string })[]; // icon: inline SVG ('' = text link)
|
|
26
|
+
readLinks: ChromeLink[]; // collections, search, RSS
|
|
27
|
+
footerHtml: string; // Settings → footer text, rendered
|
|
28
|
+
languages: { code: string; label: string; url: string; current: boolean }[]; // [] when single-language
|
|
29
|
+
search: { url: string; label: string } | null;
|
|
30
|
+
schemeToggle: boolean;
|
|
31
|
+
liveHeader: boolean;
|
|
32
|
+
headerStyle: string; // theme token `header`
|
|
33
|
+
footerStyle: string; // theme token `footer`
|
|
34
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Files a theme ships under assets/, as built URLs. Kept apart from theme.ts
|
|
2
|
+
// (which loads every .astro template) so section data resolution can use it
|
|
3
|
+
// without pulling the templates in.
|
|
4
|
+
import * as user from 'virtual:acms-user-themes';
|
|
5
|
+
|
|
6
|
+
const builtin = import.meta.glob(['../themes/*/assets/**/*', '!**/.img-subir-originals/**'], { eager: true, query: '?url', import: 'default' }) as Record<string, string>;
|
|
7
|
+
|
|
8
|
+
// "assets/hero.jpg" (relative to the theme folder) → the built asset URL, so
|
|
9
|
+
// section image fields and defaults can point at files the theme ships.
|
|
10
|
+
// '' when the theme has no such file. Project themes win over built-ins.
|
|
11
|
+
export function themeAssetUrl(slug: string, path: string): string {
|
|
12
|
+
const file = path.replace(/^\.\//, '');
|
|
13
|
+
if (!/^assets\/[^?#]+$/.test(file) || file.includes('..')) return '';
|
|
14
|
+
const all = { ...builtin, ...((user as { assets?: Record<string, string> }).assets ?? {}) };
|
|
15
|
+
return Object.entries(all).find(([p]) => p.endsWith(`/themes/${slug}/${file}`))?.[1] ?? '';
|
|
16
|
+
}
|
package/src/lib/theme.ts
CHANGED
|
@@ -83,6 +83,10 @@ export interface Theme {
|
|
|
83
83
|
pageTemplates: Record<string, unknown>;
|
|
84
84
|
// templates/event.astro replaces the built-in event page layout
|
|
85
85
|
EventTemplate: unknown | null;
|
|
86
|
+
// templates/header.astro / footer.astro replace the site header and footer;
|
|
87
|
+
// they receive a `site` prop (lib/site-chrome.ts) with links already resolved
|
|
88
|
+
HeaderTemplate: unknown | null;
|
|
89
|
+
FooterTemplate: unknown | null;
|
|
86
90
|
}
|
|
87
91
|
|
|
88
92
|
interface ThemeJson { sections?: Record<string, unknown>; version?: string; name?: string; description?: string; tokens?: Record<string, unknown>; post?: Record<string, unknown> }
|
|
@@ -100,6 +104,8 @@ const homes = import.meta.glob('../themes/*/templates/home.astro', { eager: true
|
|
|
100
104
|
const postTpls = import.meta.glob('../themes/*/templates/post-*.astro', { eager: true }) as Record<string, { default: unknown }>;
|
|
101
105
|
const pageTpls = import.meta.glob('../themes/*/templates/page-*.astro', { eager: true }) as Record<string, { default: unknown }>;
|
|
102
106
|
const eventTpls = import.meta.glob('../themes/*/templates/event.astro', { eager: true }) as Record<string, { default: unknown }>;
|
|
107
|
+
const headerTpls = import.meta.glob('../themes/*/templates/header.astro', { eager: true }) as Record<string, { default: unknown }>;
|
|
108
|
+
const footerTpls = import.meta.glob('../themes/*/templates/footer.astro', { eager: true }) as Record<string, { default: unknown }>;
|
|
103
109
|
const migrationMods = {
|
|
104
110
|
...(import.meta.glob('../themes/*/migrations/*.mjs', { eager: true }) as Record<string, { default?: unknown }>),
|
|
105
111
|
...((user as { migrations?: Record<string, { default?: unknown }> }).migrations ?? {}),
|
|
@@ -126,7 +132,9 @@ function buildThemes(
|
|
|
126
132
|
homeMap: Record<string, { default: unknown }>,
|
|
127
133
|
postMap: Record<string, { default: unknown }>,
|
|
128
134
|
pageMap: Record<string, { default: unknown }>,
|
|
129
|
-
eventMap: Record<string, { default: unknown }> = {}
|
|
135
|
+
eventMap: Record<string, { default: unknown }> = {},
|
|
136
|
+
headerMap: Record<string, { default: unknown }> = {},
|
|
137
|
+
footerMap: Record<string, { default: unknown }> = {}
|
|
130
138
|
): Theme[] {
|
|
131
139
|
return Object.entries(jsonMap).map(([path, raw]) => {
|
|
132
140
|
const slug = slugOf(path);
|
|
@@ -157,18 +165,22 @@ function buildThemes(
|
|
|
157
165
|
postTemplates: templatesFor(slug, postMap, 'post'),
|
|
158
166
|
pageTemplates: templatesFor(slug, pageMap, 'page'),
|
|
159
167
|
EventTemplate: Object.entries(eventMap).find(([p]) => slugOf(p) === slug)?.[1]?.default ?? null,
|
|
168
|
+
HeaderTemplate: Object.entries(headerMap).find(([p]) => slugOf(p) === slug)?.[1]?.default ?? null,
|
|
169
|
+
FooterTemplate: Object.entries(footerMap).find(([p]) => slugOf(p) === slug)?.[1]?.default ?? null,
|
|
160
170
|
};
|
|
161
171
|
});
|
|
162
172
|
}
|
|
163
173
|
|
|
164
|
-
const builtins = buildThemes(jsons, csses, homes, postTpls, pageTpls, eventTpls);
|
|
174
|
+
const builtins = buildThemes(jsons, csses, homes, postTpls, pageTpls, eventTpls, headerTpls, footerTpls);
|
|
165
175
|
const projectThemes = buildThemes(
|
|
166
176
|
user.jsons as Record<string, ThemeJson>,
|
|
167
177
|
user.csses,
|
|
168
178
|
user.homes,
|
|
169
179
|
user.postTpls,
|
|
170
180
|
user.pageTpls,
|
|
171
|
-
(user as { eventTpls?: Record<string, { default: unknown }> }).eventTpls ?? {}
|
|
181
|
+
(user as { eventTpls?: Record<string, { default: unknown }> }).eventTpls ?? {},
|
|
182
|
+
(user as { headerTpls?: Record<string, { default: unknown }> }).headerTpls ?? {},
|
|
183
|
+
(user as { footerTpls?: Record<string, { default: unknown }> }).footerTpls ?? {}
|
|
172
184
|
);
|
|
173
185
|
const projectSlugs = new Set(projectThemes.map((t) => t.slug));
|
|
174
186
|
|
|
@@ -176,6 +188,8 @@ const themes: Theme[] = [...builtins.filter((t) => !projectSlugs.has(t.slug)), .
|
|
|
176
188
|
(a, b) => (a.slug === DEFAULT_THEME ? -1 : b.slug === DEFAULT_THEME ? 1 : a.name.localeCompare(b.name))
|
|
177
189
|
);
|
|
178
190
|
|
|
191
|
+
export { themeAssetUrl } from './theme-assets';
|
|
192
|
+
|
|
179
193
|
export function listThemes(): Theme[] {
|
|
180
194
|
return themes;
|
|
181
195
|
}
|
package/src/middleware.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { getRepo } from './lib/repo';
|
|
|
5
5
|
import { CONTENT } from './lib/content';
|
|
6
6
|
import { processDueDeliveries, waitUntilFrom } from './lib/webhooks';
|
|
7
7
|
import { dispatchEmail } from './lib/email/service';
|
|
8
|
-
import { sendDueReminders } from './lib/booking/service';
|
|
8
|
+
import { decideOverdue, sendDueReminders } from './lib/booking/service';
|
|
9
9
|
import { PUSH } from './lib/push/config';
|
|
10
10
|
import { processPushQueue, runPushJobs } from './lib/push/service';
|
|
11
11
|
import { previewParamRedirect, hasPreviewCookie } from './lib/designPreview';
|
|
@@ -110,7 +110,11 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
110
110
|
if (wait) wait(retries); else await retries;
|
|
111
111
|
// booking reminders (Node has no cron): site URL, else this request's origin
|
|
112
112
|
const reminders = repo.getSettings()
|
|
113
|
-
.then((s) =>
|
|
113
|
+
.then(async (s) => {
|
|
114
|
+
const flow = { origin: s.siteUrl.replace(/\/+$/, '') || context.url.origin, locals: context.locals, now };
|
|
115
|
+
await sendDueReminders(repo, flow);
|
|
116
|
+
await decideOverdue(repo, flow);
|
|
117
|
+
})
|
|
114
118
|
.catch(() => {});
|
|
115
119
|
if (wait) wait(reminders); else await reminders;
|
|
116
120
|
// push jobs (Node has no cron): never awaited here, errors are logged
|