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.
@@ -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
+ }
@@ -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
- const withForm = await processFormBlocks(withIcon, tagSet);
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,