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.
- package/admin/js/app.js +2 -2
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/templates/api-reference.html +68 -1233
- package/admin/js/templates/docs/api-actions.html +146 -0
- package/admin/js/templates/docs/api-authentication.html +202 -0
- package/admin/js/templates/docs/api-collections.html +347 -0
- package/admin/js/templates/docs/api-layouts.html +123 -0
- package/admin/js/templates/docs/api-media.html +158 -0
- package/admin/js/templates/docs/api-navigation.html +99 -0
- package/admin/js/templates/docs/api-pages.html +214 -0
- package/admin/js/templates/docs/api-plugins.html +136 -0
- package/admin/js/templates/docs/api-settings.html +148 -0
- package/admin/js/templates/docs/api-users.html +189 -0
- package/admin/js/templates/docs/api-views.html +142 -0
- package/admin/js/templates/docs/components-howto.html +175 -0
- package/admin/js/templates/docs/components-reference.html +259 -0
- package/admin/js/templates/docs/components-rules.html +167 -0
- package/admin/js/templates/docs/components-walkthrough.html +167 -0
- package/admin/js/templates/docs/tutorial-crud.html +344 -0
- package/admin/js/templates/docs/tutorial-forms.html +93 -0
- package/admin/js/templates/docs/tutorial-plugin.html +234 -0
- package/admin/js/templates/docs/usage-actions.html +122 -0
- package/admin/js/templates/docs/usage-cta-shortcode.html +124 -0
- package/admin/js/templates/docs/usage-dconfig.html +148 -0
- package/admin/js/templates/docs/usage-media.html +25 -0
- package/admin/js/templates/docs/usage-navigation.html +31 -0
- package/admin/js/templates/docs/usage-pages.html +68 -0
- package/admin/js/templates/docs/usage-plugins.html +36 -0
- package/admin/js/templates/docs/usage-shortcodes.html +810 -0
- package/admin/js/templates/docs/usage-site-settings.html +52 -0
- package/admin/js/templates/docs/usage-users-roles.html +84 -0
- package/admin/js/templates/docs/usage-views.html +105 -0
- package/admin/js/templates/documentation.html +60 -1522
- package/admin/js/templates/effects.html +752 -752
- package/admin/js/templates/forms.html +17 -17
- package/admin/js/templates/my-profile.html +17 -17
- package/admin/js/templates/project-settings.html +7 -0
- package/admin/js/templates/role-editor.html +70 -70
- package/admin/js/templates/roles.html +10 -10
- package/admin/js/templates/tutorials.html +46 -659
- package/admin/js/views/api-reference.js +1 -1
- package/admin/js/views/doc-pages.js +1 -0
- package/admin/js/views/documentation.js +1 -1
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/project-settings.js +1 -1
- package/admin/js/views/projects.js +3 -7
- package/admin/js/views/tutorials.js +1 -1
- package/bin/lib/config-merge.js +44 -44
- package/config/connections.json.bak +9 -0
- package/config/menus/admin-sidebar.json +76 -6
- package/package.json +1 -1
- package/public/js/site.js +1 -1
- package/scripts/migrate-docs.js +280 -0
- package/server/middleware/auth.js +253 -253
- package/server/routes/api/auth.js +309 -309
- package/server/routes/api/collections.js +10 -1
- package/server/routes/api/navigation.js +42 -42
- package/server/routes/api/projects.js +9 -1
- package/server/routes/api/settings.js +141 -141
- package/server/routes/public.js +9 -0
- package/server/server.js +6 -2
- package/server/services/email.js +167 -167
- package/server/services/presetCollections.js +1 -0
- package/server/services/projects.js +72 -3
- package/server/services/sidebar-migration.js +145 -3
- package/server/services/userProfiles.js +199 -199
- package/server/services/users.js +302 -302
package/server/services/email.js
CHANGED
|
@@ -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, '<')
|
|
43
|
-
.replace(/>/g, '>')
|
|
44
|
-
.replace(/"/g, '"')
|
|
45
|
-
.replace(/'/g, ''');
|
|
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, '&')
|
|
42
|
+
.replace(/</g, '<')
|
|
43
|
+
.replace(/>/g, '>')
|
|
44
|
+
.replace(/"/g, '"')
|
|
45
|
+
.replace(/'/g, ''');
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
+
}
|