domma-cms 1.6.59 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.md +4344 -0
  2. package/README.md +1 -1
  3. package/admin/dist/domma/domma-tools.css +3 -3
  4. package/admin/dist/domma/domma-tools.min.js +3 -3
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +2 -2
  7. package/admin/js/lib/draft-owner.js +1 -0
  8. package/admin/js/lib/effect-defs.js +1 -1
  9. package/admin/js/lib/effects-builder.js +2 -2
  10. package/admin/js/lib/page-editor-reference.js +5 -5
  11. package/admin/js/lib/shortcode-tags.js +1 -1
  12. package/admin/js/templates/docs/usage-shortcodes.html +39 -0
  13. package/admin/js/views/user-editor.js +1 -1
  14. package/docs/DommaFeatures.md +3005 -0
  15. package/docs/MultiTenant.md +176 -0
  16. package/docs/api-reference.md +1667 -0
  17. package/docs/cache.md +96 -0
  18. package/docs/configuration.md +1811 -0
  19. package/docs/editions-and-licences.md +190 -0
  20. package/docs/getting-started.md +201 -0
  21. package/docs/guides/form-triggers.md +260 -0
  22. package/docs/guides/mongodb-storage-adapter.md +282 -0
  23. package/docs/guides/multi-page-forms.md +121 -0
  24. package/docs/images/scribe-composer.png +0 -0
  25. package/docs/markdown-shortcodes.md +1738 -0
  26. package/docs/pentest-report-2026-04-03.md +181 -0
  27. package/docs/plans/2026-02-28-admin-topbar-login-forms.md +349 -0
  28. package/docs/plans/2026-02-28-domma-ui-polish.md +286 -0
  29. package/docs/plans/2026-02-28-reset-seed-fresh-commands.md +520 -0
  30. package/docs/plans/2026-03-02-cookie-consent-design.md +86 -0
  31. package/docs/plans/2026-03-02-cookie-consent-plugin.md +453 -0
  32. package/docs/plans/2026-03-02-tooltip-sweep-and-back-to-top-plugin-design.md +126 -0
  33. package/docs/plans/2026-03-02-tooltip-sweep-back-to-top.md +427 -0
  34. package/docs/plans/2026-03-05-cookie-consent-btt-core.md +115 -0
  35. package/docs/plans/2026-03-05-custom-css-migrate-to-core-design.md +35 -0
  36. package/docs/plans/2026-03-05-custom-css-migrate-to-core.md +352 -0
  37. package/docs/plans/2026-03-05-scribe-script-shortcode.md +373 -0
  38. package/docs/plans/2026-03-05-user-types-design.md +139 -0
  39. package/docs/plans/2026-03-05-user-types-implementation.md +1035 -0
  40. package/docs/plans/2026-03-09-page-editor-view-cta-inserts-design.md +78 -0
  41. package/docs/plans/2026-03-09-page-editor-view-cta-inserts.md +388 -0
  42. package/docs/plans/2026-03-09-pro-documentation-design.md +80 -0
  43. package/docs/plans/2026-03-09-pro-documentation.md +1017 -0
  44. package/docs/plans/2026-06-10-core-project-design.md +240 -0
  45. package/docs/plugin-development.md +827 -0
  46. package/docs/recruitment-recipe.md +501 -0
  47. package/docs/scaffolding.md +312 -0
  48. package/docs/screenshots/calculator-floating.png +0 -0
  49. package/docs/screenshots/calculator-standard.png +0 -0
  50. package/docs/screenshots/email-settings.png +0 -0
  51. package/docs/screenshots/messages-count.png +0 -0
  52. package/docs/structure/user-types.md +107 -0
  53. package/docs/superpowers/plans/2026-05-25-admin-sidebar-via-menus.md +1112 -0
  54. package/docs/superpowers/plans/2026-06-08-navbar-mobile-drawer-and-menu-enrichment.md +1161 -0
  55. package/docs/superpowers/plans/2026-06-25-domma-docs-project.md +695 -0
  56. package/docs/superpowers/plans/2026-06-26-surveys-HANDOFF.md +68 -0
  57. package/docs/superpowers/plans/2026-06-26-surveys-pro-plugin.md +1902 -0
  58. package/docs/superpowers/plans/2026-08-19-collection-context-sort-export.md +1577 -0
  59. package/docs/superpowers/plans/2026-10-09-content-api.md +1192 -0
  60. package/docs/superpowers/specs/2026-03-12-form-followup-actions-design.md +152 -0
  61. package/docs/superpowers/specs/2026-03-12-page-editor-form-insert-and-per-page-theme-design.md +115 -0
  62. package/docs/superpowers/specs/2026-03-17-nav-hidden-items-design.md +85 -0
  63. package/docs/superpowers/specs/2026-03-19-accordion-insert-design.md +175 -0
  64. package/docs/superpowers/specs/2026-03-25-static-block-shortcode-design.md +153 -0
  65. package/docs/superpowers/specs/2026-03-25-text-shortcode-design.md +168 -0
  66. package/docs/superpowers/specs/2026-04-05-card-builder-design.md +315 -0
  67. package/docs/superpowers/specs/2026-04-06-insert-builders-design.md +278 -0
  68. package/docs/superpowers/specs/2026-04-06-layouts-design.md +226 -0
  69. package/docs/superpowers/specs/2026-04-07-blog-plugin-design.md +300 -0
  70. package/docs/superpowers/specs/2026-04-07-calendar-plugin-design.md +538 -0
  71. package/docs/superpowers/specs/2026-04-17-components-area-design.md +368 -0
  72. package/docs/superpowers/specs/2026-05-02-effects-slideover-overhaul-design.md +193 -0
  73. package/docs/superpowers/specs/2026-05-06-dashboard-rebuild-design.md +257 -0
  74. package/docs/superpowers/specs/2026-05-24-menus-design.md +466 -0
  75. package/docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md +358 -0
  76. package/docs/superpowers/specs/2026-05-25-projects-design.md +380 -0
  77. package/docs/superpowers/specs/2026-06-08-navbar-mobile-drawer-and-menu-enrichment-design.md +292 -0
  78. package/docs/superpowers/specs/2026-06-08-sidebar-tools-plugins-folders-design.md +59 -0
  79. package/docs/superpowers/specs/2026-06-25-domma-docs-project-design.md +192 -0
  80. package/docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md +44 -0
  81. package/docs/superpowers/specs/2026-06-26-manager-scheduler-upstream-proposal.md +46 -0
  82. package/docs/superpowers/specs/2026-06-26-surveys-pro-plugin-design.md +206 -0
  83. package/docs/superpowers/specs/2026-07-02-shortcode-context-menu-design.md +96 -0
  84. package/docs/superpowers/specs/2026-08-19-collection-context-sort-export-design.md +330 -0
  85. package/docs/superpowers/specs/2026-09-22-dcms-marketplace-design.md +1150 -0
  86. package/docs/superpowers/specs/2026-10-09-content-api-design.md +325 -0
  87. package/docs/superpowers/specs/plans/2026-03-12-form-followup-actions.md +624 -0
  88. package/docs/superpowers/specs/plans/2026-03-12-page-editor-form-insert-and-per-page-theme.md +125 -0
  89. package/docs/superpowers/specs/plans/2026-03-17-nav-hidden-items.md +578 -0
  90. package/docs/superpowers/specs/plans/2026-03-19-accordion-insert.md +635 -0
  91. package/docs/superpowers/specs/plans/2026-03-25-text-shortcode.md +545 -0
  92. package/docs/superpowers/specs/plans/2026-04-05-card-builder.md +1970 -0
  93. package/docs/superpowers/specs/plans/2026-04-06-insert-builders.md +1049 -0
  94. package/docs/superpowers/specs/plans/2026-04-06-layouts.md +1015 -0
  95. package/docs/superpowers/specs/plans/2026-04-07-calendar-plugin.md +1526 -0
  96. package/docs/superpowers/specs/plans/2026-04-08-plugin-monetisation-split.md +242 -0
  97. package/docs/superpowers/specs/plans/2026-04-17-components-area.md +2038 -0
  98. package/docs/superpowers/specs/plans/2026-05-02-effects-slideover-overhaul.md +2335 -0
  99. package/docs/superpowers/specs/plans/2026-05-06-dashboard-rebuild.md +1999 -0
  100. package/docs/superpowers/specs/plans/2026-05-17-cache-system.md +1436 -0
  101. package/docs/superpowers/specs/plans/2026-05-24-menus.md +3096 -0
  102. package/docs/superpowers/specs/plans/2026-05-25-projects.md +2869 -0
  103. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step2-consolidation.md +505 -0
  104. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step3-catalogue-and-key.md +154 -0
  105. package/docs/superpowers/specs/plans/2026-09-22-tools-promotion.md +260 -0
  106. package/docs/theming.md +320 -0
  107. package/package.json +2 -2
  108. package/plugins/_lib/admin/mail/keyboard.js +49 -0
  109. package/plugins/_lib/admin/mail/reader-view.js +4 -2
  110. package/public/js/celebrations/core/canvas.js +1 -1
  111. package/public/js/celebrations/index.js +1 -1
  112. package/public/js/celebrations/licenses/ABeeZee-Regular-OFL.txt +93 -0
  113. package/public/js/celebrations/licenses/Allura-Regular-OFL.txt +93 -0
  114. package/public/js/celebrations/message-attrs.mjs +1 -0
  115. package/public/js/celebrations/themes/anniversary.js +1 -0
  116. package/public/js/celebrations/themes/autumn.js +1 -0
  117. package/public/js/celebrations/themes/birthday.js +1 -0
  118. package/public/js/celebrations/themes/butterfly-flight.js +1 -0
  119. package/public/js/celebrations/themes/calendar.js +1 -0
  120. package/public/js/celebrations/themes/chinese-new-year-dates.js +1 -0
  121. package/public/js/celebrations/themes/chinese-new-year.js +1 -0
  122. package/public/js/celebrations/themes/christmas.js +1 -1
  123. package/public/js/celebrations/themes/easter.js +1 -0
  124. package/public/js/celebrations/themes/garden-drawing.js +1 -0
  125. package/public/js/celebrations/themes/garden.js +1 -0
  126. package/public/js/celebrations/themes/graduation.js +1 -0
  127. package/public/js/celebrations/themes/greeting.js +1 -0
  128. package/public/js/celebrations/themes/make-it-rain.js +1 -0
  129. package/public/js/celebrations/themes/money-rain.js +1 -0
  130. package/public/js/celebrations/themes/new-year.js +1 -0
  131. package/public/js/celebrations/themes/occasion-drawing.js +1 -0
  132. package/public/js/celebrations/themes/occasions.js +1 -0
  133. package/public/js/celebrations/themes/rain-control.js +1 -0
  134. package/public/js/celebrations/themes/sparkler-font-data.js +1 -0
  135. package/public/js/celebrations/themes/sparkler-font.js +1 -0
  136. package/public/js/celebrations/themes/sparkler-writing.js +1 -0
  137. package/public/js/celebrations/themes/summer-garden.js +1 -0
  138. package/public/js/celebrations/themes/winter.js +1 -0
  139. package/public/js/celebrations/trait-data.mjs +1 -1
  140. package/public/js/effects.js +1 -1
  141. package/scripts/build.js +4 -0
  142. package/server/services/markdown.js +5 -2
  143. package/server/templates/page.html +1 -1
@@ -0,0 +1,1526 @@
1
+ # Calendar Plugin Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Build a standalone Calendar plugin with public event pages, iCal feed, RSVP registration, recurring events (materialised occurrences), three admin view modes, two plugin roles, and a `service.js` layer for Jobs integration.
6
+
7
+ **Architecture:** `plugin.js` registers API routes + the `[calendar /]` shortcode at startup; `plugin.public.js` registers SSR public routes at the Fastify root; `service.js` is a plain ES module exporting CRUD helpers that Jobs imports directly. Recurrence is pre-materialised as individual `calendar-occurrences` entries (rolling 12-month window). Seeded assets (form, page, block, views) are created in the `onEnable` lifecycle and removed in `onDisable`.
8
+
9
+ **Tech Stack:** Fastify 5 (ESM), Domma SPA (admin), `server/services/collections.js` (CRUD), `server/services/renderer.js` (`renderBlogPage`), `server/services/hooks.js` (`registerShortcode`).
10
+
11
+ ---
12
+
13
+ ## Critical CMS conventions - read before implementing any task
14
+
15
+ - **HTTP in admin views**: Always use `H.get/post/put/delete` - never `fetch()`.
16
+ - **DOM selection**: `$container.find('#id')` - never `document.querySelector()`.
17
+ - **Native DOM element**: `$container.find('#id').get(0)` - never `$container.find('#id')[0]` (returns `undefined`).
18
+ - **Storage**: `S.get/set` - never `localStorage`.
19
+ - **Icons**: `<span data-icon="name">` + call `I.scan()` after DOM updates.
20
+ - **Tables**: `T.create(el, { data, columns })` - pass native DOM element via `.get(0)`.
21
+ - **Toasts**: `E.toast('msg', { type: 'success' })`.
22
+ - **Confirm**: `await E.confirm('message?')`.
23
+ - **Templates**: `templateUrl: '/plugins/calendar/admin/templates/foo.html'` (absolute path).
24
+ - **DOM updates with HTML strings**: Use `$(el).html(htmlString)` - Domma wraps DOMPurify. Avoid setting `.innerHTML` directly on raw DOM elements.
25
+ - **Interactive elements inside HTML strings**: Build them with `document.createElement` + `appendChild` - Domma's `.html()` strips buttons and inputs.
26
+ - **Route registration order in `plugin.public.js`**: Static segments before `/:slug`. Register `/feed.ics` first.
27
+ - **`listEntries` returns `{ entries, total }`** - destructure: `const { entries } = await listEntries(SLUG, { limit: 100000 })`.
28
+ - **Entry shape**: `{ id, data: { ...fields }, meta: { createdAt, updatedAt } }`. Flatten with helper: `const toEvent = e => ({ id: e.id, ...e.data, createdAt: e.meta.createdAt })`.
29
+ - **`escapeHtml`** must be defined in every file that renders untrusted data into HTML strings.
30
+ - **Contacts slug**: Check `plugins/contacts/plugin.js` for the current `CONTACTS_SLUG` constant before using it in RSVP upsert code. It should be `contacts-contacts` after the namespace rename.
31
+ - **Server is always running on port 4096** - never kill or restart it. Use `curl` to verify endpoints.
32
+
33
+ ---
34
+
35
+ ## File Structure
36
+
37
+ ### New files
38
+ ```
39
+ plugins/calendar/
40
+ plugin.json
41
+ plugin.js API routes + shortcode registration + onEnable/onDisable
42
+ plugin.public.js Public SSR routes (listing, feed.ics, event page, RSVP POST)
43
+ service.js Exported createEvent/updateEvent/cancelEvent/createOccurrence/etc.
44
+ config.js
45
+ CLAUDE.md
46
+ collections/
47
+ calendar-events/schema.json
48
+ calendar-occurrences/schema.json
49
+ calendar-rsvps/schema.json
50
+ roles/
51
+ calendar-organiser.json
52
+ calendar-admin.json
53
+ forms/
54
+ calendar-rsvp.json
55
+ pages/
56
+ calendar.md
57
+ blocks/
58
+ upcoming-events.html
59
+ templates/
60
+ index.html Public events listing body template
61
+ event.html Public event detail body template (includes inline RSVP form)
62
+ admin/
63
+ views/
64
+ calendar.js Events list with Grid / List / Split modes
65
+ event-editor.js Create / edit event + recurrence + RSVP panels
66
+ rsvps.js RSVPs list
67
+ settings.js Plugin settings form
68
+ templates/
69
+ calendar.html
70
+ event-editor.html
71
+ rsvps.html
72
+ settings.html
73
+ ```
74
+
75
+ ### Modified files
76
+ ```
77
+ config/plugins.json Add calendar entry (enabled: false initially, true after smoke test)
78
+ ```
79
+
80
+ ---
81
+
82
+ ## Task 1: Scaffolding - schemas, roles, forms, pages, blocks, manifest, config
83
+
84
+ **Files:**
85
+ - Create: `plugins/calendar/collections/calendar-events/schema.json`
86
+ - Create: `plugins/calendar/collections/calendar-occurrences/schema.json`
87
+ - Create: `plugins/calendar/collections/calendar-rsvps/schema.json`
88
+ - Create: `plugins/calendar/roles/calendar-organiser.json`
89
+ - Create: `plugins/calendar/roles/calendar-admin.json`
90
+ - Create: `plugins/calendar/forms/calendar-rsvp.json`
91
+ - Create: `plugins/calendar/pages/calendar.md`
92
+ - Create: `plugins/calendar/blocks/upcoming-events.html`
93
+ - Create: `plugins/calendar/plugin.json`
94
+ - Create: `plugins/calendar/config.js`
95
+ - Create: `plugins/calendar/CLAUDE.md`
96
+
97
+ - [ ] **Step 1: Create directory structure**
98
+
99
+ ```bash
100
+ mkdir -p plugins/calendar/collections/calendar-events
101
+ mkdir -p plugins/calendar/collections/calendar-occurrences
102
+ mkdir -p plugins/calendar/collections/calendar-rsvps
103
+ mkdir -p plugins/calendar/roles
104
+ mkdir -p plugins/calendar/forms
105
+ mkdir -p plugins/calendar/pages
106
+ mkdir -p plugins/calendar/blocks
107
+ mkdir -p plugins/calendar/templates
108
+ mkdir -p plugins/calendar/admin/views
109
+ mkdir -p plugins/calendar/admin/templates
110
+ ```
111
+
112
+ - [ ] **Step 2: Write `plugins/calendar/collections/calendar-events/schema.json`**
113
+
114
+ ```json
115
+ {
116
+ "title": "Calendar Events",
117
+ "slug": "calendar-events",
118
+ "description": "Parent event records with recurrence rules.",
119
+ "plugin": "calendar",
120
+ "bundled": false,
121
+ "fields": [
122
+ { "key": "title", "label": "Title", "type": "text", "required": true },
123
+ { "key": "slug", "label": "Slug", "type": "text", "required": true },
124
+ { "key": "description", "label": "Description", "type": "markdown", "required": false },
125
+ { "key": "type", "label": "Type", "type": "select", "required": true,
126
+ "options": ["meeting","interview","public-event","webinar","other"] },
127
+ { "key": "visibility", "label": "Visibility", "type": "select", "required": true,
128
+ "options": ["public","private"] },
129
+ { "key": "location", "label": "Location", "type": "text", "required": false },
130
+ { "key": "organiserId", "label": "Organiser ID", "type": "text", "required": true },
131
+ { "key": "recurrence", "label": "Recurrence", "type": "object", "required": false },
132
+ { "key": "windowGeneratedTo", "label": "Window Generated To", "type": "datetime", "required": false },
133
+ { "key": "rsvpEnabled", "label": "RSVP Enabled", "type": "boolean", "required": false },
134
+ { "key": "rsvpLimit", "label": "RSVP Limit", "type": "number", "required": false },
135
+ { "key": "status", "label": "Status", "type": "select", "required": true,
136
+ "options": ["active","cancelled"], "default": "active" }
137
+ ],
138
+ "api": { "list": true, "get": true, "create": true, "update": true, "delete": true },
139
+ "storage": { "adapter": "file" }
140
+ }
141
+ ```
142
+
143
+ - [ ] **Step 3: Write `plugins/calendar/collections/calendar-occurrences/schema.json`**
144
+
145
+ ```json
146
+ {
147
+ "title": "Calendar Occurrences",
148
+ "slug": "calendar-occurrences",
149
+ "description": "Materialised event instances (one per occurrence, including recurring).",
150
+ "plugin": "calendar",
151
+ "bundled": false,
152
+ "fields": [
153
+ { "key": "eventId", "label": "Event ID", "type": "text", "required": false },
154
+ { "key": "title", "label": "Title", "type": "text", "required": true },
155
+ { "key": "slug", "label": "Slug", "type": "text", "required": false,
156
+ "notes": "Denormalised from parent event at creation time. Null for Jobs-created standalone occurrences." },
157
+ { "key": "startAt", "label": "Start", "type": "datetime", "required": true },
158
+ { "key": "endAt", "label": "End", "type": "datetime", "required": true },
159
+ { "key": "type", "label": "Type", "type": "select", "required": true,
160
+ "options": ["meeting","interview","public-event","webinar","other"] },
161
+ { "key": "visibility", "label": "Visibility", "type": "select", "required": true,
162
+ "options": ["public","private"] },
163
+ { "key": "status", "label": "Status", "type": "select", "required": true,
164
+ "options": ["active","cancelled","rescheduled"], "default": "active" },
165
+ { "key": "rsvpCount", "label": "RSVP Count", "type": "number", "required": false },
166
+ { "key": "rsvpLimit", "label": "RSVP Limit", "type": "number", "required": false },
167
+ { "key": "source", "label": "Source", "type": "text", "required": false }
168
+ ],
169
+ "api": { "list": true, "get": true, "create": true, "update": true, "delete": true },
170
+ "storage": { "adapter": "file" }
171
+ }
172
+ ```
173
+
174
+ - [ ] **Step 4: Write `plugins/calendar/collections/calendar-rsvps/schema.json`**
175
+
176
+ ```json
177
+ {
178
+ "title": "Calendar RSVPs",
179
+ "slug": "calendar-rsvps",
180
+ "description": "RSVP registrations for calendar occurrences.",
181
+ "plugin": "calendar",
182
+ "bundled": false,
183
+ "fields": [
184
+ { "key": "occurrenceId", "label": "Occurrence ID", "type": "text", "required": true },
185
+ { "key": "name", "label": "Name", "type": "text", "required": true },
186
+ { "key": "email", "label": "Email", "type": "text", "required": true },
187
+ { "key": "status", "label": "Status", "type": "select", "required": true,
188
+ "options": ["confirmed","cancelled"], "default": "confirmed" },
189
+ { "key": "contactId", "label": "Contact ID", "type": "text", "required": false },
190
+ { "key": "createdAt", "label": "Created At", "type": "datetime","required": true }
191
+ ],
192
+ "api": { "list": true, "get": true, "create": true, "update": true, "delete": true },
193
+ "storage": { "adapter": "file" }
194
+ }
195
+ ```
196
+
197
+ - [ ] **Step 5: Write role files**
198
+
199
+ `plugins/calendar/roles/calendar-organiser.json`:
200
+ ```json
201
+ {
202
+ "name": "calendar-organiser",
203
+ "label": "Calendar Organiser",
204
+ "level": 2,
205
+ "permissions": ["calendar-events","calendar-occurrences"],
206
+ "badgeClass": "badge-info",
207
+ "plugin": "calendar",
208
+ "resources": [
209
+ { "key": "calendar-events", "label": "Calendar Events", "actions": ["read","create","update","delete"] },
210
+ { "key": "calendar-occurrences", "label": "Calendar Occurrences", "actions": ["read","update","delete"] }
211
+ ]
212
+ }
213
+ ```
214
+
215
+ `plugins/calendar/roles/calendar-admin.json`:
216
+ ```json
217
+ {
218
+ "name": "calendar-admin",
219
+ "label": "Calendar Admin",
220
+ "level": 1,
221
+ "permissions": ["calendar-events","calendar-occurrences","calendar-rsvps","calendar-settings"],
222
+ "badgeClass": "badge-success",
223
+ "plugin": "calendar",
224
+ "resources": [
225
+ { "key": "calendar-events", "label": "Calendar Events", "actions": ["read","create","update","delete"] },
226
+ { "key": "calendar-occurrences", "label": "Calendar Occurrences", "actions": ["read","create","update","delete"] },
227
+ { "key": "calendar-rsvps", "label": "Calendar RSVPs", "actions": ["read","update","delete"] },
228
+ { "key": "calendar-settings", "label": "Calendar Settings", "actions": ["read","update"] }
229
+ ]
230
+ }
231
+ ```
232
+
233
+ - [ ] **Step 6: Write `plugins/calendar/forms/calendar-rsvp.json`**
234
+
235
+ ```json
236
+ {
237
+ "slug": "calendar-rsvp",
238
+ "title": "Event RSVP",
239
+ "description": "RSVP registration form for calendar events.",
240
+ "plugin": "calendar",
241
+ "submitAction": "/calendar/rsvp",
242
+ "fields": [
243
+ { "name": "name", "type": "string", "label": "Name", "required": true, "placeholder": "Your name", "helper": "" },
244
+ { "name": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@email.com", "helper": "" },
245
+ { "name": "website", "type": "string", "label": "Website", "required": false, "placeholder": "", "helper": "" }
246
+ ],
247
+ "settings": {
248
+ "submitText": "Register",
249
+ "successMessage": "You're registered!",
250
+ "layout": "stacked",
251
+ "honeypot": true,
252
+ "rateLimitPerMinute": 5
253
+ }
254
+ }
255
+ ```
256
+
257
+ Note: `submitAction` is set at seed time. If `basePath` changes in settings, disable then re-enable the plugin to reseed the form with the new path. The standalone shortcode embed (`[form slug="calendar-rsvp"]`) is **out of scope for v1** - the event detail page renders the RSVP form inline in `event.html`.
258
+
259
+ - [ ] **Step 7: Write `plugins/calendar/pages/calendar.md`**
260
+
261
+ ```markdown
262
+ ---
263
+ title: Events
264
+ layout: default
265
+ description: Upcoming events
266
+ visibility: public
267
+ plugin: calendar
268
+ ---
269
+ [calendar /]
270
+ ```
271
+
272
+ - [ ] **Step 8: Write `plugins/calendar/blocks/upcoming-events.html`**
273
+
274
+ ```
275
+ [calendar limit="3" /]
276
+ ```
277
+
278
+ - [ ] **Step 9: Write `plugins/calendar/plugin.json`**
279
+
280
+ ```json
281
+ {
282
+ "name": "calendar",
283
+ "displayName": "Calendar",
284
+ "version": "1.0.0",
285
+ "description": "Public events calendar with RSVP, iCal feed, and recurring events.",
286
+ "author": "Domma CMS",
287
+ "date": "2026-04-07",
288
+ "icon": "calendar",
289
+ "admin": {
290
+ "sidebar": [
291
+ {
292
+ "id": "calendar",
293
+ "text": "Calendar",
294
+ "icon": "calendar",
295
+ "url": "#/plugins/calendar",
296
+ "section": "#/plugins/calendar",
297
+ "items": [
298
+ { "id": "calendar-events", "text": "Events", "url": "#/plugins/calendar" },
299
+ { "id": "calendar-rsvps", "text": "RSVPs", "url": "#/plugins/calendar/rsvps" },
300
+ { "id": "calendar-settings", "text": "Settings", "url": "#/plugins/calendar/settings" }
301
+ ]
302
+ }
303
+ ],
304
+ "routes": [
305
+ { "path": "/plugins/calendar", "view": "plugin-calendar", "title": "Calendar - Domma CMS" },
306
+ { "path": "/plugins/calendar/events/new", "view": "plugin-calendar-editor", "title": "New Event - Domma CMS" },
307
+ { "path": "/plugins/calendar/events/:id", "view": "plugin-calendar-editor", "title": "Edit Event - Domma CMS" },
308
+ { "path": "/plugins/calendar/rsvps", "view": "plugin-calendar-rsvps", "title": "Calendar RSVPs - Domma CMS" },
309
+ { "path": "/plugins/calendar/settings", "view": "plugin-calendar-settings", "title": "Calendar Settings - Domma CMS" }
310
+ ],
311
+ "views": {
312
+ "plugin-calendar": { "entry": "calendar/admin/views/calendar.js?v=1", "exportName": "calendarView" },
313
+ "plugin-calendar-editor": { "entry": "calendar/admin/views/event-editor.js?v=1", "exportName": "eventEditorView" },
314
+ "plugin-calendar-rsvps": { "entry": "calendar/admin/views/rsvps.js?v=1", "exportName": "rsvpsView" },
315
+ "plugin-calendar-settings": { "entry": "calendar/admin/views/settings.js?v=1", "exportName": "settingsView" }
316
+ }
317
+ }
318
+ }
319
+ ```
320
+
321
+ - [ ] **Step 10: Write `plugins/calendar/config.js`**
322
+
323
+ ```js
324
+ export default {
325
+ basePath: '/calendar',
326
+ eventsPerPage: 12,
327
+ rsvpEnabled: true,
328
+ defaultView: 'list',
329
+ occurrenceWindow: 365,
330
+ feedLength: 50,
331
+ storage: { adapter: 'file' }
332
+ };
333
+ ```
334
+
335
+ - [ ] **Step 11: Write `plugins/calendar/CLAUDE.md`**
336
+
337
+ ```markdown
338
+ # Calendar Plugin - AI Assistant Guide
339
+
340
+ ## Purpose
341
+ Public-facing events calendar with RSVP, iCal feed, recurring events, and a `service.js`
342
+ layer for direct import by the Jobs plugin.
343
+
344
+ ## Files
345
+ - `plugin.js` - API routes + `[calendar /]` shortcode + `onEnable`/`onDisable` lifecycle
346
+ - `plugin.public.js` - SSR public routes (listing, feed.ics, event page, RSVP POST)
347
+ - `service.js` - `createEvent`, `updateEvent`, `cancelEvent`, `createOccurrence`, `getOccurrence`, `cancelOccurrence`, `generateOccurrences`
348
+ - `config.js` - defaults: `basePath`, `eventsPerPage`, `rsvpEnabled`, `defaultView`, `occurrenceWindow`, `feedLength`
349
+
350
+ ## Collections
351
+ | Slug | Purpose |
352
+ |---|---|
353
+ | `calendar-events` | Parent events with recurrence rules |
354
+ | `calendar-occurrences` | Materialised instances (one per occurrence) |
355
+ | `calendar-rsvps` | RSVP registrations |
356
+
357
+ ## API Routes (`/api/plugins/calendar`) - all require `authenticate`
358
+
359
+ | Method | Path | Notes |
360
+ |---|---|---|
361
+ | GET/POST | `/events` | List / create parent events |
362
+ | GET/PUT/DELETE | `/events/:id` | Get / update / cancel event |
363
+ | GET | `/occurrences` | List occurrences (`?from=&to=&type=&visibility=&eventId=`) |
364
+ | GET/PUT/DELETE | `/occurrences/:id` | Get / update / cancel single occurrence |
365
+ | GET/PUT/DELETE | `/rsvps` / `/rsvps/:id` | RSVPs |
366
+ | GET/PUT | `/settings` | Plugin settings |
367
+
368
+ ## Public Routes (under `basePath`, default `/calendar`)
369
+ | Method | Path | Notes |
370
+ |---|---|---|
371
+ | GET | `/feed.ics` | iCal feed - registered BEFORE event slug route |
372
+ | GET | `/event/:slug` | Event detail + inline RSVP form |
373
+ | GET | `/page/:n` | Paginated listing |
374
+ | GET | `/` | Events listing (page 1) |
375
+ | POST | `/rsvp` | RSVP submission - no auth, honeypot protected |
376
+
377
+ ## Roles
378
+ - `calendar-organiser` (level 2) - CRUD own events/occurrences
379
+ - `calendar-admin` (level 1) - full access including RSVPs and settings
380
+
381
+ ## Ownership gate pattern
382
+ ```js
383
+ const ADMIN_ROLES = ['calendar-admin', 'admin', 'super-admin'];
384
+ if (!ADMIN_ROLES.includes(request.user.role) && event.organiserId !== request.user.id) {
385
+ return reply.code(403).send({ error: 'Forbidden' });
386
+ }
387
+ ```
388
+ Do NOT use `getRoleLevel()` - organiser and base user are both level 2.
389
+
390
+ ## Shortcode handler signature
391
+ ```js
392
+ options.hooks.registerShortcode('calendar', async (attrStr, body, context) => {
393
+ const attrs = context.parseShortcodeAttrs(attrStr);
394
+ // body is null for [calendar /] (self-closing)
395
+ // return HTML string
396
+ });
397
+ ```
398
+
399
+ ## Gotchas
400
+ - **Route order**: In `plugin.public.js`, register `/feed.ics` before `/event/:slug` before base `/`.
401
+ - **Occurrence slug**: Denormalised from parent event. Null for Jobs-created occurrences.
402
+ - **Top-up trigger**: Only fires on `GET /occurrences?eventId=<id>` when `to` is within 30 days of `event.windowGeneratedTo`.
403
+ - **Soft cancel**: `DELETE /events/:id` sets `status: cancelled` - no hard deletes.
404
+ - **Jobs integration**: `import { createOccurrence } from '../calendar/service.js'` - one-way dependency.
405
+ - **Seeded assets**: form, page, block, views - seeded via `onEnable`, removed via `onDisable`.
406
+ - **DOM updates**: Use `$(el).html(str)` not raw `.innerHTML` - Domma sanitises via DOMPurify.
407
+ ```
408
+
409
+ - [ ] **Step 12: Commit scaffolding**
410
+
411
+ ```bash
412
+ git add plugins/calendar/
413
+ git commit -m "feat(calendar): add plugin scaffolding - schemas, roles, forms, pages, blocks, manifest"
414
+ ```
415
+
416
+ > **Note - `setupPlugin` auto-seeding:** When Calendar is enabled via the admin UI, `setupPlugin` automatically copies `plugins/calendar/forms/calendar-rsvp.json` → `content/forms/`, `plugins/calendar/pages/calendar.md` → `content/pages/`, `plugins/calendar/roles/*.json` → `content/collections/roles/data.json`, and creates all three collection directories. Creating the files in this task is all that is required - no additional implementation needed. The `onEnable` export (Task 3) handles only blocks and views, which `setupPlugin` does NOT seed.
417
+ >
418
+ > **Note - `teardownPlugin`:** `teardownPlugin` is called automatically by the plugin system on disable. The plugin implementer does NOT write a `teardownPlugin` function - it is handled by the CMS. It removes the seeded page, form, and roles. `onDisable` (Task 3) handles block and views removal - those are NOT touched by `teardownPlugin`.
419
+
420
+ ---
421
+
422
+ ## Task 2: `service.js` - core business logic
423
+
424
+ **Files:**
425
+ - Create: `plugins/calendar/service.js`
426
+
427
+ Service functions are used by both `plugin.js` (API routes) and Jobs (via direct import). No Fastify, no HTTP - pure collection CRUD.
428
+
429
+ ### Dependencies
430
+
431
+ ```js
432
+ import {
433
+ listEntries, createEntry, updateEntry, deleteEntry, getEntry
434
+ } from '../../server/services/collections.js';
435
+ import { getConfig } from '../../server/config.js';
436
+ ```
437
+
438
+ No Fastify, no `options` object. Config is read via `getConfig()` at call time.
439
+
440
+ **Disabled-state guard:** If Calendar is disabled its collections do not exist and `createEntry` will throw. Callers (e.g. Jobs) must check plugin state before calling:
441
+
442
+ ```js
443
+ const calendarEnabled = options.config?.plugins?.calendar?.enabled ?? false;
444
+ if (calendarEnabled) {
445
+ const { createOccurrence } = await import('../calendar/service.js');
446
+ }
447
+ ```
448
+
449
+ `service.js` itself does not guard - it trusts callers.
450
+
451
+ - [ ] **Step 1: Write `plugins/calendar/service.js`**
452
+
453
+ Write the full file with these exported constants and functions:
454
+
455
+ **Constants (export these so plugin.js and plugin.public.js can import them):**
456
+ ```js
457
+ export const EVENTS_SLUG = 'calendar-events';
458
+ export const OCCURRENCES_SLUG = 'calendar-occurrences';
459
+ export const RSVPS_SLUG = 'calendar-rsvps';
460
+ ```
461
+
462
+ **Flatten helpers:**
463
+ ```js
464
+ export const toEvent = e => ({ id: e.id, ...e.data, createdAt: e.meta.createdAt, updatedAt: e.meta.updatedAt });
465
+ export const toOccurrence = e => ({ id: e.id, ...e.data, createdAt: e.meta.createdAt, updatedAt: e.meta.updatedAt });
466
+ export const toRsvp = e => ({ id: e.id, ...e.data });
467
+ ```
468
+
469
+ **`slugify(str)`** - lowercases, replaces non-alphanumeric with `-`, strips leading/trailing `-`.
470
+
471
+ **`generateOccurrences(event, windowDays)`:**
472
+ - Reads `occurrenceWindow` from `getConfig()` as fallback if `windowDays` is undefined
473
+ - Uses `event.startAt` as the series origin; skips past dates relative to today
474
+ - Supports `freq`: `daily`, `weekly`, `monthly`, `yearly`
475
+ - Supports `interval` (default 1), `byday` array (e.g. `['MO','WE']`) for weekly, `until` date string, `count` limit
476
+ - For each generated date, calls `createEntry(OCCURRENCES_SLUG, { eventId, title, slug, startAt, endAt, type, visibility, status: 'active', rsvpCount: 0, rsvpLimit, source: 'calendar' })`
477
+ - After generating, calls `updateEntry(EVENTS_SLUG, event.id, { ...event, windowGeneratedTo: effectiveEnd.toISOString() })`
478
+ - Returns array of created occurrence IDs
479
+
480
+ **`createEvent(data)`:**
481
+ - Derives slug from `data.slug` or `slugify(data.title)`
482
+ - Calls `createEntry(EVENTS_SLUG, { title, slug, description, type, visibility, location, organiserId, recurrence, windowGeneratedTo: null, rsvpEnabled, rsvpLimit, status: 'active', startAt, endAt })`
483
+ - If recurring: calls `generateOccurrences(event, occurrenceWindow)`
484
+ - If one-off: calls `createEntry(OCCURRENCES_SLUG, { eventId, title, slug, startAt, endAt, type, visibility, status: 'active', rsvpCount: 0, rsvpLimit, source: 'calendar' })`
485
+ - Returns flattened event
486
+
487
+ **`updateEvent(id, data)`:**
488
+ - Deletes all occurrences where `eventId === id && startAt > now` (future occurrences only - past preserved)
489
+ - Merges `data` into `existing.data` and calls `updateEntry`
490
+ - If recurring: calls `generateOccurrences(updatedEvent, occurrenceWindow)`
491
+ - Returns updated event
492
+
493
+ **`cancelEvent(id)`:**
494
+ - Sets `status: 'cancelled'` on the parent event
495
+ - Fetches occurrences with `listEntries(OCCURRENCES_SLUG, { limit: 100000 })` - must use high limit to avoid pagination truncation
496
+ - Sets `status: 'cancelled'` on all occurrences where `eventId === id && startAt >= now`
497
+ - Returns cancelled event
498
+
499
+ **`createOccurrence(data)`:**
500
+ - Primary Jobs entry point - creates standalone occurrence with `eventId: data.eventId ?? null`, `slug: data.slug ?? null`, `source: data.source ?? 'calendar'`
501
+ - Returns flattened occurrence
502
+
503
+ **`getOccurrence(id)`:** - calls `getEntry(OCCURRENCES_SLUG, id)`, returns flattened or `null`
504
+
505
+ **`cancelOccurrence(id)`:** - sets `status: 'cancelled'` on a single occurrence, returns updated
506
+
507
+ - [ ] **Step 2: Verify importable**
508
+
509
+ ```bash
510
+ node --input-type=module <<'EOF'
511
+ import { createEvent, createOccurrence, generateOccurrences } from './plugins/calendar/service.js';
512
+ console.log(typeof createEvent, typeof createOccurrence, typeof generateOccurrences);
513
+ EOF
514
+ ```
515
+ Expected: `function function function`
516
+
517
+ - [ ] **Step 3: Commit**
518
+
519
+ ```bash
520
+ git add plugins/calendar/service.js
521
+ git commit -m "feat(calendar): add service.js - createEvent, generateOccurrences, createOccurrence and friends"
522
+ ```
523
+
524
+ ---
525
+
526
+ ## Task 3: `plugin.js` - API routes, shortcode, lifecycle
527
+
528
+ **Files:**
529
+ - Create: `plugins/calendar/plugin.js`
530
+
531
+ All routes under `/api/plugins/calendar`. Shortcode `[calendar /]` registered via `options.hooks.registerShortcode`. `onEnable` / `onDisable` exports seed/remove block and views.
532
+
533
+ - [ ] **Step 1: Write `plugins/calendar/plugin.js`**
534
+
535
+ Structure:
536
+
537
+ ```js
538
+ import fs from 'node:fs/promises';
539
+ import path from 'node:path';
540
+ import defaultConfig from './config.js';
541
+ import { listEntries, getEntry, createEntry, updateEntry, deleteEntry } from '../../server/services/collections.js';
542
+ import { getConfig, saveConfig } from '../../server/config.js';
543
+ import {
544
+ EVENTS_SLUG, OCCURRENCES_SLUG, RSVPS_SLUG,
545
+ toEvent, toOccurrence, toRsvp,
546
+ createEvent, updateEvent, cancelEvent,
547
+ createOccurrence, cancelOccurrence, generateOccurrences
548
+ } from './service.js';
549
+
550
+ const ADMIN_ROLES = ['calendar-admin', 'admin', 'super-admin'];
551
+ const isAdmin = user => ADMIN_ROLES.includes(user?.role);
552
+ const isOrg = user => [...ADMIN_ROLES, 'calendar-organiser'].includes(user?.role);
553
+ ```
554
+
555
+ **Shortcode registration** (inside the exported Fastify plugin function, after `const settings = ...`):
556
+
557
+ > **IMPORTANT - signature discrepancy:** The spec's shortcode example shows a two-argument signature `(attrs, context)` where `attrs` is pre-parsed. That is WRONG. The CMS hook system always calls handlers as `(attrStr, body, context)` where `attrStr` is the raw attribute string. Always use the three-argument form below and parse with `context.parseShortcodeAttrs(attrStr)`.
558
+
559
+ ```js
560
+ const escapeHtml = s => String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
561
+
562
+ options.hooks.registerShortcode('calendar', async (attrStr, body, context) => {
563
+ const attrs = context.parseShortcodeAttrs(attrStr);
564
+ const limit = parseInt(attrs.limit ?? settings.eventsPerPage, 10) || 12;
565
+ const typeFilter = attrs.type ?? null;
566
+ try {
567
+ const now = new Date().toISOString();
568
+ const { entries } = await listEntries(OCCURRENCES_SLUG, { limit: 100000, sort: 'startAt', order: 'asc' });
569
+ let upcoming = entries.map(toOccurrence)
570
+ .filter(o => o.visibility === 'public' && o.status === 'active' && o.startAt >= now);
571
+ if (typeFilter) upcoming = upcoming.filter(o => o.type === typeFilter);
572
+ upcoming = upcoming.slice(0, limit);
573
+ if (!upcoming.length) return '<p class="text-muted">No upcoming events.</p>';
574
+ const base = (settings.basePath ?? '/calendar').replace(/\/$/, '');
575
+ const items = upcoming.map(o => {
576
+ const date = new Date(o.startAt).toLocaleDateString('en-GB', { weekday:'short', day:'numeric', month:'short' });
577
+ const href = o.slug ? `${base}/event/${encodeURIComponent(o.slug)}` : null;
578
+ const title = escapeHtml(o.title);
579
+ const type = escapeHtml(o.type);
580
+ return `<div class="card mb-3"><div class="card-body"><div class="text-muted small">${date}</div>`
581
+ + `<h5 class="card-title">${href ? `<a href="${href}">${title}</a>` : title}</h5>`
582
+ + (type ? `<span class="badge badge-secondary">${type}</span>` : '')
583
+ + '</div></div>';
584
+ }).join('');
585
+ return `<div class="calendar-shortcode">${items}</div>`;
586
+ } catch { return ''; }
587
+ });
588
+ ```
589
+
590
+ **Routes to implement** (follow blog/plugin.js pattern exactly):
591
+
592
+ Events:
593
+ - `GET /events` - list; organisers see own only, admins see all
594
+ - `POST /events` - calls `createEvent({ ...body, organiserId: uid })`; 201 response
595
+ - `GET /events/:id` - 404 for not-found; 404 for organiser viewing others' events
596
+ - `PUT /events/:id` - ownership gate with `ADMIN_ROLES` named list; read-only if `source === 'jobs'`; calls `updateEvent()`
597
+ - `DELETE /events/:id` - admin-only; calls `cancelEvent()`
598
+
599
+ Occurrences:
600
+ - `GET /occurrences` - filters: `from`, `to`, `type`, `visibility`, `eventId`; top-up logic when `eventId` present and `to` is within 30 days of `event.windowGeneratedTo`; organisers see occurrences from own events only
601
+ - `GET /occurrences/:id` - simple fetch
602
+ - `PUT /occurrences/:id` - ownership gate via parent event's `organiserId`
603
+ - `DELETE /occurrences/:id` - calls `cancelOccurrence()`
604
+
605
+ RSVPs:
606
+ - `GET /rsvps` - filters: `occurrenceId`, `status`; organisers scoped to own events' occurrences
607
+ - `PUT /rsvps/:id` - update status
608
+ - `DELETE /rsvps/:id` - hard delete
609
+
610
+ Settings:
611
+ - `GET /settings` - admin-only; returns current `settings` object
612
+ - `PUT /settings` - admin-only; merges into `config/plugins.json` under `plugins.calendar.settings` using `saveConfig`
613
+
614
+ **Lifecycle exports** (these are named exports from `plugin.js`, NOT Fastify routes):
615
+
616
+ ```js
617
+ export async function onEnable({ fastify, services }) {
618
+ // Seed block
619
+ const blockPath = path.resolve('content/blocks/upcoming-events.html');
620
+ try { await fs.access(blockPath); } catch {
621
+ await fs.writeFile(blockPath, '[calendar limit="3" /]\n', 'utf8');
622
+ }
623
+ // Seed views (advisory - for admin Collections UI)
624
+ const views = [
625
+ { slug: 'calendar-upcoming', label: 'Upcoming Events', collection: 'calendar-occurrences',
626
+ filter: { status: 'active' }, sort: { field: 'startAt', direction: 'asc' } },
627
+ { slug: 'calendar-past', label: 'Past Events', collection: 'calendar-occurrences',
628
+ filter: {}, sort: { field: 'startAt', direction: 'desc' } },
629
+ { slug: 'calendar-my-events', label: 'My Events', collection: 'calendar-occurrences',
630
+ filter: {}, sort: { field: 'startAt', direction: 'asc' } }
631
+ ];
632
+ for (const view of views) {
633
+ const p = path.resolve(`content/views/${view.slug}.json`);
634
+ try { await fs.access(p); } catch {
635
+ await fs.writeFile(p, JSON.stringify(view, null, 2), 'utf8');
636
+ }
637
+ }
638
+ }
639
+
640
+ export async function onDisable({ fastify, services }) {
641
+ await fs.unlink(path.resolve('content/blocks/upcoming-events.html')).catch(() => {});
642
+ for (const slug of ['calendar-upcoming', 'calendar-past', 'calendar-my-events']) {
643
+ await fs.unlink(path.resolve(`content/views/${slug}.json`)).catch(() => {});
644
+ }
645
+ }
646
+ ```
647
+
648
+ - [ ] **Step 2: Add calendar to `config/plugins.json`**
649
+
650
+ Open `config/plugins.json` and add: `"calendar": { "enabled": false, "bundled": false, "settings": {} }`
651
+
652
+ - [ ] **Step 3: Smoke-test routes respond**
653
+
654
+ ```bash
655
+ curl -s http://localhost:4096/api/plugins/calendar/events \
656
+ -H "Authorization: Bearer INVALID_TOKEN" | grep -o '"error"\|"message"'
657
+ ```
658
+ Expected: `"error"` or `"message"` (not a 404 on the route itself - the plugin is loaded)
659
+
660
+ - [ ] **Step 4: Commit**
661
+
662
+ ```bash
663
+ git add plugins/calendar/plugin.js config/plugins.json
664
+ git commit -m "feat(calendar): add plugin.js - API routes, shortcode registration, onEnable/onDisable lifecycle"
665
+ ```
666
+
667
+ ---
668
+
669
+ ## Task 4: `plugin.public.js` - public SSR routes
670
+
671
+ **Files:**
672
+ - Create: `plugins/calendar/plugin.public.js`
673
+
674
+ Receives `options = { settings, auth: { authenticate, requireAdmin }, config }`.
675
+ `basePath` resolved as `(options.settings?.basePath ?? '/calendar').replace(/\/$/, '')`.
676
+
677
+ **CRITICAL: Route registration order** - register `feed.ics` FIRST, then `rsvp`, then `/event/:slug`, then paginated `/page/:n`, then base `/`. Fastify matches in registration order; `/event/:slug` registered last (of the static routes) to avoid swallowing the feed.
678
+
679
+ - [ ] **Step 1: Before writing - read `server/services/renderer.js`**
680
+
681
+ Open `server/services/renderer.js` and find `renderBlogPage`. Confirm its signature (typically `renderBlogPage(templatePath, data, seoMeta, reply)`). The Blog plugin uses it for plugin SSR pages; Calendar follows the same pattern. If the signature differs from what the plan describes, adjust the calls accordingly.
682
+
683
+ - [ ] **Step 2: Write `plugins/calendar/plugin.public.js`**
684
+
685
+ Imports:
686
+ ```js
687
+ import path from 'node:path';
688
+ import { fileURLToPath } from 'url';
689
+ import { renderBlogPage } from '../../server/services/renderer.js';
690
+ import { listEntries, getEntry, createEntry, updateEntry } from '../../server/services/collections.js';
691
+ import { getConfig } from '../../server/config.js';
692
+ import { EVENTS_SLUG, OCCURRENCES_SLUG, RSVPS_SLUG, toOccurrence } from './service.js';
693
+
694
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
695
+ ```
696
+
697
+ **Helpers:**
698
+ ```js
699
+ function escapeHtml(s) { return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
700
+ function toIcalDate(iso) { return iso.replace(/[-:]/g,'').replace(/\.\d{3}/,''); }
701
+ const isPublicActive = o => o.visibility === 'public' && o.status === 'active';
702
+ ```
703
+
704
+ **`GET {base}/feed.ics`** - iCal 2.0 feed:
705
+ - Fetch public active occurrences (sorted by `startAt`, limited to `feedLength`)
706
+ - For each occurrence build a `VEVENT` with: `UID:<id>@<hostname>`, `DTSTAMP:<now>`, `DTSTART`, `DTEND`, `SUMMARY`, `DESCRIPTION`, `LOCATION` (if set), `URL:<siteUrl>/calendar/event/<slug>` (omit if `slug` is null)
707
+ - Wrap in `BEGIN:VCALENDAR` / `END:VCALENDAR` with `CRLF` line endings
708
+ - Reply with `Content-Type: text/calendar`
709
+
710
+ **`POST {base}/rsvp`** - RSVP submission:
711
+ 1. Honeypot check: if `website` field non-empty → return 201 silently
712
+ 2. Validate `occurrenceId`, `name`, `email` - return 400 if missing
713
+ 3. Fetch occurrence - 404 if not found
714
+ 4. Capacity: if `rsvpLimit` set and `rsvpCount >= rsvpLimit` → 409
715
+ 5. Duplicate: check `calendar-rsvps` for same `occurrenceId` + `email` + `status: confirmed` → 200 if found
716
+ 6. Build `rsvpData = { occurrenceId, name, email, status: 'confirmed', contactId: null, createdAt: new Date().toISOString() }`
717
+ 7. Contacts upsert: if `getConfig()?.plugins?.calendar?.enabled && getConfig()?.plugins?.contacts?.enabled` → check `contacts-contacts` for matching email; upsert (verify slug constant first); store `contactId`
718
+ 8. `createEntry(RSVPS_SLUG, rsvpData)`
719
+ 9. Increment `rsvpCount` on occurrence via `updateEntry`
720
+ 10. If `Accept` header includes `text/html` → redirect to `{base}/event/{slug}?rsvp=success`; else return `201 { ok: true }`
721
+
722
+ **`GET {base}/event/:slug`** - event detail page:
723
+ - List occurrences, find first public active one matching the slug
724
+ - 404 if not found
725
+ - If `eventId` is set, fetch parent event for `description` and `location`
726
+ - Call `renderBlogPage(path.join(templateDir, 'event.html'), { occ, description, location, ... }, seoMeta, reply)`
727
+
728
+ **Listing helper `renderListing(reply, page)`:**
729
+ - List public active occurrences with `startAt >= now`, sorted ascending
730
+ - Paginate with `eventsPerPage`
731
+ - Call `renderBlogPage(path.join(templateDir, 'index.html'), { events, page, totalPages, base }, { title: 'Events' }, reply)`
732
+
733
+ **`GET {base}/page/:n`** → `renderListing(reply, parseInt(n))`
734
+
735
+ **`GET {base}`** → `renderListing(reply, 1)`
736
+
737
+ - [ ] **Step 3: Verify public plugin exports a default function**
738
+
739
+ ```bash
740
+ node --input-type=module <<'EOF'
741
+ import plugin from './plugins/calendar/plugin.public.js';
742
+ console.log(typeof plugin);
743
+ EOF
744
+ ```
745
+ Expected: `function`
746
+
747
+ - [ ] **Step 4: Commit**
748
+
749
+ ```bash
750
+ git add plugins/calendar/plugin.public.js
751
+ git commit -m "feat(calendar): add plugin.public.js - public listing, iCal feed, event page, RSVP POST"
752
+ ```
753
+
754
+ ---
755
+
756
+ ## Task 5: Public body templates
757
+
758
+ **Files:**
759
+ - Create: `plugins/calendar/templates/index.html`
760
+ - Create: `plugins/calendar/templates/event.html`
761
+
762
+ **CRITICAL: Before writing these, read how `renderBlogPage` works:**
763
+
764
+ ```bash
765
+ sed -n '/export async function renderBlogPage/,/^}/p' server/services/renderer.js | head -60
766
+ cat plugins/blog/templates/index.html
767
+ ```
768
+
769
+ Follow the exact same pattern that the blog plugin uses. The `data` object passed to `renderBlogPage` is used to construct the HTML body string - the exact mechanism (string interpolation, template engine, etc.) depends on what `renderBlogPage` does. Match the blog's approach exactly.
770
+
771
+ - [ ] **Step 1: Read blog's `renderBlogPage` implementation and `plugins/blog/templates/index.html`**
772
+
773
+ Run:
774
+ ```bash
775
+ sed -n '/export async function renderBlogPage/,/^}/p' server/services/renderer.js | head -80
776
+ cat plugins/blog/templates/index.html
777
+ ```
778
+
779
+ - [ ] **Step 2: Write `plugins/calendar/templates/index.html`**
780
+
781
+ Build to match the blog template pattern. Include:
782
+ - Page heading "Events"
783
+ - List/grid of event cards: title (linked to `/calendar/event/{slug}` if slug exists), date, time, type badge, location (if set)
784
+ - Pagination nav (Previous / Next links when `totalPages > 1`)
785
+ - "No upcoming events" empty state
786
+
787
+ - [ ] **Step 3: Write `plugins/calendar/templates/event.html`**
788
+
789
+ Include:
790
+ - Event title as `<h1>`
791
+ - Date / time formatted as human-readable string
792
+ - Location (if set)
793
+ - Description rendered as HTML (check how blog renders Markdown in public templates)
794
+ - RSVP success banner (shown when `?rsvp=success` query param is present - check the `rsvpSuccess` data field)
795
+ - Inline RSVP form when `showRsvp` is true and `atCapacity` is false:
796
+
797
+ ```html
798
+ <form method="POST" action="{base}/rsvp">
799
+ <input type="hidden" name="occurrenceId" value="{occ.id}">
800
+ <div class="mb-3">
801
+ <label>Name *</label>
802
+ <input type="text" name="name" required class="form-control">
803
+ </div>
804
+ <div class="mb-3">
805
+ <label>Email *</label>
806
+ <input type="email" name="email" required class="form-control">
807
+ </div>
808
+ <div style="display:none">
809
+ <label>Website<br><input type="text" name="website"></label>
810
+ </div>
811
+ <button type="submit" class="btn btn-primary">Register</button>
812
+ </form>
813
+ ```
814
+
815
+ - "At capacity" message when `atCapacity` is true
816
+ - Current RSVP count when `rsvpCount > 0`
817
+
818
+ - [ ] **Step 4: Enable calendar and smoke-test public routes**
819
+
820
+ Set `"calendar": { "enabled": true }` in `config/plugins.json`, then:
821
+
822
+ ```bash
823
+ # Public listing
824
+ curl -s http://localhost:4096/calendar | grep -c '<title>'
825
+
826
+ # iCal feed
827
+ curl -s http://localhost:4096/calendar/feed.ics | head -3
828
+ ```
829
+ Expected for feed:
830
+ ```
831
+ BEGIN:VCALENDAR
832
+ VERSION:2.0
833
+ PRODID:-//Domma CMS//Calendar//EN
834
+ ```
835
+
836
+ - [ ] **Step 5: Commit**
837
+
838
+ ```bash
839
+ git add plugins/calendar/templates/
840
+ git commit -m "feat(calendar): add public body templates - events listing and event detail with RSVP form"
841
+ ```
842
+
843
+ ---
844
+
845
+ ## Task 6: Admin HTML templates
846
+
847
+ **Files:**
848
+ - Create: `plugins/calendar/admin/templates/calendar.html`
849
+ - Create: `plugins/calendar/admin/templates/event-editor.html`
850
+ - Create: `plugins/calendar/admin/templates/rsvps.html`
851
+ - Create: `plugins/calendar/admin/templates/settings.html`
852
+
853
+ Admin templates are static HTML shells - all dynamic content is injected by the corresponding `.js` view using Domma APIs. Follow the blog plugin template pattern (`plugins/blog/admin/templates/blog.html`).
854
+
855
+ - [ ] **Step 1: Write `plugins/calendar/admin/templates/calendar.html`**
856
+
857
+ ```html
858
+ <div class="d-flex justify-content-between align-items-center mb-3">
859
+ <h2 class="mb-0">Events</h2>
860
+ <div class="d-flex gap-2 align-items-center">
861
+ <div class="btn-group" role="group">
862
+ <button id="btn-view-grid" class="btn btn-sm btn-outline" title="Grid view">
863
+ <span data-icon="grid" data-icon-size="14"></span>
864
+ </button>
865
+ <button id="btn-view-list" class="btn btn-sm btn-outline" title="List view">
866
+ <span data-icon="list" data-icon-size="14"></span>
867
+ </button>
868
+ <button id="btn-view-split" class="btn btn-sm btn-outline" title="Split view">
869
+ <span data-icon="columns" data-icon-size="14"></span>
870
+ </button>
871
+ </div>
872
+ <button id="btn-new-event" class="btn btn-sm btn-primary">
873
+ <span data-icon="plus" data-icon-size="14"></span> New Event
874
+ </button>
875
+ </div>
876
+ </div>
877
+
878
+ <div class="d-flex gap-2 mb-3">
879
+ <select id="filter-type" class="form-control form-control-sm" style="width:auto;">
880
+ <option value="">All types</option>
881
+ <option value="meeting">Meeting</option>
882
+ <option value="interview">Interview</option>
883
+ <option value="public-event">Public Event</option>
884
+ <option value="webinar">Webinar</option>
885
+ <option value="other">Other</option>
886
+ </select>
887
+ <select id="filter-visibility" class="form-control form-control-sm" style="width:auto;">
888
+ <option value="">All visibility</option>
889
+ <option value="public">Public</option>
890
+ <option value="private">Private</option>
891
+ </select>
892
+ </div>
893
+
894
+ <!-- Grid view -->
895
+ <div id="view-grid" style="display:none;">
896
+ <div class="d-flex justify-content-between align-items-center mb-2">
897
+ <button id="btn-prev-month" class="btn btn-sm btn-outline">&laquo;</button>
898
+ <strong id="grid-month-label"></strong>
899
+ <button id="btn-next-month" class="btn btn-sm btn-outline">&raquo;</button>
900
+ </div>
901
+ <div id="calendar-grid"></div>
902
+ </div>
903
+
904
+ <!-- List view -->
905
+ <div id="view-list" style="display:none;">
906
+ <div id="events-table"></div>
907
+ </div>
908
+
909
+ <!-- Split view -->
910
+ <div id="view-split" style="display:none;">
911
+ <div class="d-flex gap-3">
912
+ <div style="width:240px;flex-shrink:0;">
913
+ <div class="d-flex justify-content-between align-items-center mb-2">
914
+ <button id="btn-split-prev" class="btn btn-sm btn-outline">&laquo;</button>
915
+ <strong id="split-month-label"></strong>
916
+ <button id="btn-split-next" class="btn btn-sm btn-outline">&raquo;</button>
917
+ </div>
918
+ <div id="split-mini-cal"></div>
919
+ </div>
920
+ <div style="flex:1;">
921
+ <div id="split-day-label" class="text-muted small mb-2"></div>
922
+ <div id="split-day-events"></div>
923
+ </div>
924
+ </div>
925
+ </div>
926
+ ```
927
+
928
+ - [ ] **Step 2: Write `plugins/calendar/admin/templates/event-editor.html`**
929
+
930
+ ```html
931
+ <div class="d-flex justify-content-between align-items-center mb-3">
932
+ <h2 class="mb-0" id="editor-heading">New Event</h2>
933
+ <div class="d-flex gap-2">
934
+ <button id="btn-cancel" class="btn btn-sm btn-outline">Cancel</button>
935
+ <button id="btn-save" class="btn btn-sm btn-primary">Save Event</button>
936
+ </div>
937
+ </div>
938
+
939
+ <div id="jobs-notice" style="display:none;" class="alert alert-info mb-3">
940
+ This event is managed by Jobs and is read-only.
941
+ </div>
942
+
943
+ <div class="d-flex gap-3" id="editor-body">
944
+ <div style="flex:1;">
945
+ <div class="mb-3">
946
+ <label class="form-label">Title *</label>
947
+ <input id="field-title" type="text" class="form-control" placeholder="Event title">
948
+ </div>
949
+ <div class="mb-3">
950
+ <label class="form-label">Slug</label>
951
+ <div class="d-flex gap-2 align-items-center">
952
+ <input id="field-slug" type="text" class="form-control" placeholder="auto-derived">
953
+ <label style="white-space:nowrap;font-size:12px;">
954
+ <input type="checkbox" id="slug-auto" checked> Auto
955
+ </label>
956
+ </div>
957
+ </div>
958
+ <div class="mb-3">
959
+ <label class="form-label">Description (Markdown)</label>
960
+ <textarea id="field-description" class="form-control" rows="5" placeholder="Optional description"></textarea>
961
+ </div>
962
+ <div class="d-flex gap-2 mb-3">
963
+ <div style="flex:1;">
964
+ <label class="form-label">Type</label>
965
+ <select id="field-type" class="form-control">
966
+ <option value="meeting">Meeting</option>
967
+ <option value="interview">Interview</option>
968
+ <option value="public-event">Public Event</option>
969
+ <option value="webinar">Webinar</option>
970
+ <option value="other">Other</option>
971
+ </select>
972
+ </div>
973
+ <div style="flex:1;">
974
+ <label class="form-label">Visibility</label>
975
+ <select id="field-visibility" class="form-control">
976
+ <option value="private">Private</option>
977
+ <option value="public">Public</option>
978
+ </select>
979
+ </div>
980
+ </div>
981
+ <div class="mb-3">
982
+ <label class="form-label">Location</label>
983
+ <input id="field-location" type="text" class="form-control" placeholder="Address or video URL">
984
+ </div>
985
+ <div class="d-flex gap-2 mb-3">
986
+ <div style="flex:1;">
987
+ <label class="form-label">Start *</label>
988
+ <input id="field-start" type="datetime-local" class="form-control">
989
+ </div>
990
+ <div style="flex:1;">
991
+ <label class="form-label">End *</label>
992
+ <input id="field-end" type="datetime-local" class="form-control">
993
+ </div>
994
+ </div>
995
+ </div>
996
+ <div style="width:220px;flex-shrink:0;">
997
+ <div class="card mb-3">
998
+ <div class="card-header" style="font-size:12px;font-weight:600;">Recurrence</div>
999
+ <div class="card-body" style="font-size:12px;">
1000
+ <label class="d-flex align-items-center gap-2 mb-2">
1001
+ <input type="checkbox" id="recurrence-enabled"> Recurring event
1002
+ </label>
1003
+ <div id="recurrence-fields" style="display:none;">
1004
+ <div class="mb-2">
1005
+ <label>Frequency</label>
1006
+ <select id="field-freq" class="form-control form-control-sm">
1007
+ <option value="daily">Daily</option>
1008
+ <option value="weekly" selected>Weekly</option>
1009
+ <option value="monthly">Monthly</option>
1010
+ <option value="yearly">Yearly</option>
1011
+ </select>
1012
+ </div>
1013
+ <div class="mb-2">
1014
+ <label>Every</label>
1015
+ <input id="field-interval" type="number" min="1" value="1" class="form-control form-control-sm">
1016
+ </div>
1017
+ <div id="byday-section" class="mb-2">
1018
+ <label>On days</label>
1019
+ <div class="d-flex gap-1 flex-wrap" id="byday-checks">
1020
+ <label><input type="checkbox" value="MO"> Mo</label>
1021
+ <label><input type="checkbox" value="TU"> Tu</label>
1022
+ <label><input type="checkbox" value="WE"> We</label>
1023
+ <label><input type="checkbox" value="TH"> Th</label>
1024
+ <label><input type="checkbox" value="FR"> Fr</label>
1025
+ <label><input type="checkbox" value="SA"> Sa</label>
1026
+ <label><input type="checkbox" value="SU"> Su</label>
1027
+ </div>
1028
+ </div>
1029
+ <div class="mb-2">
1030
+ <label>Until</label>
1031
+ <input id="field-until" type="date" class="form-control form-control-sm">
1032
+ </div>
1033
+ </div>
1034
+ </div>
1035
+ </div>
1036
+ <div class="card">
1037
+ <div class="card-header" style="font-size:12px;font-weight:600;">RSVP</div>
1038
+ <div class="card-body" style="font-size:12px;">
1039
+ <label class="d-flex align-items-center gap-2 mb-2">
1040
+ <input type="checkbox" id="rsvp-enabled"> Accept RSVPs
1041
+ </label>
1042
+ <div id="rsvp-fields" style="display:none;">
1043
+ <label>Capacity (blank = unlimited)</label>
1044
+ <input id="field-rsvp-limit" type="number" min="1" class="form-control form-control-sm" placeholder="Unlimited">
1045
+ </div>
1046
+ </div>
1047
+ </div>
1048
+ </div>
1049
+ </div>
1050
+ ```
1051
+
1052
+ - [ ] **Step 3: Write `plugins/calendar/admin/templates/rsvps.html`**
1053
+
1054
+ ```html
1055
+ <div class="d-flex justify-content-between align-items-center mb-3">
1056
+ <h2 class="mb-0">RSVPs</h2>
1057
+ </div>
1058
+ <div class="d-flex gap-2 mb-3">
1059
+ <select id="filter-occurrence" class="form-control form-control-sm" style="max-width:320px;">
1060
+ <option value="">All events</option>
1061
+ </select>
1062
+ <select id="filter-status" class="form-control form-control-sm" style="width:auto;">
1063
+ <option value="">All statuses</option>
1064
+ <option value="confirmed">Confirmed</option>
1065
+ <option value="cancelled">Cancelled</option>
1066
+ </select>
1067
+ </div>
1068
+ <div id="rsvps-table"></div>
1069
+ ```
1070
+
1071
+ - [ ] **Step 4: Write `plugins/calendar/admin/templates/settings.html`**
1072
+
1073
+ ```html
1074
+ <div class="mb-3">
1075
+ <h2>Settings</h2>
1076
+ </div>
1077
+ <div class="card" style="max-width:520px;">
1078
+ <div class="card-body">
1079
+ <div id="settings-form"></div>
1080
+ </div>
1081
+ </div>
1082
+ ```
1083
+
1084
+ - [ ] **Step 5: Commit**
1085
+
1086
+ ```bash
1087
+ git add plugins/calendar/admin/templates/
1088
+ git commit -m "feat(calendar): add admin HTML templates"
1089
+ ```
1090
+
1091
+ ---
1092
+
1093
+ ## Task 7: Admin view: `calendar.js` - events list (Grid, List, Split)
1094
+
1095
+ **Files:**
1096
+ - Create: `plugins/calendar/admin/views/calendar.js`
1097
+
1098
+ This is the most complex admin view. Manages three rendering modes, month navigation for Grid/Split, and a filter bar. All event data fetched via `H.get`. View mode preference persisted via `S.set('calendar_view', mode)`.
1099
+
1100
+ **Key implementation notes:**
1101
+ - Use `T.create()` for the List mode - same pattern as `plugins/blog/admin/views/blog.js`
1102
+ - For Grid and Split calendar grids: build HTML strings with `escapeHtml()` for all user data, then use `$(el).html(htmlString)` to update the DOM - this goes through Domma's DOMPurify sanitisation
1103
+ - For Split day events: same pattern - build HTML string, set via `$(el).html(str)`
1104
+ - Interactive elements (edit buttons in Split view): build with `document.createElement` and `appendChild`, or use event delegation on the container after setting HTML (since `$(el).html()` strips buttons)
1105
+ - For the Split day events panel buttons specifically: render event cards as plain HTML (no buttons inside) and add separate DOM-built action buttons, OR navigate via `<a href="...">` links instead of buttons
1106
+
1107
+ - [ ] **Step 1: Write `plugins/calendar/admin/views/calendar.js`**
1108
+
1109
+ Export `calendarView = { templateUrl: '/plugins/calendar/admin/templates/calendar.html', async onMount($container) { ... } }`.
1110
+
1111
+ **`onMount` structure:**
1112
+
1113
+ ```js
1114
+ async onMount($container) {
1115
+ let allEvents = [], allOccurrences = [];
1116
+ let eventsTable = null;
1117
+ let gridMonth = new Date(); gridMonth.setDate(1);
1118
+ let splitSelectedDate = new Date().toISOString().slice(0, 10);
1119
+
1120
+ const BASE = '/api/plugins/calendar';
1121
+
1122
+ function escapeHtml(s) { return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
1123
+
1124
+ async function loadData() { /* H.get events + occurrences */ }
1125
+
1126
+ // View mode
1127
+ let currentMode = S.get('calendar_view') ?? 'list';
1128
+ function activateMode(mode) {
1129
+ currentMode = mode;
1130
+ S.set('calendar_view', mode);
1131
+ // Hide all view containers
1132
+ ['grid','list','split'].forEach(m => {
1133
+ const el = $container.find(`#view-${m}`).get(0);
1134
+ if (el) el.style.display = m === mode ? '' : 'none';
1135
+ });
1136
+ // Update button styles
1137
+ ['grid','list','split'].forEach(m => {
1138
+ const btn = $container.find(`#btn-view-${m}`).get(0);
1139
+ if (!btn) return;
1140
+ btn.className = `btn btn-sm ${m === mode ? 'btn-primary' : 'btn-outline'}`;
1141
+ });
1142
+ renderCurrentMode();
1143
+ }
1144
+ ```
1145
+
1146
+ **List mode (`renderList`):**
1147
+ - Use `T.create()` with columns: title (linked), next occurrence date, type badge, visibility badge, RSVP count, actions (Edit link, Delete button)
1148
+ - Type badge colours: `meeting→badge-info`, `interview→badge-warning`, `public-event→badge-success`, `webinar→badge-primary`, `other→badge-secondary`
1149
+ - `nextOccurrence(eventId)` helper: finds first occurrence where `eventId` matches, `startAt >= now`, `status === 'active'`, sorted ascending
1150
+ - Guard: `if (eventsTable) { eventsTable.setData(filteredEvents); return; }` to avoid duplicate tables
1151
+ - Delete via event delegation on `tableEl`: confirm with `E.confirm()`, call `H.delete`, reload data, call `eventsTable.setData()`
1152
+
1153
+ **Grid mode (`renderGrid`):**
1154
+ - Build 7-column CSS grid HTML string (Monday-first week)
1155
+ - Day cells: show date number + up to 3 occurrence badges (type-coloured) + "+N more" if more
1156
+ - Use CSS custom properties for theming: `var(--dm-bg)`, `var(--dm-text)`, `var(--dm-primary)`, `var(--dm-border-color)`, `var(--dm-text-muted)`, `var(--dm-bg-subtle)`
1157
+ - Highlight today's date with primary colour border/weight
1158
+ - After building the HTML string: `$(gridEl).html(html)` - this goes through DOMPurify (no interactive elements in grid cells)
1159
+
1160
+ **Split mode (`renderSplit`):**
1161
+ - Mini-cal (left): 7-column grid showing month days; clicking a day sets `splitSelectedDate` and calls `renderSplit()`
1162
+ - Mini-cal day cells: plain divs with `data-date` attribute; after `$(calEl).html(miniCalHtml)`, attach click listener via `calEl.addEventListener('click', e => { const d = e.target.closest('[data-date]'); if (d) { splitSelectedDate = d.dataset.date; renderSplit(); } })`
1163
+ - Day events panel (right): for the selected date, render event cards as `<a>` links (no buttons) pointing to `#/plugins/calendar/events/{eventId}` - avoids DOMPurify stripping buttons
1164
+
1165
+ **Filter bar:**
1166
+ - `$container.find('#filter-type, #filter-visibility').each(function() { this.addEventListener('change', () => { eventsTable = null; renderCurrentMode(); }); })`
1167
+
1168
+ **Month navigation:**
1169
+ - Prev/Next buttons adjust `gridMonth` (for Grid) or trigger `renderSplit()` (for Split mini-cal)
1170
+
1171
+ **Init sequence:**
1172
+ ```js
1173
+ await loadData();
1174
+ // Bind all buttons
1175
+ // Activate saved mode
1176
+ activateMode(currentMode);
1177
+ I.scan();
1178
+ ```
1179
+
1180
+ - [ ] **Step 2: Commit**
1181
+
1182
+ ```bash
1183
+ git add plugins/calendar/admin/views/calendar.js
1184
+ git commit -m "feat(calendar): add calendar.js admin view - Grid, List, Split modes"
1185
+ ```
1186
+
1187
+ ---
1188
+
1189
+ ## Task 8: Admin view: `event-editor.js` - create / edit + recurrence panel
1190
+
1191
+ **Files:**
1192
+ - Create: `plugins/calendar/admin/views/event-editor.js`
1193
+
1194
+ Reads `R.current?.params?.id` - if missing or `'new'`, create mode; otherwise edit mode. Jobs-sourced events are shown read-only (all inputs disabled, save button disabled).
1195
+
1196
+ - [ ] **Step 1: Write `plugins/calendar/admin/views/event-editor.js`**
1197
+
1198
+ Export `eventEditorView = { templateUrl: '/plugins/calendar/admin/templates/event-editor.html', async onMount($container) { ... } }`.
1199
+
1200
+ **`onMount` structure:**
1201
+
1202
+ ```js
1203
+ const rawId = R.current?.params?.id;
1204
+ const isNew = !rawId || rawId === 'new';
1205
+ const id = isNew ? null : rawId;
1206
+ let event = null;
1207
+ let autoSlug = true;
1208
+ const BASE = '/api/plugins/calendar';
1209
+ ```
1210
+
1211
+ **Load for edit:**
1212
+ ```js
1213
+ if (!isNew) {
1214
+ const res = await H.get(`${BASE}/events/${id}`);
1215
+ if (res.error) { E.toast('Failed to load event.', { type: 'error' }); R.navigate('#/plugins/calendar'); return; }
1216
+ event = res.data ?? res;
1217
+ }
1218
+ ```
1219
+
1220
+ **Populate fields:**
1221
+ ```js
1222
+ function setField(fieldId, value) {
1223
+ const el = $container.find(`#${fieldId}`).get(0);
1224
+ if (el) el.value = value ?? '';
1225
+ }
1226
+ ```
1227
+ Set: title, slug, description, type, visibility, location, start (as `datetime-local` format: `iso.slice(0,16)`), end.
1228
+
1229
+ **Read-only for Jobs events:**
1230
+ ```js
1231
+ const isJobsEvent = event?.source === 'jobs';
1232
+ if (isJobsEvent) {
1233
+ $container.find('#jobs-notice').get(0).style.display = '';
1234
+ $container.find('#editor-body').get(0)
1235
+ .querySelectorAll('input, select, textarea, button').forEach(el => el.disabled = true);
1236
+ }
1237
+ ```
1238
+
1239
+ **Recurrence panel:**
1240
+ - `#recurrence-enabled` checkbox toggles `#recurrence-fields` visibility
1241
+ - When populated from existing event: set freq, interval, until, byday checkboxes
1242
+ - `#field-freq` change → show/hide `#byday-section` (only visible when freq is `weekly`)
1243
+
1244
+ **RSVP panel:**
1245
+ - `#rsvp-enabled` checkbox toggles `#rsvp-fields` visibility
1246
+
1247
+ **Auto-slug:**
1248
+ - `#slug-auto` checkbox: when checked, `#field-slug` is `disabled = true` and its value mirrors `slugify($('#field-title').value)` on input events
1249
+ - For edit mode with existing slug: set `autoSlug = false`, uncheck `#slug-auto`, leave slug editable
1250
+
1251
+ **`collectValues()` helper:**
1252
+ ```js
1253
+ function collectValues() {
1254
+ const recurrenceEnabled = $container.find('#recurrence-enabled').get(0)?.checked;
1255
+ let recurrence = null;
1256
+ if (recurrenceEnabled) {
1257
+ const freq = $container.find('#field-freq').get(0)?.value ?? 'weekly';
1258
+ const interval = parseInt($container.find('#field-interval').get(0)?.value ?? '1', 10);
1259
+ const until = $container.find('#field-until').get(0)?.value || null;
1260
+ const byday = [];
1261
+ $container.find('#byday-checks input').each(function() { if (this.checked) byday.push(this.value); });
1262
+ recurrence = { freq, interval, byday: freq === 'weekly' ? byday : [], until, count: null };
1263
+ }
1264
+ // Convert datetime-local string 'YYYY-MM-DDTHH:mm' to ISO 8601
1265
+ const startRaw = $container.find('#field-start').get(0)?.value ?? '';
1266
+ const endRaw = $container.find('#field-end').get(0)?.value ?? '';
1267
+ return {
1268
+ title: $container.find('#field-title').get(0)?.value.trim() ?? '',
1269
+ slug: $container.find('#field-slug').get(0)?.value.trim() ?? '',
1270
+ description: $container.find('#field-description').get(0)?.value ?? '',
1271
+ type: $container.find('#field-type').get(0)?.value ?? 'other',
1272
+ visibility: $container.find('#field-visibility').get(0)?.value ?? 'private',
1273
+ location: $container.find('#field-location').get(0)?.value.trim() ?? '',
1274
+ startAt: startRaw ? new Date(startRaw).toISOString() : null,
1275
+ endAt: endRaw ? new Date(endRaw).toISOString() : null,
1276
+ recurrence,
1277
+ rsvpEnabled: $container.find('#rsvp-enabled').get(0)?.checked ?? false,
1278
+ rsvpLimit: $container.find('#field-rsvp-limit').get(0)?.value
1279
+ ? parseInt($container.find('#field-rsvp-limit').get(0)?.value, 10) : null
1280
+ };
1281
+ }
1282
+ ```
1283
+
1284
+ **`doSave()` async function:**
1285
+ - Validate title + startAt + endAt; show `E.toast` on failure and return null
1286
+ - `isNew ? H.post(...) : H.put(...)`
1287
+ - On success: `E.toast('Event saved.', { type: 'success' })` then `R.navigate('#/plugins/calendar')`
1288
+
1289
+ **Button bindings:**
1290
+ - `#btn-save`: calls `doSave()`; disabled if `isJobsEvent`
1291
+ - `#btn-cancel`: `R.navigate('#/plugins/calendar')`
1292
+
1293
+ **End of `onMount`:** `I.scan()`
1294
+
1295
+ - [ ] **Step 2: Commit**
1296
+
1297
+ ```bash
1298
+ git add plugins/calendar/admin/views/event-editor.js
1299
+ git commit -m "feat(calendar): add event-editor.js - create/edit event with recurrence and RSVP panels"
1300
+ ```
1301
+
1302
+ ---
1303
+
1304
+ ## Task 9: Admin view: `rsvps.js`
1305
+
1306
+ **Files:**
1307
+ - Create: `plugins/calendar/admin/views/rsvps.js`
1308
+
1309
+ - [ ] **Step 1: Write `plugins/calendar/admin/views/rsvps.js`**
1310
+
1311
+ Export `rsvpsView = { templateUrl: '/plugins/calendar/admin/templates/rsvps.html', async onMount($container) { ... } }`.
1312
+
1313
+ ```js
1314
+ const BASE = '/api/plugins/calendar';
1315
+ function escapeHtml(s) { ... }
1316
+ function formatDate(iso) { return iso ? new Date(iso).toLocaleDateString('en-GB', {...}) : '-'; }
1317
+ ```
1318
+
1319
+ **`onMount` structure:**
1320
+ ```js
1321
+ let allRsvps = [], allOccurrences = [];
1322
+ let rsvpsTable = null;
1323
+
1324
+ async function loadData() {
1325
+ const [rsvpRes, occRes] = await Promise.all([H.get(`${BASE}/rsvps`), H.get(`${BASE}/occurrences`)]);
1326
+ if (rsvpRes.error || occRes.error) { E.toast('Failed to load RSVPs.', { type: 'error' }); return; }
1327
+ allRsvps = Array.isArray(rsvpRes) ? rsvpRes : (rsvpRes.data ?? []);
1328
+ allOccurrences = Array.isArray(occRes) ? occRes : (occRes.data ?? []);
1329
+ }
1330
+ ```
1331
+
1332
+ **`buildOccurrenceFilter()`:** Populates `#filter-occurrence` select with upcoming occurrences (sorted by `startAt` ascending), `option value = occ.id`, `textContent = "${occ.title} - ${formatDate(occ.startAt)}"`.
1333
+
1334
+ **`filteredRsvps()`:** Reads filter select values, returns filtered `allRsvps`.
1335
+
1336
+ **`renderTable()`:**
1337
+ - Guard: `if (rsvpsTable) { rsvpsTable.setData(filteredRsvps()); return; }`
1338
+ - `T.create()` columns: Name, Email, Event (resolve occurrence title from `allOccurrences`), Status (badge), Registered (date), Actions
1339
+ - Actions column: toggle button (Cancel ↔ Confirm) + Delete button
1340
+ - Event delegation on `tableEl`: Cancel/Confirm call `H.put(..., { status: ... })`, Delete calls `E.confirm()` then `H.delete`; on success: `await loadData(); rsvpsTable = null; renderTable()`
1341
+
1342
+ **Init:**
1343
+ ```js
1344
+ await loadData();
1345
+ buildOccurrenceFilter();
1346
+ renderTable();
1347
+ $container.find('#filter-occurrence, #filter-status').each(function() {
1348
+ this.addEventListener('change', () => { rsvpsTable = null; renderTable(); });
1349
+ });
1350
+ ```
1351
+
1352
+ - [ ] **Step 2: Commit**
1353
+
1354
+ ```bash
1355
+ git add plugins/calendar/admin/views/rsvps.js
1356
+ git commit -m "feat(calendar): add rsvps.js admin view"
1357
+ ```
1358
+
1359
+ ---
1360
+
1361
+ ## Task 10: Admin view: `settings.js`, enable plugin, smoke test
1362
+
1363
+ **Files:**
1364
+ - Create: `plugins/calendar/admin/views/settings.js`
1365
+ - Modify: `config/plugins.json` (set `enabled: true`)
1366
+
1367
+ - [ ] **Step 1: Write `plugins/calendar/admin/views/settings.js`**
1368
+
1369
+ Export `settingsView = { templateUrl: '/plugins/calendar/admin/templates/settings.html', async onMount($container) { ... } }`.
1370
+
1371
+ ```js
1372
+ const BASE = '/api/plugins/calendar';
1373
+
1374
+ async onMount($container) {
1375
+ let settings = {};
1376
+ try {
1377
+ const res = await H.get(`${BASE}/settings`);
1378
+ if (res.error) throw new Error(res.error);
1379
+ settings = res.data ?? res ?? {};
1380
+ } catch { E.toast('Failed to load settings.', { type: 'error' }); }
1381
+
1382
+ const formEl = $container.find('#settings-form').get(0);
1383
+ if (!formEl) return;
1384
+
1385
+ F.create(formEl, {
1386
+ blueprint: [
1387
+ { key: 'basePath', label: 'Base path', type: 'text', value: settings.basePath ?? '/calendar',
1388
+ hint: 'URL prefix for all calendar pages. Changes require a server restart.' },
1389
+ { key: 'eventsPerPage', label: 'Events per page', type: 'number', value: settings.eventsPerPage ?? 12 },
1390
+ { key: 'rsvpEnabled', label: 'Enable RSVPs', type: 'toggle', value: settings.rsvpEnabled ?? true },
1391
+ { key: 'defaultView', label: 'Default admin view', type: 'select', value: settings.defaultView ?? 'list',
1392
+ options: ['list','grid','split'] },
1393
+ { key: 'occurrenceWindow', label: 'Occurrence window (days)', type: 'number', value: settings.occurrenceWindow ?? 365,
1394
+ hint: 'How many days ahead to pre-generate recurring occurrences.' },
1395
+ { key: 'feedLength', label: 'iCal feed length', type: 'number', value: settings.feedLength ?? 50 }
1396
+ ],
1397
+ onSubmit: async (values) => {
1398
+ try {
1399
+ const res = await H.put(`${BASE}/settings`, {
1400
+ basePath: values.basePath,
1401
+ eventsPerPage: parseInt(values.eventsPerPage, 10),
1402
+ rsvpEnabled: Boolean(values.rsvpEnabled),
1403
+ defaultView: values.defaultView,
1404
+ occurrenceWindow: parseInt(values.occurrenceWindow, 10),
1405
+ feedLength: parseInt(values.feedLength, 10)
1406
+ });
1407
+ if (res.error) throw new Error(res.error);
1408
+ E.toast('Settings saved. Base path changes require a server restart.', { type: 'success' });
1409
+ } catch (err) {
1410
+ E.toast(err?.message ?? 'Failed to save settings.', { type: 'error' });
1411
+ }
1412
+ }
1413
+ });
1414
+ }
1415
+ ```
1416
+
1417
+ - [ ] **Step 2: Enable calendar in `config/plugins.json`**
1418
+
1419
+ Set `"calendar": { "enabled": true, "bundled": false, "settings": {} }`.
1420
+
1421
+ - [ ] **Step 3: Smoke-test API routes with valid token**
1422
+
1423
+ Get a token first:
1424
+ ```bash
1425
+ TOKEN=$(curl -s -X POST http://localhost:4096/api/auth/login \
1426
+ -H "Content-Type: application/json" \
1427
+ -d '{"email":"admin@example.com","password":"YOUR_ADMIN_PASSWORD"}' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
1428
+ ```
1429
+
1430
+ ```bash
1431
+ # Create a one-off public event
1432
+ curl -s -X POST http://localhost:4096/api/plugins/calendar/events \
1433
+ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
1434
+ -d '{"title":"Launch Webinar","type":"public-event","visibility":"public","startAt":"2026-06-01T10:00:00.000Z","endAt":"2026-06-01T11:30:00.000Z"}' | grep -o '"id":"[^"]*"'
1435
+ ```
1436
+ Expected: `"id":"<uuid>"`
1437
+
1438
+ ```bash
1439
+ # Verify occurrence was materialised
1440
+ curl -s http://localhost:4096/api/plugins/calendar/occurrences \
1441
+ -H "Authorization: Bearer $TOKEN" | grep -o '"title":"Launch Webinar"'
1442
+ ```
1443
+ Expected: `"title":"Launch Webinar"`
1444
+
1445
+ - [ ] **Step 4: Smoke-test public routes**
1446
+
1447
+ ```bash
1448
+ curl -s http://localhost:4096/calendar/feed.ics | head -4
1449
+ ```
1450
+ Expected:
1451
+ ```
1452
+ BEGIN:VCALENDAR
1453
+ VERSION:2.0
1454
+ PRODID:-//Domma CMS//Calendar//EN
1455
+ ```
1456
+
1457
+ ```bash
1458
+ curl -s http://localhost:4096/calendar | grep -i 'events\|calendar' | head -3
1459
+ ```
1460
+
1461
+ - [ ] **Step 5: Smoke-test admin panel in browser**
1462
+
1463
+ Open `http://192.168.1.4:4096/admin` in browser. Log in as admin.
1464
+
1465
+ 1. Verify "Calendar" appears in the sidebar
1466
+ 2. Navigate to Calendar → Events → confirm List view table loads
1467
+ 3. Toggle Grid view → confirm month calendar renders (days with type-coloured badges)
1468
+ 4. Toggle Split view → confirm mini calendar + day detail panel render
1469
+ 5. Click "New Event" → confirm editor opens
1470
+ 6. Fill in title, type=Public Event, visibility=Public, start + end datetimes → Save → confirm event appears in list
1471
+ 7. Enable recurrence (weekly, Monday + Wednesday) → save → check that multiple occurrences appear when switching to Grid view
1472
+ 8. Navigate to Calendar → RSVPs → confirm table loads (may be empty)
1473
+ 9. Navigate to Calendar → Settings → confirm F.create form renders with all fields
1474
+
1475
+ - [ ] **Step 6: Smoke-test RSVP submission**
1476
+
1477
+ After creating a public event with `rsvpEnabled: true`:
1478
+ ```bash
1479
+ # Find the occurrence ID (UUID - used as occurrenceId in the RSVP body)
1480
+ OCC_RESPONSE=$(curl -s http://localhost:4096/api/plugins/calendar/occurrences \
1481
+ -H "Authorization: Bearer $TOKEN")
1482
+ OCC_ID=$(echo "$OCC_RESPONSE" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
1483
+ echo "Occurrence ID: $OCC_ID"
1484
+
1485
+ # Submit RSVP
1486
+ curl -s -X POST http://localhost:4096/calendar/rsvp \
1487
+ -H "Content-Type: application/json" \
1488
+ -d "{\"occurrenceId\":\"$OCC_ID\",\"name\":\"Jane Smith\",\"email\":\"jane@example.com\"}" | grep -o '"ok"'
1489
+ ```
1490
+ Expected: `"ok"`
1491
+
1492
+ ```bash
1493
+ # Verify RSVP in admin
1494
+ curl -s http://localhost:4096/api/plugins/calendar/rsvps \
1495
+ -H "Authorization: Bearer $TOKEN" | grep -o '"email":"jane@example.com"'
1496
+ ```
1497
+ Expected: `"email":"jane@example.com"`
1498
+
1499
+ - [ ] **Step 7: Final commit**
1500
+
1501
+ ```bash
1502
+ git add plugins/calendar/admin/views/settings.js config/plugins.json
1503
+ git commit -m "feat(calendar): add settings.js admin view and enable plugin"
1504
+ ```
1505
+
1506
+ ---
1507
+
1508
+ ## Reference: what `setupPlugin` seeds automatically on `onEnable`
1509
+
1510
+ When Calendar is enabled via the admin UI, the plugin system calls `setupPlugin` with full services, then calls the plugin's exported `onEnable({ fastify, services })`. Together these seed:
1511
+
1512
+ | Asset | Source | Destination |
1513
+ |---|---|---|
1514
+ | Collection `calendar-events` | `plugins/calendar/collections/calendar-events/schema.json` | `content/collections/calendar-events/` |
1515
+ | Collection `calendar-occurrences` | `plugins/calendar/collections/calendar-occurrences/schema.json` | `content/collections/calendar-occurrences/` |
1516
+ | Collection `calendar-rsvps` | `plugins/calendar/collections/calendar-rsvps/schema.json` | `content/collections/calendar-rsvps/` |
1517
+ | Role `calendar-organiser` | `plugins/calendar/roles/calendar-organiser.json` | `content/collections/roles/data.json` |
1518
+ | Role `calendar-admin` | `plugins/calendar/roles/calendar-admin.json` | `content/collections/roles/data.json` |
1519
+ | Form `calendar-rsvp` | `plugins/calendar/forms/calendar-rsvp.json` | `content/forms/calendar-rsvp.json` |
1520
+ | Page `/calendar` | `plugins/calendar/pages/calendar.md` | `content/pages/calendar.md` |
1521
+ | Block `upcoming-events` | `plugins/calendar/blocks/upcoming-events.html` | `content/blocks/upcoming-events.html` |
1522
+ | View `calendar-upcoming` | generated in `onEnable` | `content/views/calendar-upcoming.json` |
1523
+ | View `calendar-past` | generated in `onEnable` | `content/views/calendar-past.json` |
1524
+ | View `calendar-my-events` | generated in `onEnable` | `content/views/calendar-my-events.json` |
1525
+
1526
+ `teardownPlugin` removes: page, form, roles. `onDisable` removes: block, views. Collections are never destroyed (data preserved).