domma-cms 0.34.1 → 0.36.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/CLAUDE.md +2 -1
- package/admin/js/lib/effect-defs.js +1 -1
- package/admin/js/lib/effects-builder.js +1 -1
- package/admin/js/lib/embed-builder.js +1 -0
- package/admin/js/lib/markdown-toolbar.js +17 -17
- package/admin/js/lib/shortcode-context-menu.js +9 -0
- package/admin/js/lib/shortcode-registry.js +45 -0
- package/admin/js/lib/shortcode-tags.js +1 -0
- 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/role-editor.html +70 -70
- package/admin/js/templates/roles.html +10 -10
- package/admin/js/views/page-editor.js +14 -14
- package/bin/lib/config-merge.js +44 -44
- package/config/connections.json.bak +9 -0
- package/config/embeds.json +9 -0
- package/package.json +1 -1
- package/public/css/site.css +1 -1
- package/public/js/effects.js +1 -1
- package/server/middleware/auth.js +253 -253
- package/server/routes/api/auth.js +309 -309
- package/server/routes/api/navigation.js +42 -42
- package/server/routes/api/settings.js +141 -141
- package/server/services/email.js +167 -167
- package/server/services/markdown.js +210 -4
- 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
|
+
}
|
|
@@ -24,7 +24,7 @@ const BUILTIN_SHORTCODES = new Set([
|
|
|
24
24
|
'text', 'button', 'link', 'cta', 'grid', 'row', 'col', 'card',
|
|
25
25
|
'banner', 'slideover', 'counter', 'celebrate', 'firework', 'fireworks', 'scribe',
|
|
26
26
|
'reveal', 'breathe', 'pulse', 'shake', 'scramble', 'ripple', 'twinkle',
|
|
27
|
-
'ticker-tape', 'animate', 'ambient', 'list-group', 'menu',
|
|
27
|
+
'ticker-tape', 'butterflies', 'strobe', 'animate', 'ambient', 'list-group', 'menu',
|
|
28
28
|
]);
|
|
29
29
|
|
|
30
30
|
// Configure marked for safe output
|
|
@@ -1265,7 +1265,7 @@ async function processPluginShortcodes(markdown) {
|
|
|
1265
1265
|
* Process built-in Effects shortcodes natively.
|
|
1266
1266
|
* Handles: [counter /], [celebrate /], [firework], [fireworks], [scribe],
|
|
1267
1267
|
* [reveal], [breathe], [pulse], [shake], [scramble], [ripple],
|
|
1268
|
-
* [twinkle], [ticker-tape], [animate], [ambient]
|
|
1268
|
+
* [twinkle], [ticker-tape], [butterflies], [strobe], [animate], [ambient]
|
|
1269
1269
|
*
|
|
1270
1270
|
* @param {string} markdown
|
|
1271
1271
|
* @returns {string}
|
|
@@ -1399,6 +1399,25 @@ function processEffectsBlocks(markdown) {
|
|
|
1399
1399
|
return `<div class="dm-fx-ticker-tape" data-fx-mode="container"${dataAttrs}>${innerHtml}</div>\n`;
|
|
1400
1400
|
});
|
|
1401
1401
|
|
|
1402
|
+
// [butterflies /] → full-page overlay (mode=page)
|
|
1403
|
+
// [butterflies] body [/butterflies] → container-scoped (mode=container)
|
|
1404
|
+
// [strobe /] / [strobe] body [/strobe] — same shape
|
|
1405
|
+
for (const name of ['butterflies', 'strobe']) {
|
|
1406
|
+
apply(name, (attrStr, body) => {
|
|
1407
|
+
const attrs = parseShortcodeAttrs(attrStr);
|
|
1408
|
+
const dataAttrs = Object.entries(attrs)
|
|
1409
|
+
.map(([k, v]) => ` data-fx-${k}="${escapeAttr(String(v))}"`)
|
|
1410
|
+
.join('');
|
|
1411
|
+
if (body === null) {
|
|
1412
|
+
return `<div class="dm-fx-${name}" data-fx-mode="page"${dataAttrs}></div>`;
|
|
1413
|
+
}
|
|
1414
|
+
// Restore code placeholders before the nested parse (tabs-handler
|
|
1415
|
+
// pattern) so code spans/fences render as <code>, not raw backticks.
|
|
1416
|
+
const innerHtml = marked.parse(processCardBlocks(processGridBlocks(restore(body.trim()))));
|
|
1417
|
+
return `<div class="dm-fx-${name}" data-fx-mode="container"${dataAttrs}>${innerHtml}</div>\n`;
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1402
1421
|
apply('animate', (attrStr, body) => {
|
|
1403
1422
|
if (body === null) return '';
|
|
1404
1423
|
const attrs = parseShortcodeAttrs(attrStr);
|
|
@@ -3065,6 +3084,177 @@ function processIconBlocks(markdown) {
|
|
|
3065
3084
|
));
|
|
3066
3085
|
}
|
|
3067
3086
|
|
|
3087
|
+
// ─── [embed] — video / iframe embeds ───────────────────────────────────────
|
|
3088
|
+
|
|
3089
|
+
/**
|
|
3090
|
+
* Classify an embed URL into one of: youtube, vimeo, video (self-hosted),
|
|
3091
|
+
* iframe (generic) or invalid. Pure — no allow-list logic here; the host
|
|
3092
|
+
* allow-list is applied at render time for the generic-iframe case only.
|
|
3093
|
+
*
|
|
3094
|
+
* @param {string} url
|
|
3095
|
+
* @returns {{type:('youtube'|'vimeo'|'video'|'iframe'|'invalid'), src?:string, id?:string, host?:string, mime?:string}}
|
|
3096
|
+
*/
|
|
3097
|
+
export function classifyEmbedUrl(url) {
|
|
3098
|
+
const u = String(url ?? '').trim();
|
|
3099
|
+
if (!u) return {type: 'invalid'};
|
|
3100
|
+
|
|
3101
|
+
let m;
|
|
3102
|
+
// YouTube — watch?v=, youtu.be/, /embed/, /shorts/, /live/. Any non-empty
|
|
3103
|
+
// id token counts so a YouTube host never falls through to the generic
|
|
3104
|
+
// iframe path (which would show a misleading "not allow-listed" message).
|
|
3105
|
+
if ((m = u.match(/(?:youtube(?:-nocookie)?\.com\/(?:watch\?(?:[^#]*&)?v=|embed\/|shorts\/|live\/)|youtu\.be\/)([\w-]+)/i))) {
|
|
3106
|
+
return {type: 'youtube', id: m[1], src: `https://www.youtube-nocookie.com/embed/${m[1]}`};
|
|
3107
|
+
}
|
|
3108
|
+
// Vimeo — vimeo.com/ID or player.vimeo.com/video/ID
|
|
3109
|
+
if ((m = u.match(/vimeo\.com\/(?:video\/)?(\d+)/i))) {
|
|
3110
|
+
return {type: 'vimeo', id: m[1], src: `https://player.vimeo.com/video/${m[1]}`};
|
|
3111
|
+
}
|
|
3112
|
+
// Self-hosted video — by file extension (optional query/hash suffix)
|
|
3113
|
+
if ((m = u.match(/\.(mp4|webm|ogg|ogv)(?:\?[^#]*)?(?:#.*)?$/i))) {
|
|
3114
|
+
const ext = m[1].toLowerCase();
|
|
3115
|
+
const mime = ext === 'mp4' ? 'video/mp4' : ext === 'webm' ? 'video/webm' : 'video/ogg';
|
|
3116
|
+
return {type: 'video', src: u, mime};
|
|
3117
|
+
}
|
|
3118
|
+
// Generic http(s) — host decides allow/deny at render time
|
|
3119
|
+
if (/^https?:\/\//i.test(u)) {
|
|
3120
|
+
try {
|
|
3121
|
+
return {type: 'iframe', src: u, host: new URL(u).hostname};
|
|
3122
|
+
} catch {
|
|
3123
|
+
return {type: 'invalid'};
|
|
3124
|
+
}
|
|
3125
|
+
}
|
|
3126
|
+
return {type: 'invalid'};
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
const EMBED_RATIOS = {
|
|
3130
|
+
'16:9': '16 / 9', '4:3': '4 / 3', '1:1': '1 / 1',
|
|
3131
|
+
'21:9': '21 / 9', '9:16': '9 / 16', '3:4': '3 / 4',
|
|
3132
|
+
};
|
|
3133
|
+
|
|
3134
|
+
function _embedRatio(raw) {
|
|
3135
|
+
if (!raw || raw === true) return '16 / 9';
|
|
3136
|
+
const key = String(raw).trim();
|
|
3137
|
+
if (EMBED_RATIOS[key]) return EMBED_RATIOS[key];
|
|
3138
|
+
const m = key.match(/^(\d{1,2}):(\d{1,2})$/); // bounded arbitrary W:H
|
|
3139
|
+
return m ? `${m[1]} / ${m[2]}` : '16 / 9';
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
function _embedBlocked(message) {
|
|
3143
|
+
return `<div class="dm-embed-blocked" role="note">⚠ ${message}</div>`;
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3146
|
+
const _embedFlag = (v) => v === true || v === '' || v === 'true';
|
|
3147
|
+
|
|
3148
|
+
/**
|
|
3149
|
+
* Render a single [embed] shortcode into sanitiser-safe HTML. YouTube/Vimeo
|
|
3150
|
+
* and self-hosted video always render; a generic iframe renders only when its
|
|
3151
|
+
* host is in `allowedHosts`, otherwise a visible placeholder is returned.
|
|
3152
|
+
*
|
|
3153
|
+
* @param {object} attrs - Parsed shortcode attributes (`url` required).
|
|
3154
|
+
* @param {string[]} [allowedHosts] - Extra iframe hostnames permitted for the
|
|
3155
|
+
* generic-iframe case (YouTube/Vimeo hosts are always permitted).
|
|
3156
|
+
* @returns {string}
|
|
3157
|
+
*/
|
|
3158
|
+
export function renderEmbed(attrs, allowedHosts = []) {
|
|
3159
|
+
const info = classifyEmbedUrl(attrs.url);
|
|
3160
|
+
if (info.type === 'invalid') return _embedBlocked('Invalid embed URL.');
|
|
3161
|
+
|
|
3162
|
+
const ratio = _embedRatio(attrs.ratio);
|
|
3163
|
+
const title = attrs.title ? escapeAttr(attrs.title) : '';
|
|
3164
|
+
const autoplay = _embedFlag(attrs.autoplay);
|
|
3165
|
+
const loop = _embedFlag(attrs.loop);
|
|
3166
|
+
const muted = _embedFlag(attrs.muted);
|
|
3167
|
+
|
|
3168
|
+
// Outer <figure> — width + alignment.
|
|
3169
|
+
const figStyles = ['margin:1.5rem 0'];
|
|
3170
|
+
if (attrs.width) {
|
|
3171
|
+
const w = /^\d+%$/.test(attrs.width) ? attrs.width : `${parseInt(attrs.width, 10) || 0}px`;
|
|
3172
|
+
if (w !== '0px') figStyles.push(`max-width:${w}`);
|
|
3173
|
+
}
|
|
3174
|
+
const align = String(attrs.align || '').toLowerCase();
|
|
3175
|
+
if (align === 'center') figStyles.push('margin-left:auto', 'margin-right:auto');
|
|
3176
|
+
else if (align === 'right') figStyles.push('margin-left:auto');
|
|
3177
|
+
|
|
3178
|
+
let inner;
|
|
3179
|
+
if (info.type === 'video') {
|
|
3180
|
+
const flags = ['controls', 'playsinline'];
|
|
3181
|
+
if (autoplay) flags.push('autoplay', 'muted'); // browsers block un-muted autoplay
|
|
3182
|
+
else if (muted) flags.push('muted');
|
|
3183
|
+
if (loop) flags.push('loop');
|
|
3184
|
+
inner = `<video ${flags.join(' ')} preload="metadata"`
|
|
3185
|
+
+ ` style="position:absolute;inset:0;width:100%;height:100%;display:block;background:#000">`
|
|
3186
|
+
+ `<source src="${escapeAttr(info.src)}" type="${escapeAttr(info.mime)}"></video>`;
|
|
3187
|
+
} else {
|
|
3188
|
+
if (info.type === 'iframe') {
|
|
3189
|
+
const allowed = allowedHosts.map(h => String(h).toLowerCase());
|
|
3190
|
+
if (!allowed.includes(String(info.host).toLowerCase())) {
|
|
3191
|
+
return _embedBlocked(
|
|
3192
|
+
`Embedded content from <code>${escapeAttr(info.host)}</code> is not in the allow-list (config/embeds.json).`);
|
|
3193
|
+
}
|
|
3194
|
+
}
|
|
3195
|
+
let src = info.src;
|
|
3196
|
+
if (info.type === 'youtube' || info.type === 'vimeo') {
|
|
3197
|
+
const muteKey = info.type === 'youtube' ? 'mute=1' : 'muted=1';
|
|
3198
|
+
const params = [];
|
|
3199
|
+
if (autoplay) params.push('autoplay=1', muteKey);
|
|
3200
|
+
else if (muted) params.push(muteKey);
|
|
3201
|
+
if (loop) info.type === 'youtube' ? params.push('loop=1', `playlist=${info.id}`) : params.push('loop=1');
|
|
3202
|
+
if (params.length) src += (src.includes('?') ? '&' : '?') + params.join('&');
|
|
3203
|
+
}
|
|
3204
|
+
inner = `<iframe src="${escapeAttr(src)}"${title ? ` title="${title}"` : ''}`
|
|
3205
|
+
+ ` loading="lazy" referrerpolicy="strict-origin-when-cross-origin"`
|
|
3206
|
+
+ ` allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"`
|
|
3207
|
+
+ ` allowfullscreen style="position:absolute;inset:0;width:100%;height:100%;border:0"></iframe>`;
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
const caption = title ? `\n<figcaption class="dm-embed-caption">${title}</figcaption>` : '';
|
|
3211
|
+
return `<figure class="dm-embed" style="${figStyles.join(';')}">`
|
|
3212
|
+
+ `<div class="dm-embed-frame" style="position:relative;width:100%;aspect-ratio:${ratio}">${inner}</div>`
|
|
3213
|
+
+ `${caption}</figure>`;
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3216
|
+
/**
|
|
3217
|
+
* Read the generic-iframe host allow-list from config/embeds.json. A missing
|
|
3218
|
+
* file yields no extra hosts — YouTube/Vimeo and self-hosted video still work.
|
|
3219
|
+
*
|
|
3220
|
+
* @returns {string[]}
|
|
3221
|
+
*/
|
|
3222
|
+
export function getEmbedAllowedHosts() {
|
|
3223
|
+
try {
|
|
3224
|
+
const cfg = getConfig('embeds');
|
|
3225
|
+
return Array.isArray(cfg?.allowedHosts) ? cfg.allowedHosts : [];
|
|
3226
|
+
} catch {
|
|
3227
|
+
return [];
|
|
3228
|
+
}
|
|
3229
|
+
}
|
|
3230
|
+
|
|
3231
|
+
/**
|
|
3232
|
+
* Pre-process [embed] self-closing shortcodes before running through marked.
|
|
3233
|
+
*
|
|
3234
|
+
* Syntax:
|
|
3235
|
+
* [embed url="https://youtu.be/ID" /]
|
|
3236
|
+
* [embed url="/media/clip.mp4" ratio="16:9" title="..." autoplay muted loop /]
|
|
3237
|
+
* [embed url="https://maps.example.com/..." width="640" align="center" /]
|
|
3238
|
+
*
|
|
3239
|
+
* Supported attributes:
|
|
3240
|
+
* url - YouTube/Vimeo link, self-hosted video path, or generic iframe URL (required)
|
|
3241
|
+
* ratio - Aspect ratio: 16:9 (default), 4:3, 1:1, 21:9, 9:16, 3:4, or W:H
|
|
3242
|
+
* title - Accessibility title + visible caption
|
|
3243
|
+
* width - Max width (px or %); align - left | center | right
|
|
3244
|
+
* autoplay / loop / muted - Playback flags (autoplay forces muted)
|
|
3245
|
+
*
|
|
3246
|
+
* @param {string} markdown
|
|
3247
|
+
* @returns {string}
|
|
3248
|
+
*/
|
|
3249
|
+
function processEmbedBlocks(markdown) {
|
|
3250
|
+
const {scrubbed, restore} = scrubCodeRegions(markdown);
|
|
3251
|
+
const allowedHosts = getEmbedAllowedHosts();
|
|
3252
|
+
return restore(scrubbed.replace(
|
|
3253
|
+
/\[embed([^\]]*?)\/?\]/gi,
|
|
3254
|
+
(_, attrStr) => '\n' + renderEmbed(parseShortcodeAttrs(attrStr || ''), allowedHosts) + '\n'
|
|
3255
|
+
));
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3068
3258
|
/**
|
|
3069
3259
|
* Pre-process [form] self-closing shortcodes before running through marked.
|
|
3070
3260
|
*
|
|
@@ -3209,7 +3399,7 @@ function processHeroBlocks(markdown) {
|
|
|
3209
3399
|
const heroColor = attrs.color ? String(attrs.color) : '';
|
|
3210
3400
|
const minHeight = attrs['min-height'] ? String(attrs['min-height']) : '';
|
|
3211
3401
|
|
|
3212
|
-
const HERO_INJECTABLE = ['reveal', 'breathe', 'pulse', 'twinkle', 'ticker-tape', 'ambient', 'animate'];
|
|
3402
|
+
const HERO_INJECTABLE = ['reveal', 'breathe', 'pulse', 'twinkle', 'ticker-tape', 'butterflies', 'strobe', 'ambient', 'animate'];
|
|
3213
3403
|
const injected = extractInjectedEffects(attrs, HERO_INJECTABLE);
|
|
3214
3404
|
|
|
3215
3405
|
// Legacy ad-hoc 'blobs' flag — keep for backward compat. Different keyspace
|
|
@@ -3556,7 +3746,8 @@ export async function parseMarkdown(raw, opts = {}) {
|
|
|
3556
3746
|
const withSpacer = processSpacerBlocks(withListGroup);
|
|
3557
3747
|
const withCenter = processCenterBlocks(withSpacer);
|
|
3558
3748
|
const withIcon = processIconBlocks(withCenter);
|
|
3559
|
-
|
|
3749
|
+
const withEmbed = processEmbedBlocks(withIcon);
|
|
3750
|
+
const withForm = await processFormBlocks(withEmbed, tagSet);
|
|
3560
3751
|
const withHero = processHeroBlocks(withForm);
|
|
3561
3752
|
const withTable = processTableBlocks(withHero);
|
|
3562
3753
|
const withBadge = processBadgeBlocks(withTable);
|
|
@@ -3575,10 +3766,20 @@ export async function parseMarkdown(raw, opts = {}) {
|
|
|
3575
3766
|
// reach the page. Admins write block templates; same trust model as
|
|
3576
3767
|
// content/custom.css. This flag silences sanitize-html's warning.
|
|
3577
3768
|
allowVulnerableTags: true,
|
|
3769
|
+
// [embed] emits <iframe>/<video>; hostnames are gated by
|
|
3770
|
+
// allowedIframeHostnames below (defence in depth over the render-time
|
|
3771
|
+
// host allow-list).
|
|
3772
|
+
allowedIframeHostnames: [
|
|
3773
|
+
'www.youtube-nocookie.com', 'www.youtube.com', 'youtube.com',
|
|
3774
|
+
'player.vimeo.com',
|
|
3775
|
+
...getEmbedAllowedHosts(),
|
|
3776
|
+
],
|
|
3578
3777
|
allowedTags: sanitizeHtml.defaults.allowedTags.concat([
|
|
3579
3778
|
'img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
3580
3779
|
'form', 'input', 'textarea', 'select', 'option', 'optgroup',
|
|
3581
3780
|
'button', 'label', 'fieldset', 'legend',
|
|
3781
|
+
// [embed] shortcode output
|
|
3782
|
+
'iframe', 'video', 'source', 'figure', 'figcaption',
|
|
3582
3783
|
// <style> tags are emitted by block rendering (buildBlockStyleTag) so
|
|
3583
3784
|
// per-block custom CSS can reach the page. Admin-trust: block authors
|
|
3584
3785
|
// are admins, same security model as site-wide content/custom.css.
|
|
@@ -3605,6 +3806,11 @@ export async function parseMarkdown(raw, opts = {}) {
|
|
|
3605
3806
|
button: ['type', 'disabled', 'aria-label', 'data-action', 'data-entry', 'data-confirm'],
|
|
3606
3807
|
label: ['for'],
|
|
3607
3808
|
fieldset: ['disabled'],
|
|
3809
|
+
iframe: ['src', 'title', 'width', 'height', 'allow', 'allowfullscreen',
|
|
3810
|
+
'loading', 'referrerpolicy', 'frameborder', 'style'],
|
|
3811
|
+
video: ['controls', 'autoplay', 'muted', 'loop', 'playsinline',
|
|
3812
|
+
'preload', 'poster', 'width', 'height', 'style'],
|
|
3813
|
+
source: ['src', 'type'],
|
|
3608
3814
|
// Allow any attribute on every registered dm-* tag. Props are
|
|
3609
3815
|
// author-defined and enumerating them at sanitise time has no security
|
|
3610
3816
|
// benefit — custom-element attributes cannot execute JS on their own,
|