domma-cms 0.35.0 → 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
+ }
@@ -3084,6 +3084,177 @@ function processIconBlocks(markdown) {
3084
3084
  ));
3085
3085
  }
3086
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
+
3087
3258
  /**
3088
3259
  * Pre-process [form] self-closing shortcodes before running through marked.
3089
3260
  *
@@ -3575,7 +3746,8 @@ export async function parseMarkdown(raw, opts = {}) {
3575
3746
  const withSpacer = processSpacerBlocks(withListGroup);
3576
3747
  const withCenter = processCenterBlocks(withSpacer);
3577
3748
  const withIcon = processIconBlocks(withCenter);
3578
- const withForm = await processFormBlocks(withIcon, tagSet);
3749
+ const withEmbed = processEmbedBlocks(withIcon);
3750
+ const withForm = await processFormBlocks(withEmbed, tagSet);
3579
3751
  const withHero = processHeroBlocks(withForm);
3580
3752
  const withTable = processTableBlocks(withHero);
3581
3753
  const withBadge = processBadgeBlocks(withTable);
@@ -3594,10 +3766,20 @@ export async function parseMarkdown(raw, opts = {}) {
3594
3766
  // reach the page. Admins write block templates; same trust model as
3595
3767
  // content/custom.css. This flag silences sanitize-html's warning.
3596
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
+ ],
3597
3777
  allowedTags: sanitizeHtml.defaults.allowedTags.concat([
3598
3778
  'img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
3599
3779
  'form', 'input', 'textarea', 'select', 'option', 'optgroup',
3600
3780
  'button', 'label', 'fieldset', 'legend',
3781
+ // [embed] shortcode output
3782
+ 'iframe', 'video', 'source', 'figure', 'figcaption',
3601
3783
  // <style> tags are emitted by block rendering (buildBlockStyleTag) so
3602
3784
  // per-block custom CSS can reach the page. Admin-trust: block authors
3603
3785
  // are admins, same security model as site-wide content/custom.css.
@@ -3624,6 +3806,11 @@ export async function parseMarkdown(raw, opts = {}) {
3624
3806
  button: ['type', 'disabled', 'aria-label', 'data-action', 'data-entry', 'data-confirm'],
3625
3807
  label: ['for'],
3626
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'],
3627
3814
  // Allow any attribute on every registered dm-* tag. Props are
3628
3815
  // author-defined and enumerating them at sanitise time has no security
3629
3816
  // benefit — custom-element attributes cannot execute JS on their own,