domma-cms 0.25.15 → 0.30.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 (67) hide show
  1. package/admin/js/app.js +2 -2
  2. package/admin/js/lib/sidebar-renderer.js +4 -4
  3. package/admin/js/templates/api-reference.html +68 -1233
  4. package/admin/js/templates/docs/api-actions.html +146 -0
  5. package/admin/js/templates/docs/api-authentication.html +202 -0
  6. package/admin/js/templates/docs/api-collections.html +347 -0
  7. package/admin/js/templates/docs/api-layouts.html +123 -0
  8. package/admin/js/templates/docs/api-media.html +158 -0
  9. package/admin/js/templates/docs/api-navigation.html +99 -0
  10. package/admin/js/templates/docs/api-pages.html +214 -0
  11. package/admin/js/templates/docs/api-plugins.html +136 -0
  12. package/admin/js/templates/docs/api-settings.html +148 -0
  13. package/admin/js/templates/docs/api-users.html +189 -0
  14. package/admin/js/templates/docs/api-views.html +142 -0
  15. package/admin/js/templates/docs/components-howto.html +175 -0
  16. package/admin/js/templates/docs/components-reference.html +259 -0
  17. package/admin/js/templates/docs/components-rules.html +167 -0
  18. package/admin/js/templates/docs/components-walkthrough.html +167 -0
  19. package/admin/js/templates/docs/tutorial-crud.html +344 -0
  20. package/admin/js/templates/docs/tutorial-forms.html +93 -0
  21. package/admin/js/templates/docs/tutorial-plugin.html +234 -0
  22. package/admin/js/templates/docs/usage-actions.html +122 -0
  23. package/admin/js/templates/docs/usage-cta-shortcode.html +124 -0
  24. package/admin/js/templates/docs/usage-dconfig.html +148 -0
  25. package/admin/js/templates/docs/usage-media.html +25 -0
  26. package/admin/js/templates/docs/usage-navigation.html +31 -0
  27. package/admin/js/templates/docs/usage-pages.html +68 -0
  28. package/admin/js/templates/docs/usage-plugins.html +36 -0
  29. package/admin/js/templates/docs/usage-shortcodes.html +810 -0
  30. package/admin/js/templates/docs/usage-site-settings.html +52 -0
  31. package/admin/js/templates/docs/usage-users-roles.html +84 -0
  32. package/admin/js/templates/docs/usage-views.html +105 -0
  33. package/admin/js/templates/documentation.html +60 -1522
  34. package/admin/js/templates/effects.html +752 -752
  35. package/admin/js/templates/forms.html +17 -17
  36. package/admin/js/templates/my-profile.html +17 -17
  37. package/admin/js/templates/project-settings.html +7 -0
  38. package/admin/js/templates/role-editor.html +70 -70
  39. package/admin/js/templates/roles.html +10 -10
  40. package/admin/js/templates/tutorials.html +46 -659
  41. package/admin/js/views/api-reference.js +1 -1
  42. package/admin/js/views/doc-pages.js +1 -0
  43. package/admin/js/views/documentation.js +1 -1
  44. package/admin/js/views/index.js +1 -1
  45. package/admin/js/views/project-settings.js +1 -1
  46. package/admin/js/views/projects.js +3 -7
  47. package/admin/js/views/tutorials.js +1 -1
  48. package/bin/lib/config-merge.js +44 -44
  49. package/config/connections.json.bak +9 -0
  50. package/config/menus/admin-sidebar.json +76 -6
  51. package/package.json +1 -1
  52. package/public/js/site.js +1 -1
  53. package/scripts/migrate-docs.js +280 -0
  54. package/server/middleware/auth.js +253 -253
  55. package/server/routes/api/auth.js +309 -309
  56. package/server/routes/api/collections.js +10 -1
  57. package/server/routes/api/navigation.js +42 -42
  58. package/server/routes/api/projects.js +9 -1
  59. package/server/routes/api/settings.js +141 -141
  60. package/server/routes/public.js +9 -0
  61. package/server/server.js +6 -2
  62. package/server/services/email.js +167 -167
  63. package/server/services/presetCollections.js +1 -0
  64. package/server/services/projects.js +72 -3
  65. package/server/services/sidebar-migration.js +145 -3
  66. package/server/services/userProfiles.js +199 -199
  67. package/server/services/users.js +302 -302
@@ -1,167 +1,167 @@
1
- /**
2
- * Core Email Utility
3
- * Nodemailer transport factory and generic form email sending utility.
4
- */
5
- import nodemailer from 'nodemailer';
6
-
7
- let lastSendResult = null;
8
-
9
- /**
10
- * Return the most recent email send result, or null if no send has occurred.
11
- *
12
- * @returns {{ ok: boolean, at: string, info: string|null } | null}
13
- */
14
- export function getLastSendResult() {
15
- return lastSendResult;
16
- }
17
-
18
- /**
19
- * Record the outcome of an email send for health reporting.
20
- *
21
- * @param {boolean} ok
22
- * @param {string|null} info
23
- * @returns {void}
24
- */
25
- function recordSendResult(ok, info) {
26
- lastSendResult = {
27
- ok,
28
- at: new Date().toISOString(),
29
- info: info || null
30
- };
31
- }
32
-
33
- /**
34
- * Escape HTML special characters for safe use in email bodies.
35
- *
36
- * @param {string} str
37
- * @returns {string}
38
- */
39
- export function escapeHtml(str) {
40
- return String(str)
41
- .replace(/&/g, '&')
42
- .replace(/</g, '&lt;')
43
- .replace(/>/g, '&gt;')
44
- .replace(/"/g, '&quot;')
45
- .replace(/'/g, '&#39;');
46
- }
47
-
48
- /**
49
- * Create a nodemailer transport.
50
- * Falls back to an Ethereal test account when no SMTP host is configured.
51
- *
52
- * @param {{ host: string, port: number, secure: boolean, user: string, pass: string }} smtp
53
- * @returns {Promise<import('nodemailer').Transporter>}
54
- * @throws {Error} If Ethereal test account creation fails.
55
- */
56
- export async function createTransport(smtp) {
57
- if (smtp?.host) {
58
- return nodemailer.createTransport({
59
- host: smtp.host,
60
- port: smtp.port || 587,
61
- secure: smtp.secure || false,
62
- auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : undefined,
63
- tls: { rejectUnauthorized: false }
64
- });
65
- }
66
-
67
- // No SMTP configured — use Ethereal for dev/demo
68
- const testAccount = await nodemailer.createTestAccount();
69
- console.log('[email] No SMTP configured. Using Ethereal test account:', testAccount.user);
70
- return nodemailer.createTransport({
71
- host: 'smtp.ethereal.email',
72
- port: 587,
73
- secure: false,
74
- auth: { user: testAccount.user, pass: testAccount.pass }
75
- });
76
- }
77
-
78
- /**
79
- * Send a generic transactional email.
80
- *
81
- * @param {import('nodemailer').Transporter} transport
82
- * @param {{ from: string, fromName: string, to: string, subject: string, html: string, text: string }} opts
83
- * @returns {Promise<void>}
84
- * @throws {Error} If sending the email fails.
85
- */
86
- export async function sendEmail(transport, {from, fromName, to, subject, html, text}) {
87
- try {
88
- const info = await transport.sendMail({
89
- from: `"${fromName}" <${from}>`,
90
- to,
91
- subject,
92
- text,
93
- html
94
- });
95
- recordSendResult(true, info.messageId);
96
-
97
- const previewUrl = nodemailer.getTestMessageUrl(info);
98
- if (previewUrl) {
99
- console.log('[email] Preview URL:', previewUrl);
100
- }
101
- return info;
102
- } catch (err) {
103
- recordSendResult(false, err.message);
104
- throw err;
105
- }
106
- }
107
-
108
- /**
109
- * Send an HTML + plain-text form submission notification email.
110
- * Builds a generic table of field→value pairs from the submitted data.
111
- *
112
- * @param {import('nodemailer').Transporter} transport
113
- * @param {{ from: string, fromName: string, to: string, subject: string, formTitle: string, fields: Array<{name: string, label: string}>, data: Record<string, unknown> }} opts
114
- * @returns {Promise<void>}
115
- * @throws {Error} If sending the email fails.
116
- */
117
- export async function sendFormEmail(transport, { from, fromName, to, subject, formTitle, fields, data }) {
118
- const rows = fields.map(field => {
119
- const val = data[field.name] ?? '';
120
- const safe = escapeHtml(String(val)).replace(/\n/g, '<br>');
121
- const safeLabel = escapeHtml(field.label || field.name);
122
- return `
123
- <tr>
124
- <td style="padding:8px 12px;font-weight:600;background:#f9f9f9;border:1px solid #eee;white-space:nowrap;vertical-align:top;">${safeLabel}</td>
125
- <td style="padding:8px 12px;border:1px solid #eee;vertical-align:top;">${safe}</td>
126
- </tr>`.trim();
127
- }).join('\n');
128
-
129
- const plainRows = fields.map(field => {
130
- const val = data[field.name] ?? '';
131
- return `${field.label || field.name}: ${val}`;
132
- }).join('\n');
133
-
134
- const html = `
135
- <!DOCTYPE html>
136
- <html>
137
- <body style="font-family:sans-serif;max-width:640px;margin:0 auto;padding:20px;">
138
- <h2 style="color:#333;margin-bottom:4px;">New Form Submission</h2>
139
- <p style="color:#888;margin-top:0;font-size:.9rem;">${escapeHtml(formTitle)}</p>
140
- <table style="width:100%;border-collapse:collapse;margin-top:16px;">
141
- ${rows}
142
- </table>
143
- </body>
144
- </html>`.trim();
145
-
146
- const text = `New form submission: ${formTitle}\n\n${plainRows}`;
147
-
148
- try {
149
- const info = await transport.sendMail({
150
- from: `"${fromName}" <${from}>`,
151
- to,
152
- subject,
153
- text,
154
- html
155
- });
156
- recordSendResult(true, info.messageId);
157
-
158
- const previewUrl = nodemailer.getTestMessageUrl(info);
159
- if (previewUrl) {
160
- console.log('[email] Preview URL:', previewUrl);
161
- }
162
- return info;
163
- } catch (err) {
164
- recordSendResult(false, err.message);
165
- throw err;
166
- }
167
- }
1
+ /**
2
+ * Core Email Utility
3
+ * Nodemailer transport factory and generic form email sending utility.
4
+ */
5
+ import nodemailer from 'nodemailer';
6
+
7
+ let lastSendResult = null;
8
+
9
+ /**
10
+ * Return the most recent email send result, or null if no send has occurred.
11
+ *
12
+ * @returns {{ ok: boolean, at: string, info: string|null } | null}
13
+ */
14
+ export function getLastSendResult() {
15
+ return lastSendResult;
16
+ }
17
+
18
+ /**
19
+ * Record the outcome of an email send for health reporting.
20
+ *
21
+ * @param {boolean} ok
22
+ * @param {string|null} info
23
+ * @returns {void}
24
+ */
25
+ function recordSendResult(ok, info) {
26
+ lastSendResult = {
27
+ ok,
28
+ at: new Date().toISOString(),
29
+ info: info || null
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Escape HTML special characters for safe use in email bodies.
35
+ *
36
+ * @param {string} str
37
+ * @returns {string}
38
+ */
39
+ export function escapeHtml(str) {
40
+ return String(str)
41
+ .replace(/&/g, '&amp;')
42
+ .replace(/</g, '&lt;')
43
+ .replace(/>/g, '&gt;')
44
+ .replace(/"/g, '&quot;')
45
+ .replace(/'/g, '&#39;');
46
+ }
47
+
48
+ /**
49
+ * Create a nodemailer transport.
50
+ * Falls back to an Ethereal test account when no SMTP host is configured.
51
+ *
52
+ * @param {{ host: string, port: number, secure: boolean, user: string, pass: string }} smtp
53
+ * @returns {Promise<import('nodemailer').Transporter>}
54
+ * @throws {Error} If Ethereal test account creation fails.
55
+ */
56
+ export async function createTransport(smtp) {
57
+ if (smtp?.host) {
58
+ return nodemailer.createTransport({
59
+ host: smtp.host,
60
+ port: smtp.port || 587,
61
+ secure: smtp.secure || false,
62
+ auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : undefined,
63
+ tls: { rejectUnauthorized: false }
64
+ });
65
+ }
66
+
67
+ // No SMTP configured — use Ethereal for dev/demo
68
+ const testAccount = await nodemailer.createTestAccount();
69
+ console.log('[email] No SMTP configured. Using Ethereal test account:', testAccount.user);
70
+ return nodemailer.createTransport({
71
+ host: 'smtp.ethereal.email',
72
+ port: 587,
73
+ secure: false,
74
+ auth: { user: testAccount.user, pass: testAccount.pass }
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Send a generic transactional email.
80
+ *
81
+ * @param {import('nodemailer').Transporter} transport
82
+ * @param {{ from: string, fromName: string, to: string, subject: string, html: string, text: string }} opts
83
+ * @returns {Promise<void>}
84
+ * @throws {Error} If sending the email fails.
85
+ */
86
+ export async function sendEmail(transport, {from, fromName, to, subject, html, text}) {
87
+ try {
88
+ const info = await transport.sendMail({
89
+ from: `"${fromName}" <${from}>`,
90
+ to,
91
+ subject,
92
+ text,
93
+ html
94
+ });
95
+ recordSendResult(true, info.messageId);
96
+
97
+ const previewUrl = nodemailer.getTestMessageUrl(info);
98
+ if (previewUrl) {
99
+ console.log('[email] Preview URL:', previewUrl);
100
+ }
101
+ return info;
102
+ } catch (err) {
103
+ recordSendResult(false, err.message);
104
+ throw err;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Send an HTML + plain-text form submission notification email.
110
+ * Builds a generic table of field→value pairs from the submitted data.
111
+ *
112
+ * @param {import('nodemailer').Transporter} transport
113
+ * @param {{ from: string, fromName: string, to: string, subject: string, formTitle: string, fields: Array<{name: string, label: string}>, data: Record<string, unknown> }} opts
114
+ * @returns {Promise<void>}
115
+ * @throws {Error} If sending the email fails.
116
+ */
117
+ export async function sendFormEmail(transport, { from, fromName, to, subject, formTitle, fields, data }) {
118
+ const rows = fields.map(field => {
119
+ const val = data[field.name] ?? '';
120
+ const safe = escapeHtml(String(val)).replace(/\n/g, '<br>');
121
+ const safeLabel = escapeHtml(field.label || field.name);
122
+ return `
123
+ <tr>
124
+ <td style="padding:8px 12px;font-weight:600;background:#f9f9f9;border:1px solid #eee;white-space:nowrap;vertical-align:top;">${safeLabel}</td>
125
+ <td style="padding:8px 12px;border:1px solid #eee;vertical-align:top;">${safe}</td>
126
+ </tr>`.trim();
127
+ }).join('\n');
128
+
129
+ const plainRows = fields.map(field => {
130
+ const val = data[field.name] ?? '';
131
+ return `${field.label || field.name}: ${val}`;
132
+ }).join('\n');
133
+
134
+ const html = `
135
+ <!DOCTYPE html>
136
+ <html>
137
+ <body style="font-family:sans-serif;max-width:640px;margin:0 auto;padding:20px;">
138
+ <h2 style="color:#333;margin-bottom:4px;">New Form Submission</h2>
139
+ <p style="color:#888;margin-top:0;font-size:.9rem;">${escapeHtml(formTitle)}</p>
140
+ <table style="width:100%;border-collapse:collapse;margin-top:16px;">
141
+ ${rows}
142
+ </table>
143
+ </body>
144
+ </html>`.trim();
145
+
146
+ const text = `New form submission: ${formTitle}\n\n${plainRows}`;
147
+
148
+ try {
149
+ const info = await transport.sendMail({
150
+ from: `"${fromName}" <${from}>`,
151
+ to,
152
+ subject,
153
+ text,
154
+ html
155
+ });
156
+ recordSendResult(true, info.messageId);
157
+
158
+ const previewUrl = nodemailer.getTestMessageUrl(info);
159
+ if (previewUrl) {
160
+ console.log('[email] Preview URL:', previewUrl);
161
+ }
162
+ return info;
163
+ } catch (err) {
164
+ recordSendResult(false, err.message);
165
+ throw err;
166
+ }
167
+ }
@@ -51,6 +51,7 @@ const PRESETS = [
51
51
  {name: 'icon', label: 'Icon', type: 'text', default: 'folder'},
52
52
  {name: 'rootUrl', label: 'Root URL', type: 'text'},
53
53
  {name: 'sortOrder', label: 'Sort Order', type: 'number', default: 0},
54
+ {name: 'enabled', label: 'Enabled', type: 'boolean', default: true},
54
55
  {name: 'protected', label: 'Protected', type: 'boolean', default: false}
55
56
  ],
56
57
  api: {
@@ -36,6 +36,23 @@ const CORE_PROJECT_SEED = {
36
36
  icon: 'home',
37
37
  rootUrl: '/',
38
38
  sortOrder: -1,
39
+ enabled: true,
40
+ protected: true
41
+ };
42
+
43
+ /** Slug of the built-in, disabled-by-default documentation project. */
44
+ export const DOCS_PROJECT_SLUG = 'domma-docs';
45
+
46
+ /** Seed record for the Domma Docs project (created at boot if absent). */
47
+ const DOCS_PROJECT_SEED = {
48
+ slug: DOCS_PROJECT_SLUG,
49
+ name: 'Domma Docs',
50
+ description: 'The Domma CMS handbook — guides, tutorials, component & API ' +
51
+ 'reference. Switch on to publish it on your own site.',
52
+ icon: 'document',
53
+ rootUrl: '/domma-docs',
54
+ sortOrder: 0,
55
+ enabled: false,
39
56
  protected: true
40
57
  };
41
58
 
@@ -105,6 +122,7 @@ export async function createProject(input) {
105
122
  icon: input.icon || 'folder',
106
123
  ...(input.rootUrl != null && {rootUrl: input.rootUrl}),
107
124
  sortOrder: Number.isFinite(input.sortOrder) ? input.sortOrder : 0,
125
+ enabled: input.enabled !== false,
108
126
  ...(typeof input.createdBy === 'string' && input.createdBy.trim() && {createdBy: input.createdBy.trim()}),
109
127
  createdAt: now,
110
128
  updatedAt: now
@@ -127,8 +145,9 @@ export async function seedCoreProject() {
127
145
  const {entries} = await listEntries(PROJECTS_COLLECTION_SLUG, {limit: 0});
128
146
  const existing = entries.find(e => e.data?.slug === CORE_PROJECT_SLUG);
129
147
  if (existing) {
130
- if (existing.data.protected === true) return normalise(existing);
131
- const adopted = {...existing.data, protected: true, updatedAt: new Date().toISOString()};
148
+ // Core is always protected and always enabled — adopt both invariants.
149
+ if (existing.data.protected === true && existing.data.enabled !== false) return normalise(existing);
150
+ const adopted = {...existing.data, protected: true, enabled: true, updatedAt: new Date().toISOString()};
132
151
  await updateEntry(PROJECTS_COLLECTION_SLUG, existing.id, adopted);
133
152
  return adopted;
134
153
  }
@@ -138,6 +157,24 @@ export async function seedCoreProject() {
138
157
  return data;
139
158
  }
140
159
 
160
+ /**
161
+ * Seed the built-in Domma Docs project at boot. Create-if-absent by slug — an
162
+ * existing record (including the admin's enabled/disabled choice and any edits)
163
+ * is returned untouched. Disabled by default: a fresh site ships with the docs
164
+ * off until an admin enables the project.
165
+ *
166
+ * @returns {Promise<object>} The docs project record.
167
+ */
168
+ export async function seedDocsProject() {
169
+ const {entries} = await listEntries(PROJECTS_COLLECTION_SLUG, {limit: 0});
170
+ const existing = entries.find(e => e.data?.slug === DOCS_PROJECT_SLUG);
171
+ if (existing) return normalise(existing);
172
+ const now = new Date().toISOString();
173
+ const data = {...DOCS_PROJECT_SEED, createdAt: now, updatedAt: now};
174
+ await createEntry(PROJECTS_COLLECTION_SLUG, data);
175
+ return data;
176
+ }
177
+
141
178
  /**
142
179
  * Update a project. Slug is immutable. Preserves createdAt; bumps updatedAt.
143
180
  * The core project's `rootUrl` and `protected` flag are locked; its name,
@@ -159,6 +196,9 @@ export async function updateProject(slug, input) {
159
196
  if (input.protected != null && input.protected !== existing.protected) {
160
197
  throw new Error("Cannot change the core project's protected flag");
161
198
  }
199
+ if (input.enabled === false) {
200
+ throw new Error('Cannot disable the core project');
201
+ }
162
202
  }
163
203
  const merged = {
164
204
  ...existing,
@@ -167,6 +207,7 @@ export async function updateProject(slug, input) {
167
207
  ...(input.icon != null && {icon: input.icon}),
168
208
  ...(input.rootUrl != null && {rootUrl: input.rootUrl}),
169
209
  ...(Number.isFinite(input.sortOrder) && {sortOrder: input.sortOrder}),
210
+ ...(typeof input.enabled === 'boolean' && {enabled: input.enabled}),
170
211
  slug,
171
212
  createdAt: existing.createdAt,
172
213
  updatedAt: new Date().toISOString()
@@ -196,6 +237,9 @@ export async function deleteProject(slug) {
196
237
  const {entries} = await listEntries(PROJECTS_COLLECTION_SLUG, {limit: 0});
197
238
  const entry = entries.find(e => e.data?.slug === slug);
198
239
  if (!entry) return false;
240
+ if (entry.data?.protected === true) {
241
+ throw new Error(`Cannot delete the protected "${slug}" project`);
242
+ }
199
243
  await deleteEntry(PROJECTS_COLLECTION_SLUG, entry.id);
200
244
  return true;
201
245
  }
@@ -229,7 +273,28 @@ export function validateProject(project) {
229
273
  }
230
274
 
231
275
  function normalise(entry) {
232
- return {...(entry.data || {})};
276
+ const data = entry.data || {};
277
+ // `enabled` is default-on: legacy records (and any record missing the
278
+ // field) are treated as enabled. Only an explicit `false` disables.
279
+ return {...data, enabled: data.enabled !== false};
280
+ }
281
+
282
+ /**
283
+ * Whether the given project is enabled (i.e. not switched off). A disabled
284
+ * project is a hard kill-switch on its PUBLIC surfaces — public pages 404 and
285
+ * its external API (collections + custom endpoints + tokens) is refused.
286
+ *
287
+ * The core project is always enabled. An unknown slug resolves to core by
288
+ * fallback elsewhere, so it is reported enabled here (never a block).
289
+ *
290
+ * @param {string} slug
291
+ * @returns {Promise<boolean>}
292
+ */
293
+ export async function isProjectEnabled(slug) {
294
+ if (!slug || slug === CORE_PROJECT_SLUG) return true;
295
+ const project = await getProject(slug);
296
+ if (!project) return true;
297
+ return project.enabled !== false;
233
298
  }
234
299
 
235
300
  /**
@@ -455,6 +520,10 @@ export async function untagAllForProject(projectSlug) {
455
520
  if (projectSlug === CORE_PROJECT_SLUG) {
456
521
  throw new Error('Cannot untag the core project — artefacts would immediately resolve back to it');
457
522
  }
523
+ const proj = await getProject(projectSlug);
524
+ if (proj?.protected === true) {
525
+ throw new Error(`Cannot untag the protected "${projectSlug}" project — its pages would resolve back to core`);
526
+ }
458
527
  const counts = {
459
528
  pages: 0, collections: 0, forms: 0, actions: 0,
460
529
  menus: 0, blocks: 0, components: 0, views: 0, roles: 0, users: 0, apis: 0
@@ -63,13 +63,80 @@ const SEED_ITEMS = [
63
63
  {
64
64
  text: 'Documentation', icon: 'book',
65
65
  items: [
66
- {text: 'Usage', url: '#/documentation', icon: 'book'},
67
- {text: 'Tutorials', url: '#/tutorials', icon: 'document'},
68
- {text: 'API Reference', url: '#/api-reference', icon: 'code'}
66
+ DOC_USAGE_SUBMENU(),
67
+ DOC_TUTORIALS_SUBMENU(),
68
+ DOC_COMPONENTS_SUBMENU(),
69
+ DOC_API_SUBMENU()
69
70
  ]
70
71
  }
71
72
  ];
72
73
 
74
+ // ─── Documentation sub-trees ──────────────────────────────────────────────
75
+ // Defined as factory functions so the seed and the migration each get their
76
+ // own fresh object (never a shared reference). Used by both SEED_ITEMS (fresh
77
+ // installs) and ensureDocumentationSubmenus() (existing installs).
78
+
79
+ function DOC_USAGE_SUBMENU() {
80
+ return {
81
+ text: 'Usage', icon: 'book',
82
+ items: [
83
+ {text: 'Pages', url: '#/docs/usage/pages', icon: 'file-text'},
84
+ {text: 'Media', url: '#/docs/usage/media', icon: 'image'},
85
+ {text: 'Navigation', url: '#/docs/usage/navigation', icon: 'menu'},
86
+ {text: 'DConfig', url: '#/docs/usage/dconfig', icon: 'settings'},
87
+ {text: 'Shortcodes', url: '#/docs/usage/shortcodes', icon: 'code'},
88
+ {text: 'Site Settings', url: '#/docs/usage/site-settings', icon: 'settings'},
89
+ {text: 'Plugins', url: '#/docs/usage/plugins', icon: 'package'},
90
+ {text: 'Users & Roles', url: '#/docs/usage/users-roles', icon: 'users'},
91
+ {text: 'Views', url: '#/docs/usage/views', icon: 'eye'},
92
+ {text: 'Actions', url: '#/docs/usage/actions', icon: 'zap'},
93
+ {text: 'CTA Shortcode', url: '#/docs/usage/cta-shortcode', icon: 'zap'}
94
+ ]
95
+ };
96
+ }
97
+
98
+ function DOC_TUTORIALS_SUBMENU() {
99
+ return {
100
+ text: 'Tutorials', icon: 'document',
101
+ items: [
102
+ {text: 'Building a CRUD App', url: '#/tutorials/crud', icon: 'database'},
103
+ {text: 'Writing a Plugin', url: '#/tutorials/plugin', icon: 'package'},
104
+ {text: 'Form Follow-Up', url: '#/tutorials/forms', icon: 'layout'}
105
+ ]
106
+ };
107
+ }
108
+
109
+ function DOC_COMPONENTS_SUBMENU() {
110
+ return {
111
+ text: 'Components', icon: 'component',
112
+ items: [
113
+ {text: 'Reference', url: '#/docs/components', icon: 'book'},
114
+ {text: 'How-To', url: '#/docs/components-howto', icon: 'zap'},
115
+ {text: 'Walkthrough', url: '#/docs/components-walkthrough', icon: 'document'},
116
+ {text: 'Rules', url: '#/docs/components-rules', icon: 'shield'}
117
+ ]
118
+ };
119
+ }
120
+
121
+ function DOC_API_SUBMENU() {
122
+ return {
123
+ text: 'API Reference', icon: 'code',
124
+ items: [
125
+ {text: 'Authentication', url: '#/docs/api/authentication', icon: 'shield'},
126
+ {text: 'Pages', url: '#/docs/api/pages', icon: 'file-text'},
127
+ {text: 'Settings', url: '#/docs/api/settings', icon: 'settings'},
128
+ {text: 'Layouts', url: '#/docs/api/layouts', icon: 'layout'},
129
+ {text: 'Navigation', url: '#/docs/api/navigation', icon: 'menu'},
130
+ {text: 'Media', url: '#/docs/api/media', icon: 'image'},
131
+ {text: 'Users', url: '#/docs/api/users', icon: 'users'},
132
+ {text: 'Plugins', url: '#/docs/api/plugins', icon: 'package'},
133
+ {text: 'Collections', url: '#/docs/api/collections', icon: 'database'},
134
+ {text: 'Views API', url: '#/docs/api/views', icon: 'eye'},
135
+ {text: 'Actions API', url: '#/docs/api/actions', icon: 'zap'}
136
+ ]
137
+ };
138
+ }
139
+
73
140
  async function exists(p) {
74
141
  try { await fs.access(p); return true; } catch { return false; }
75
142
  }
@@ -160,3 +227,78 @@ export async function ensureSidebarItem({groupText, item}, opts = {}) {
160
227
  console.log(`[admin-sidebar] Added "${item.text}" to the ${groupText} group`);
161
228
  return {ensured: true};
162
229
  }
230
+
231
+ /**
232
+ * Upgrade EXISTING installs so the Documentation group's one-page docs become
233
+ * per-topic submenus. Idempotent and non-clobbering:
234
+ *
235
+ * - If the persisted menu has no "Documentation" group, do nothing (the admin
236
+ * may have removed it deliberately — don't resurrect it).
237
+ * - Convert each flat leaf — "Usage" (`#/documentation`), "Tutorials"
238
+ * (`#/tutorials`) and "API Reference" (`#/api-reference`) — into its
239
+ * submenu, replacing the node in place so its position is preserved. A leaf
240
+ * that the admin already turned into a parent, or whose new pages already
241
+ * appear anywhere in the group, is left untouched.
242
+ * - Add the Components submenu only if `#/docs/components` isn't present yet —
243
+ * inserted before "API Reference" when that node exists.
244
+ *
245
+ * Returns `{updated: boolean, reason?: string}`.
246
+ *
247
+ * @param {{configDir?: string}} [opts]
248
+ */
249
+ export async function ensureDocumentationSubmenus(opts = {}) {
250
+ const configDir = opts.configDir || DEFAULT_CONFIG_DIR;
251
+ const menuPath = path.join(configDir, 'menus', 'admin-sidebar.json');
252
+
253
+ if (!await exists(menuPath)) {
254
+ return {updated: false, reason: 'admin-sidebar.json not present'};
255
+ }
256
+
257
+ const menu = await readJson(menuPath);
258
+ const items = Array.isArray(menu.items) ? menu.items : [];
259
+ const docGroup = items.find(n => typeof n?.text === 'string' && n.text.toLowerCase() === 'documentation');
260
+ if (!docGroup || !Array.isArray(docGroup.items)) {
261
+ return {updated: false, reason: 'no Documentation group'};
262
+ }
263
+
264
+ const hasUrl = (nodes, url) => Array.isArray(nodes) &&
265
+ nodes.some(n => n?.url === url || hasUrl(n?.items, url));
266
+ const findByText = (text) => docGroup.items.findIndex(n =>
267
+ typeof n?.text === 'string' && n.text.toLowerCase() === text.toLowerCase());
268
+
269
+ let changed = false;
270
+
271
+ // Leaf → submenu conversions. `guardUrl` is a page from the submenu: if it's
272
+ // already present anywhere we've migrated before and skip.
273
+ const CONVERSIONS = [
274
+ {text: 'Usage', guardUrl: '#/docs/usage/pages', build: DOC_USAGE_SUBMENU},
275
+ {text: 'Tutorials', guardUrl: '#/tutorials/crud', build: DOC_TUTORIALS_SUBMENU},
276
+ {text: 'API Reference', guardUrl: '#/docs/api/authentication', build: DOC_API_SUBMENU}
277
+ ];
278
+ for (const {text, guardUrl, build} of CONVERSIONS) {
279
+ if (hasUrl(docGroup.items, guardUrl)) continue;
280
+ const idx = findByText(text);
281
+ if (idx === -1) continue;
282
+ const node = docGroup.items[idx];
283
+ const alreadyParent = Array.isArray(node.items) && node.items.length > 0;
284
+ if (alreadyParent) continue;
285
+ docGroup.items[idx] = build();
286
+ changed = true;
287
+ }
288
+
289
+ // Components submenu — add if absent, before API Reference.
290
+ if (!hasUrl(docGroup.items, '#/docs/components')) {
291
+ const apiIdx = findByText('API Reference');
292
+ const node = DOC_COMPONENTS_SUBMENU();
293
+ if (apiIdx !== -1) docGroup.items.splice(apiIdx, 0, node);
294
+ else docGroup.items.push(node);
295
+ changed = true;
296
+ }
297
+
298
+ if (!changed) return {updated: false, reason: 'already up to date'};
299
+
300
+ menu.meta = {...(menu.meta || {}), updatedAt: new Date().toISOString()};
301
+ await writeJson(menuPath, menu);
302
+ console.log('[admin-sidebar] Upgraded Documentation group with per-topic submenus');
303
+ return {updated: true};
304
+ }