domma-cms 0.17.0 → 0.21.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 (136) hide show
  1. package/CLAUDE.md +39 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/css/dashboard.css +1 -1
  4. package/admin/index.html +2 -2
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +4 -4
  7. package/admin/js/config/sidebar-config.js +1 -1
  8. package/admin/js/lib/card-builder.js +3 -3
  9. package/admin/js/lib/crud-tutorial.js +1 -0
  10. package/admin/js/lib/effects-builder.js +1 -1
  11. package/admin/js/lib/markdown-toolbar.js +6 -6
  12. package/admin/js/lib/project-context.js +1 -0
  13. package/admin/js/lib/sidebar-renderer.js +4 -0
  14. package/admin/js/templates/action-editor.html +7 -0
  15. package/admin/js/templates/block-editor.html +7 -0
  16. package/admin/js/templates/collection-editor.html +9 -0
  17. package/admin/js/templates/dashboard/cache.html +32 -0
  18. package/admin/js/templates/dashboard.html +4 -0
  19. package/admin/js/templates/form-editor.html +9 -0
  20. package/admin/js/templates/menu-editor.html +98 -0
  21. package/admin/js/templates/menu-locations.html +14 -0
  22. package/admin/js/templates/menus.html +14 -0
  23. package/admin/js/templates/page-editor.html +9 -2
  24. package/admin/js/templates/project-detail.html +50 -0
  25. package/admin/js/templates/project-editor.html +45 -0
  26. package/admin/js/templates/project-settings.html +60 -0
  27. package/admin/js/templates/projects.html +13 -0
  28. package/admin/js/templates/role-editor.html +11 -0
  29. package/admin/js/templates/settings.html +26 -0
  30. package/admin/js/templates/tutorials.html +335 -2
  31. package/admin/js/templates/view-editor.html +7 -0
  32. package/admin/js/views/action-editor.js +1 -1
  33. package/admin/js/views/actions-list.js +1 -1
  34. package/admin/js/views/block-editor-enhance.js +1 -1
  35. package/admin/js/views/block-editor.js +8 -8
  36. package/admin/js/views/blocks.js +2 -2
  37. package/admin/js/views/collection-editor.js +4 -4
  38. package/admin/js/views/collections.js +1 -1
  39. package/admin/js/views/dashboard/widgets/activity-feed.js +1 -1
  40. package/admin/js/views/dashboard/widgets/cache.js +1 -0
  41. package/admin/js/views/dashboard/widgets/journeys.js +1 -1
  42. package/admin/js/views/dashboard/widgets/spike-feed.js +1 -1
  43. package/admin/js/views/dashboard/widgets/top-pages.js +1 -1
  44. package/admin/js/views/dashboard.js +1 -1
  45. package/admin/js/views/form-editor.js +6 -6
  46. package/admin/js/views/forms.js +1 -1
  47. package/admin/js/views/index.js +1 -1
  48. package/admin/js/views/menu-editor.js +19 -0
  49. package/admin/js/views/menu-locations.js +1 -0
  50. package/admin/js/views/menus.js +5 -0
  51. package/admin/js/views/page-editor.js +41 -36
  52. package/admin/js/views/pages.js +3 -3
  53. package/admin/js/views/project-detail.js +4 -0
  54. package/admin/js/views/project-editor.js +1 -0
  55. package/admin/js/views/project-settings.js +1 -0
  56. package/admin/js/views/projects.js +7 -0
  57. package/admin/js/views/role-editor.js +1 -1
  58. package/admin/js/views/roles.js +3 -3
  59. package/admin/js/views/settings.js +3 -3
  60. package/admin/js/views/tutorials.js +1 -1
  61. package/admin/js/views/user-editor.js +1 -1
  62. package/admin/js/views/users.js +3 -3
  63. package/admin/js/views/view-editor.js +1 -1
  64. package/admin/js/views/views-list.js +1 -1
  65. package/config/cache.json +4 -0
  66. package/config/cache.json.example +12 -0
  67. package/config/menu-locations.json +5 -0
  68. package/config/menus/admin-sidebar.json +185 -0
  69. package/config/menus/footer.json +33 -0
  70. package/config/menus/main.json +35 -0
  71. package/config/menus/sproj-1779696558011-menu.json +17 -0
  72. package/config/menus/sproj-1779696960337-menu.json +18 -0
  73. package/config/menus/sproj-1779696985353-menu.json +18 -0
  74. package/config/site.json +6 -22
  75. package/package.json +4 -3
  76. package/plugins/analytics/daily.json +3 -0
  77. package/plugins/analytics/journeys.json +8 -0
  78. package/plugins/analytics/lifetime.json +1 -1
  79. package/public/css/site.css +1 -1
  80. package/public/js/collection-browser.js +4 -0
  81. package/public/js/forms.js +1 -1
  82. package/public/js/site.js +1 -1
  83. package/server/config.js +12 -1
  84. package/server/middleware/auth.js +88 -22
  85. package/server/routes/api/actions.js +58 -5
  86. package/server/routes/api/auth.js +2 -2
  87. package/server/routes/api/blocks.js +18 -3
  88. package/server/routes/api/cache.js +57 -0
  89. package/server/routes/api/collections.js +201 -8
  90. package/server/routes/api/forms.js +266 -21
  91. package/server/routes/api/menu-locations.js +46 -0
  92. package/server/routes/api/menus.js +115 -0
  93. package/server/routes/api/navigation.js +2 -0
  94. package/server/routes/api/pages.js +1 -1
  95. package/server/routes/api/projects.js +107 -0
  96. package/server/routes/api/scaffold.js +86 -0
  97. package/server/routes/api/settings.js +3 -0
  98. package/server/routes/api/sidebar.js +23 -0
  99. package/server/routes/api/users.js +32 -7
  100. package/server/routes/api/views.js +10 -2
  101. package/server/routes/public.js +88 -7
  102. package/server/server.js +54 -3
  103. package/server/services/actions.js +137 -8
  104. package/server/services/adapters/FileAdapter.js +23 -8
  105. package/server/services/adapters/MongoAdapter.js +36 -18
  106. package/server/services/blocks.js +23 -8
  107. package/server/services/cache/drivers/MemoryDriver.js +118 -0
  108. package/server/services/cache/drivers/NoneDriver.js +12 -0
  109. package/server/services/cache/index.js +229 -0
  110. package/server/services/cache/lru.js +61 -0
  111. package/server/services/collections.js +102 -12
  112. package/server/services/content.js +25 -6
  113. package/server/services/filterEngine.js +281 -0
  114. package/server/services/forms.js +3 -0
  115. package/server/services/hooks.js +48 -0
  116. package/server/services/markdown.js +711 -124
  117. package/server/services/menus-migration.js +107 -0
  118. package/server/services/menus.js +422 -0
  119. package/server/services/permissionRegistry.js +26 -0
  120. package/server/services/plugins.js +9 -2
  121. package/server/services/presetCollections.js +22 -0
  122. package/server/services/projects.js +429 -0
  123. package/server/services/recipes/contact-list.json +78 -0
  124. package/server/services/recipes/onboarding.json +426 -0
  125. package/server/services/references.js +174 -0
  126. package/server/services/renderer.js +237 -40
  127. package/server/services/roles.js +6 -1
  128. package/server/services/rowAccess.js +86 -13
  129. package/server/services/scaffolder.js +465 -0
  130. package/server/services/sidebar-migration.js +117 -0
  131. package/server/services/sitemap.js +112 -0
  132. package/server/services/userRoles.js +86 -0
  133. package/server/services/users.js +23 -2
  134. package/server/services/views.js +19 -4
  135. package/server/templates/page.html +135 -130
  136. /package/config/{navigation.json → navigation.json.bak} +0 -0
@@ -0,0 +1,86 @@
1
+ /**
2
+ * User Roles — multi-role support helpers.
3
+ *
4
+ * A user has ONE primary role (`user.role`) and an optional array of
5
+ * additional roles (`user.additionalRoles`). Permission, visibility, and
6
+ * row-access checks consult ALL of them and grant access if any one role
7
+ * satisfies — "any path wins" semantics that matches how most apps actually
8
+ * model multi-membership (you can be both a candidate AND a recruiter).
9
+ *
10
+ * Hierarchical-level comparisons (`canManageUser`, `requireAdmin`, the
11
+ * level-clamp in `checkSingleVisibility`) use `getEffectiveLevel` which
12
+ * returns the LOWEST level number across all the user's roles — i.e. the
13
+ * highest privilege the user has access to. This is what makes "additional
14
+ * admin" meaningfully grant admin-tier access without rewriting the level
15
+ * arithmetic everywhere.
16
+ *
17
+ * Backward compatibility: users with no `additionalRoles` field behave
18
+ * exactly as before — `getEffectiveRoles` returns `[user.role]`, `getEffectiveLevel`
19
+ * returns `getRoleLevel(user.role)`. Existing callers that read `user.role`
20
+ * directly continue to work; the new helpers are opt-in for callers that
21
+ * want the union semantics.
22
+ */
23
+ import {getRoleLevel} from './roles.js';
24
+
25
+ /**
26
+ * Return all roles a user holds, primary first, deduplicated. Empty array
27
+ * if the user is null/has no role at all.
28
+ *
29
+ * @param {object|null} user
30
+ * @returns {string[]}
31
+ */
32
+ export function getEffectiveRoles(user) {
33
+ if (!user || !user.role) return [];
34
+ const additional = Array.isArray(user.additionalRoles) ? user.additionalRoles : [];
35
+ const out = [user.role];
36
+ for (const r of additional) {
37
+ if (r && r !== user.role && !out.includes(r)) out.push(r);
38
+ }
39
+ return out;
40
+ }
41
+
42
+ /**
43
+ * Return the effective role level for a user — the LOWEST level number
44
+ * across all roles. Lower number = higher privilege, so the user's most
45
+ * privileged role wins all hierarchical checks.
46
+ *
47
+ * Returns `Infinity` for users with no roles (denies all level-gated checks).
48
+ *
49
+ * @param {object|null} user
50
+ * @returns {number}
51
+ */
52
+ export function getEffectiveLevel(user) {
53
+ const roles = getEffectiveRoles(user);
54
+ if (!roles.length) return Infinity;
55
+ return Math.min(...roles.map(r => getRoleLevel(r)));
56
+ }
57
+
58
+ /**
59
+ * Check whether a user holds a specific role (primary or additional).
60
+ * Exact-name match — does NOT walk the hierarchy. For hierarchical "user
61
+ * is at least this level" checks use `getEffectiveLevel` against the target
62
+ * role's level instead.
63
+ *
64
+ * @param {object|null} user
65
+ * @param {string} role
66
+ * @returns {boolean}
67
+ */
68
+ export function userHasRole(user, role) {
69
+ if (!user || !role) return false;
70
+ return getEffectiveRoles(user).includes(role);
71
+ }
72
+
73
+ /**
74
+ * Check whether a user holds ANY of the given roles. Convenience helper
75
+ * for "anyone in this set can do X" patterns — equivalent to OR-ing
76
+ * `userHasRole` over the list.
77
+ *
78
+ * @param {object|null} user
79
+ * @param {string[]} roles
80
+ * @returns {boolean}
81
+ */
82
+ export function userHasAnyRole(user, roles) {
83
+ if (!user || !Array.isArray(roles) || !roles.length) return false;
84
+ const userRoles = new Set(getEffectiveRoles(user));
85
+ return roles.some(r => userRoles.has(r));
86
+ }
@@ -9,6 +9,7 @@ import bcrypt from 'bcryptjs';
9
9
  import {v4 as uuidv4} from 'uuid';
10
10
  import {config} from '../config.js';
11
11
  import {deleteProfile, ensureProfile} from './userProfiles.js';
12
+ import * as cache from './cache/index.js';
12
13
 
13
14
  const USERS_DIR = path.resolve(config.content.usersDir);
14
15
 
@@ -122,7 +123,7 @@ export async function getUserByEmail(email) {
122
123
  * @param {object} data - { name, email, password, role }
123
124
  * @returns {Promise<object>} Created user (password stripped)
124
125
  */
125
- export async function createUser({ name, email, password, role = 'user' }) {
126
+ export async function createUser({ name, email, password, role = 'user', additionalRoles = [], meta, projects }) {
126
127
  const existing = await getUserByEmail(email);
127
128
  if (existing) throw new Error('A user with that email already exists');
128
129
 
@@ -134,10 +135,19 @@ export async function createUser({ name, email, password, role = 'user' }) {
134
135
  name: name.trim(),
135
136
  password: hash,
136
137
  role,
138
+ // Additional roles beyond the primary — permission/visibility checks
139
+ // grant access if ANY role satisfies. The primary `role` is what
140
+ // appears in UI badges and is used for hierarchical level comparisons
141
+ // when a single value is needed.
142
+ additionalRoles: Array.isArray(additionalRoles) ? additionalRoles.filter(r => r && r !== role) : [],
143
+ // Project access-scope: when non-empty, the user is restricted to artefacts
144
+ // tagged with one of these project slugs (see canSeeArtefact in projects.js).
145
+ projects: Array.isArray(projects) ? projects.filter(Boolean) : [],
137
146
  isActive: true,
138
147
  createdAt: now,
139
148
  updatedAt: now,
140
- lastLogin: null
149
+ lastLogin: null,
150
+ ...(meta != null && {meta})
141
151
  };
142
152
 
143
153
  await writeUserFile(user);
@@ -169,6 +179,17 @@ export async function updateUser(id, updates) {
169
179
  };
170
180
 
171
181
  await writeUserFile(updated);
182
+
183
+ // Invalidate per-user cache when the project access-scope changes — downstream
184
+ // request handlers cache scope-filtered responses keyed by `user:<id>`.
185
+ if (updates.projects !== undefined) {
186
+ const before = JSON.stringify(user.projects || []);
187
+ const after = JSON.stringify(updated.projects || []);
188
+ if (before !== after) {
189
+ await cache.invalidateTags([`user:${id}`]);
190
+ }
191
+ }
192
+
172
193
  return stripPassword(updated);
173
194
  }
174
195
 
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import {v4 as uuidv4} from 'uuid';
16
16
  import {buildRowLevelMatch} from './rowAccess.js';
17
+ import * as cache from './cache/index.js';
17
18
 
18
19
  /** MongoDB collection where view configs are stored. */
19
20
  const VIEWS_COLLECTION = 'cms__views';
@@ -136,7 +137,7 @@ export async function getView(slug) {
136
137
  */
137
138
  export async function createView(data, userId = null) {
138
139
  const db = await getMetaDb();
139
- const { title, description = '', connection = 'default', pipeline, display, access } = data;
140
+ const { title, description = '', connection = 'default', pipeline, display, access, meta: inputMeta } = data;
140
141
 
141
142
  if (!title) throw new Error('title is required');
142
143
  if (!pipeline?.source) throw new Error('pipeline.source is required');
@@ -169,10 +170,16 @@ export async function createView(data, userId = null) {
169
170
  public: access?.public || false,
170
171
  rowLevel: access?.rowLevel || null
171
172
  },
172
- meta: { createdAt: now, updatedAt: now, createdBy: userId }
173
+ meta: {
174
+ createdAt: now,
175
+ updatedAt: now,
176
+ createdBy: userId,
177
+ ...(inputMeta?.project != null && {project: inputMeta.project})
178
+ }
173
179
  };
174
180
 
175
181
  await db.collection(VIEWS_COLLECTION).insertOne({ ...view });
182
+ await cache.invalidateTags([`view:${slug}`]);
176
183
  return view;
177
184
  }
178
185
 
@@ -192,7 +199,7 @@ export async function updateView(slug, data) {
192
199
  const stages = data.pipeline?.stages ?? existing.pipeline?.stages ?? [];
193
200
  validateStages(stages);
194
201
 
195
- const { _id, id, meta, ...rest } = data;
202
+ const { _id, id, meta: inputMeta, ...rest } = data;
196
203
  const updated = {
197
204
  ...existing,
198
205
  ...rest,
@@ -202,10 +209,17 @@ export async function updateView(slug, data) {
202
209
  ...data.pipeline,
203
210
  stages
204
211
  },
205
- meta: { ...existing.meta, updatedAt: new Date().toISOString() }
212
+ meta: {
213
+ ...existing.meta,
214
+ ...(inputMeta && typeof inputMeta === 'object'
215
+ ? ('project' in inputMeta ? {project: inputMeta.project} : {})
216
+ : {}),
217
+ updatedAt: new Date().toISOString()
218
+ }
206
219
  };
207
220
 
208
221
  await db.collection(VIEWS_COLLECTION).replaceOne({ slug }, { ...updated });
222
+ await cache.invalidateTags([`view:${slug}`]);
209
223
  const { _id: _stripped, ...result } = updated;
210
224
  return result;
211
225
  }
@@ -221,6 +235,7 @@ export async function deleteView(slug) {
221
235
  const db = await getMetaDb();
222
236
  const result = await db.collection(VIEWS_COLLECTION).deleteOne({ slug });
223
237
  if (result.deletedCount === 0) throw new Error(`View "${slug}" not found`);
238
+ await cache.invalidateTags([`view:${slug}`]);
224
239
  }
225
240
 
226
241
  // ---------------------------------------------------------------------------
@@ -1,130 +1,135 @@
1
- <!DOCTYPE html>
2
- <html lang="en-GB">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>{{seoTitle}}</title>
7
- <meta name="description" content="{{seoDescription}}">
8
- {{#if ogImage}}<meta property="og:image" content="{{ogImage}}">{{/if}}
9
-
10
- <!-- Fonts -->
11
- {{#if fontLink}}{{fontLink}}{{/if}}
12
-
13
- <!-- DommaJS CSS -->
14
- <link rel="stylesheet" href="/dist/domma/domma.css">
15
- <link rel="stylesheet" href="/dist/domma/grid.css">
16
- <link rel="stylesheet" href="/dist/domma/elements.css">
17
- <link rel="stylesheet" href="/dist/domma/themes/domma-themes.css">
18
-
19
- <!-- Site CSS -->
20
- <link rel="stylesheet" href="/public/css/site.css">
21
- <link rel="stylesheet" href="/public/css/forms.css">
22
-
23
- <!-- Font overrides -->
24
- {{fontStyleTag}}
25
-
26
- <!-- Plugin head injection -->
27
- {{headInject}}
28
-
29
- <!-- Late head injection — custom CSS always loads last so it can override everything -->
30
- {{headInjectLate}}
31
- </head>
32
- <body class="dm-cloaked dm-theme-{{theme}} {{layoutBodyClass}}" data-layout="{{layout}}">
33
-
34
- {{#if showNavbar}}
35
- <nav id="site-navbar"></nav>
36
- {{/if}}
37
-
38
- <main class="site-main {{#if showSidebar}}with-sidebar{{/if}}">
39
- {{#if showSidebar}}
40
- <aside id="site-sidebar" class="site-sidebar"></aside>
41
- {{/if}}
42
-
43
- <article class="site-content">
44
- <div class="container">
45
- {{breadcrumbsHtml}}
46
- <div class="page-body"{{#if pageBodyStyle}} style="{{pageBodyStyle}}"{{/if}}>
47
- {{html}}
48
- </div>
49
- </div>
50
- </article>
51
- </main>
52
-
53
- {{#if showFooter}}
54
- <footer id="site-footer" class="page-footer"></footer>
55
- {{/if}}
56
-
57
- <!-- DOMPurify - must load before DommaJS -->
58
- <script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
59
-
60
- <!-- DommaJS -->
61
- <script src="/dist/domma/domma.min.js"></script>
62
-
63
- <!-- Initialise DommaJS before module loads -->
64
- <script>
65
- (function () {
66
- var _stored;
67
- try {
68
- _stored = JSON.parse(localStorage.getItem('domma:reduced_motion'));
69
- } catch (e) {
70
- }
71
- if (_stored === true) {
72
- document.documentElement.classList.add('dm-reduced-motion');
73
- }
74
- // Override window.matchMedia so Domma JS effects (scribe, breathe, etc.)
75
- // respect the stored preference. When explicitly set to false the user is
76
- // overriding the OS "reduce" preference to allow motion on this site.
77
- if (_stored !== null && _stored !== undefined && window.matchMedia) {
78
- var _orig = window.matchMedia.bind(window);
79
- window.matchMedia = function (q) {
80
- if (q === '(prefers-reduced-motion: reduce)') {
81
- return {
82
- matches: !!_stored, media: q, onchange: null,
83
- addListener: function () {
84
- }, removeListener: function () {
85
- },
86
- addEventListener: function () {
87
- }, removeEventListener: function () {
88
- },
89
- dispatchEvent: function () {
90
- return false;
91
- }
92
- };
93
- }
94
- return _orig(q);
95
- };
96
- }
97
- }());
98
- if (window.Domma && typeof window.Domma.init === 'function') {
99
- window.Domma.init();
100
- }
101
- if (window.Domma && window.Domma.theme) {
102
- window.Domma.theme.init({ theme: '{{theme}}', persist: false });
103
- }
104
- window.__CMS_NAV__ = {{navJson}};
105
- window.__CMS_SITE__ = {{siteJson}};
106
- (function () {
107
- var c = window.__CMS_SITE__ && window.__CMS_SITE__.autoTheme;
108
- if (!c || !c.enabled) return;
109
- var n = new Date(), m = n.getHours() * 60 + n.getMinutes(), ds = (c.dayStart || "07:00").split(":"),
110
- ns = (c.nightStart || "19:00").split(":"), d = +ds[0] * 60 + (+ds[1] || 0),
111
- e = +ns[0] * 60 + (+ns[1] || 0), t = (m >= d && m < e) ? c.dayTheme : c.nightTheme;
112
- if (window.Domma && window.Domma.theme) window.Domma.theme.set(t);
113
- }());
114
- {{dconfigScript}}
115
- </script>
116
-
117
- <!-- Site initialisation -->
118
- <script src="/public/js/site.js" type="module"></script>
119
-
120
- <!-- Core Forms (logic engine must load before renderer) -->
121
- <script src="/public/js/form-logic-engine.js"></script>
122
- <script src="/public/js/forms.js" type="module"></script>
123
-
124
- <!-- Core effects runtime -->
125
- <script src="/public/js/effects.js"></script>
126
-
127
- <!-- Plugin body-end injection -->
128
- {{bodyEndInject}}
129
- </body>
130
- </html>
1
+ <!DOCTYPE html>
2
+ <html lang="en-GB">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{{seoTitle}}</title>
7
+ <meta name="description" content="{{seoDescription}}">
8
+ {{seoTags}}
9
+
10
+ <!-- Fonts -->
11
+ {{#if fontLink}}{{fontLink}}{{/if}}
12
+
13
+ <!-- DommaJS CSS -->
14
+ <link rel="stylesheet" href="/dist/domma/domma.css">
15
+ <link rel="stylesheet" href="/dist/domma/grid.css">
16
+ <link rel="stylesheet" href="/dist/domma/elements.css">
17
+ <link rel="stylesheet" href="/dist/domma/themes/domma-themes.css">
18
+
19
+ <!-- Site CSS -->
20
+ <link rel="stylesheet" href="/public/css/site.css">
21
+ <link rel="stylesheet" href="/public/css/forms.css">
22
+
23
+ <!-- Font overrides -->
24
+ {{fontStyleTag}}
25
+
26
+ <!-- Plugin head injection -->
27
+ {{headInject}}
28
+
29
+ <!-- Late head injection — custom CSS always loads last so it can override everything -->
30
+ {{headInjectLate}}
31
+ </head>
32
+ <body class="dm-cloaked dm-theme-{{theme}} {{layoutBodyClass}}" data-layout="{{layout}}">
33
+
34
+ {{#if showNavbar}}
35
+ <nav id="site-navbar"></nav>
36
+ {{/if}}
37
+
38
+ <main class="site-main {{#if showSidebar}}with-sidebar{{/if}}">
39
+ {{#if showSidebar}}
40
+ <aside id="site-sidebar" class="site-sidebar"></aside>
41
+ {{/if}}
42
+
43
+ <article class="site-content">
44
+ <div class="container">
45
+ {{breadcrumbsHtml}}
46
+ <div class="page-body"{{#if pageBodyStyle}} style="{{pageBodyStyle}}"{{/if}}>
47
+ {{html}}
48
+ </div>
49
+ </div>
50
+ </article>
51
+ </main>
52
+
53
+ {{#if showFooter}}
54
+ <footer id="site-footer" class="page-footer"></footer>
55
+ {{/if}}
56
+
57
+ <!-- DOMPurify - must load before DommaJS -->
58
+ <script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
59
+
60
+ <!-- DommaJS -->
61
+ <script src="/dist/domma/domma.min.js"></script>
62
+
63
+ <!-- Initialise DommaJS before module loads -->
64
+ <script>
65
+ (function () {
66
+ var _stored;
67
+ try {
68
+ _stored = JSON.parse(localStorage.getItem('domma:reduced_motion'));
69
+ } catch (e) {
70
+ }
71
+ if (_stored === true) {
72
+ document.documentElement.classList.add('dm-reduced-motion');
73
+ }
74
+ // Override window.matchMedia so Domma JS effects (scribe, breathe, etc.)
75
+ // respect the stored preference. When explicitly set to false the user is
76
+ // overriding the OS "reduce" preference to allow motion on this site.
77
+ if (_stored !== null && _stored !== undefined && window.matchMedia) {
78
+ var _orig = window.matchMedia.bind(window);
79
+ window.matchMedia = function (q) {
80
+ if (q === '(prefers-reduced-motion: reduce)') {
81
+ return {
82
+ matches: !!_stored, media: q, onchange: null,
83
+ addListener: function () {
84
+ }, removeListener: function () {
85
+ },
86
+ addEventListener: function () {
87
+ }, removeEventListener: function () {
88
+ },
89
+ dispatchEvent: function () {
90
+ return false;
91
+ }
92
+ };
93
+ }
94
+ return _orig(q);
95
+ };
96
+ }
97
+ }());
98
+ if (window.Domma && typeof window.Domma.init === 'function') {
99
+ window.Domma.init();
100
+ }
101
+ if (window.Domma && window.Domma.theme) {
102
+ window.Domma.theme.init({ theme: '{{theme}}', persist: false });
103
+ }
104
+ window.__CMS_NAV__ = {{navJson}};
105
+ window.__CMS_SITE__ = {{siteJson}};
106
+ {{footerScript}}
107
+ (function () {
108
+ var c = window.__CMS_SITE__ && window.__CMS_SITE__.autoTheme;
109
+ if (!c || !c.enabled) return;
110
+ var n = new Date(), m = n.getHours() * 60 + n.getMinutes(), ds = (c.dayStart || "07:00").split(":"),
111
+ ns = (c.nightStart || "19:00").split(":"), d = +ds[0] * 60 + (+ds[1] || 0),
112
+ e = +ns[0] * 60 + (+ns[1] || 0), t = (m >= d && m < e) ? c.dayTheme : c.nightTheme;
113
+ if (window.Domma && window.Domma.theme) window.Domma.theme.set(t);
114
+ }());
115
+ {{dconfigScript}}
116
+ </script>
117
+
118
+ <!-- Site initialisation -->
119
+ <script src="/public/js/site.js?v=20260524-formerror" type="module"></script>
120
+
121
+ <!-- Interactive Collection Browser (used by [collection] shortcodes with
122
+ searchable / filterable / sortable / paginate attributes) -->
123
+ <script src="/public/js/collection-browser.js?v=20260523-browser"></script>
124
+
125
+ <!-- Core Forms (logic engine must load before renderer) -->
126
+ <script src="/public/js/form-logic-engine.js?v=20260509-chooser"></script>
127
+ <script src="/public/js/forms.js?v=20260509-chooser" type="module"></script>
128
+
129
+ <!-- Core effects runtime -->
130
+ <script src="/public/js/effects.js"></script>
131
+
132
+ <!-- Plugin body-end injection -->
133
+ {{bodyEndInject}}
134
+ </body>
135
+ </html>
File without changes