Site builder: security & data-loss hardening + unified asset picker + audit backlog #3

Merged
jknapp merged 61 commits from builder-hardening-2026-07 into main 2026-07-13 01:13:28 +00:00
21 changed files with 255 additions and 27 deletions
Showing only changes of commit 9969adca72 - Show all commits
@@ -0,0 +1,28 @@
import { describe, test, expect } from 'vitest';
import { Navbar } from './Navbar';
const toHtml = (Navbar as any).toHtml;
describe('Navbar.toHtml hamburger accessibility (F2.3)', () => {
test('mobile toggle button has an accessible name, aria-expanded, and aria-controls', () => {
const { html } = toHtml({ showMobileMenu: true }, '');
expect(html).toMatch(/class="navbar-hamburger"[^>]*aria-label="Toggle navigation menu"/);
expect(html).toMatch(/aria-expanded="false"/);
expect(html).toMatch(/aria-controls="navbar-links"/);
});
test('aria-controls target id exists on the links container', () => {
const { html } = toHtml({ showMobileMenu: true }, '');
expect(html).toContain('id="navbar-links"');
});
test('toggle script flips aria-expanded on click', () => {
const { html } = toHtml({ showMobileMenu: true }, '');
expect(html).toMatch(/setAttribute\(['"]aria-expanded['"]/);
});
test('no mobile menu: no hamburger button emitted', () => {
const { html } = toHtml({ showMobileMenu: false }, '');
expect(html).not.toContain('navbar-hamburger');
});
});
+6 -3
View File
@@ -264,9 +264,12 @@ Navbar.craft = {
return `<a href="${escapeAttr(safeUrl(link.href || "#"))}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`; return `<a href="${escapeAttr(safeUrl(link.href || "#"))}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
}).join('\n '); }).join('\n ');
// Hamburger HTML for mobile // Hamburger HTML for mobile. The toggle needs an accessible name (there's
// no visible text, just three bars) and must report its open/closed state
// via aria-expanded, kept in sync with the .navbar-open class by the
// inline onclick handler.
const hamburgerHtml = mobile const hamburgerHtml = mobile
? `\n <button class="navbar-hamburger" onclick="this.parentElement.querySelector('.navbar-links').classList.toggle('navbar-open')" style="display:none;background:none;border:none;cursor:pointer;padding:4px;flex-direction:column;gap:4px"> ? `\n <button class="navbar-hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="navbar-links" onclick="var m=this.parentElement.querySelector('.navbar-links');var open=m.classList.toggle('navbar-open');this.setAttribute('aria-expanded', open ? 'true' : 'false');" style="display:none;background:none;border:none;cursor:pointer;padding:4px;flex-direction:column;gap:4px">
<span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span> <span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span>
<span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span> <span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span>
<span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span> <span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span>
@@ -305,7 +308,7 @@ Navbar.craft = {
html: `${hoverCss} html: `${hoverCss}
<nav${navStyle ? ` style="${navStyle}${mobile ? ';position:relative' : ''}"` : ''}> <nav${navStyle ? ` style="${navStyle}${mobile ? ';position:relative' : ''}"` : ''}>
${logoHtml}${hamburgerHtml} ${logoHtml}${hamburgerHtml}
<div class="navbar-links" style="display:flex;align-items:center;gap:24px"> <div class="navbar-links" id="navbar-links" style="display:flex;align-items:center;gap:24px">
${linksHtmlWithClass} ${linksHtmlWithClass}
</div> </div>
</nav>`, </nav>`,
@@ -0,0 +1,13 @@
import { describe, test, expect } from 'vitest';
import { SearchBar } from './SearchBar';
const toHtml = (SearchBar as any).toHtml;
describe('SearchBar.toHtml decorative icons (F2.5)', () => {
test('the input-adjacent search icon is aria-hidden', () => {
const { html } = toHtml({}, '');
const icons = html.match(/<i class="fa fa-search"[^>]*>/g) || [];
expect(icons.length).toBeGreaterThan(0);
icons.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"'));
});
});
+2 -2
View File
@@ -130,13 +130,13 @@ SearchBar.craft = {
const inputStyleStr = `width:100%;padding:12px 16px 12px 40px;font-size:15px;font-family:Inter,sans-serif;border:1px solid #d1d5db;border-radius:${showButton ? '8px 0 0 8px' : '8px'};background-color:#ffffff;color:#1f2937;outline:none;box-sizing:border-box`; const inputStyleStr = `width:100%;padding:12px 16px 12px 40px;font-size:15px;font-family:Inter,sans-serif;border:1px solid #d1d5db;border-radius:${showButton ? '8px 0 0 8px' : '8px'};background-color:#ffffff;color:#1f2937;outline:none;box-sizing:border-box`;
const btnHtml = showButton const btnHtml = showButton
? `<button type="submit" style="padding:12px 20px;font-size:15px;font-weight:600;font-family:Inter,sans-serif;color:#ffffff;background-color:#3b82f6;border:none;border-radius:0 8px 8px 0;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:6px"><i class="fa fa-search" style="font-size:13px"></i>${escapeHtml(buttonText)}</button>` ? `<button type="submit" style="padding:12px 20px;font-size:15px;font-weight:600;font-family:Inter,sans-serif;color:#ffffff;background-color:#3b82f6;border:none;border-radius:0 8px 8px 0;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:6px"><i class="fa fa-search" style="font-size:13px" aria-hidden="true"></i>${escapeHtml(buttonText)}</button>`
: ''; : '';
return { return {
html: `<form role="search"${formStyle ? ` style="${formStyle}"` : ''}> html: `<form role="search"${formStyle ? ` style="${formStyle}"` : ''}>
<div style="position:relative;flex:1"> <div style="position:relative;flex:1">
<i class="fa fa-search" style="position:absolute;left:14px;top:50%;transform:translateY(-50%);color:#9ca3af;font-size:14px;pointer-events:none"></i> <i class="fa fa-search" style="position:absolute;left:14px;top:50%;transform:translateY(-50%);color:#9ca3af;font-size:14px;pointer-events:none" aria-hidden="true"></i>
<input type="search" placeholder="${escapeAttr(placeholder)}" style="${inputStyleStr}" /> <input type="search" placeholder="${escapeAttr(placeholder)}" style="${inputStyleStr}" />
</div> </div>
${btnHtml} ${btnHtml}
@@ -0,0 +1,16 @@
import { describe, test, expect } from 'vitest';
import { SocialLinks } from './SocialLinks';
const toHtml = (SocialLinks as any).toHtml;
describe('SocialLinks.toHtml accessibility (F2.5)', () => {
test('icon-only links get an aria-label naming the platform', () => {
const { html } = toHtml({ links: [{ platform: 'facebook', url: 'https://fb.example/x' }] }, '');
expect(html).toMatch(/<a[^>]*aria-label="Facebook"/);
});
test('the icon glyph itself is aria-hidden', () => {
const { html } = toHtml({ links: [{ platform: 'twitter', url: '#' }] }, '');
expect(html).toMatch(/<i class="fa fa-twitter"[^>]*aria-hidden="true"/);
});
});
+5 -1
View File
@@ -204,7 +204,11 @@ SocialLinks.craft = {
if (hasBg) { if (hasBg) {
aStyle += `;${getShapeStr()}`; aStyle += `;${getShapeStr()}`;
} }
return `<a href="${escapeAttr(safeUrl(link.url || '#'))}" target="_blank" rel="noopener noreferrer" title="${escapeAttr(title)}" style="${aStyle}"><i class="fa ${escapeAttr(iconClass)}" style="font-size:${iconSize}"></i></a>`; // The link's only content is the icon glyph, so the glyph itself is
// aria-hidden and the accessible name lives on the link (aria-label,
// mirroring the existing `title` tooltip since title support in
// screen readers is inconsistent).
return `<a href="${escapeAttr(safeUrl(link.url || '#'))}" target="_blank" rel="noopener noreferrer" title="${escapeAttr(title)}" aria-label="${escapeAttr(title)}" style="${aStyle}"><i class="fa ${escapeAttr(iconClass)}" style="font-size:${iconSize}" aria-hidden="true"></i></a>`;
}).join('\n '); }).join('\n ');
return { return {
@@ -0,0 +1,23 @@
import { describe, test, expect } from 'vitest';
import { StarRating } from './StarRating';
const toHtml = (StarRating as any).toHtml;
describe('StarRating.toHtml accessibility (F2.2)', () => {
test('wrapper has role="img" and a "Rating: N out of maxStars" aria-label', () => {
const { html } = toHtml({ rating: 4.5, maxStars: 5 }, '');
expect(html).toMatch(/<span role="img" aria-label="Rating: 4\.5 out of 5"/);
});
test('individual star glyphs are aria-hidden', () => {
const { html } = toHtml({ rating: 3, maxStars: 5 }, '');
const glyphs = html.match(/<i class="fa fa-star"[^>]*>/g) || [];
expect(glyphs.length).toBeGreaterThan(0);
glyphs.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"'));
});
test('respects custom maxStars in the aria-label', () => {
const { html } = toHtml({ rating: 2, maxStars: 10 }, '');
expect(html).toContain('aria-label="Rating: 2 out of 10"');
});
});
+7 -4
View File
@@ -112,15 +112,18 @@ StarRating.craft = {
let starsHtml = ''; let starsHtml = '';
for (let i = 1; i <= maxStars; i++) { for (let i = 1; i <= maxStars; i++) {
if (i <= Math.floor(rating)) { if (i <= Math.floor(rating)) {
starsHtml += `<i class="fa fa-star" style="color:${filledColor};font-size:${size}"></i>`; starsHtml += `<i class="fa fa-star" style="color:${filledColor};font-size:${size}" aria-hidden="true"></i>`;
} else if (i === Math.ceil(rating) && rating % 1 !== 0) { } else if (i === Math.ceil(rating) && rating % 1 !== 0) {
starsHtml += `<span style="position:relative;display:inline-block;font-size:${size}"><i class="fa fa-star" style="color:${emptyColor}"></i><span style="position:absolute;left:0;top:0;overflow:hidden;width:50%"><i class="fa fa-star" style="color:${filledColor}"></i></span></span>`; starsHtml += `<span style="position:relative;display:inline-block;font-size:${size}" aria-hidden="true"><i class="fa fa-star" style="color:${emptyColor}"></i><span style="position:absolute;left:0;top:0;overflow:hidden;width:50%"><i class="fa fa-star" style="color:${filledColor}"></i></span></span>`;
} else { } else {
starsHtml += `<i class="fa fa-star" style="color:${emptyColor};font-size:${size}"></i>`; starsHtml += `<i class="fa fa-star" style="color:${emptyColor};font-size:${size}" aria-hidden="true"></i>`;
} }
} }
// The star glyphs convey nothing to assistive tech on their own -- wrap
// in role="img" with a textual equivalent, and hide the decorative glyphs
// themselves (aria-hidden above) so AT doesn't announce each icon.
return { return {
html: `<span${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>${starsHtml}</span>`, html: `<span role="img" aria-label="Rating: ${rating} out of ${maxStars}"${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>${starsHtml}</span>`,
}; };
}; };
@@ -63,3 +63,28 @@ describe('ContactForm.toHtml successMessage', () => {
expect(html).not.toContain('onerror="alert(1)"'); expect(html).not.toContain('onerror="alert(1)"');
}); });
}); });
describe('ContactForm.toHtml accessibility (F2.1)', () => {
const fields = [
{ type: 'text' as const, label: 'Name', name: 'name', placeholder: 'Your name', required: true },
{ type: 'email' as const, label: 'Email', name: 'email', placeholder: 'you@example.com', required: true },
];
test('each field label for= matches its control id=, and ids are unique', () => {
const { html } = toHtml({ fields }, '');
const labelIds = [...html.matchAll(/<label for="([^"]+)"/g)].map((m) => m[1]);
const controlIds = [...html.matchAll(/<(?:input|textarea|select) id="([^"]+)"/g)].map((m) => m[1]);
expect(labelIds.length).toBe(2);
expect(controlIds.length).toBe(2);
expect(labelIds).toEqual(controlIds);
expect(new Set(controlIds).size).toBe(2);
});
test('ids are deterministic across repeated calls with the same fields', () => {
const { html: html1 } = toHtml({ fields }, '');
const { html: html2 } = toHtml({ fields }, '');
const ids1 = [...html1.matchAll(/<input id="([^"]+)"/g)].map((m) => m[1]);
const ids2 = [...html2.matchAll(/<input id="([^"]+)"/g)].map((m) => m[1]);
expect(ids1).toEqual(ids2);
});
});
+9 -6
View File
@@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { relayFormWiring } from '../../utils/form-relay-wiring'; import { relayFormWiring } from '../../utils/form-relay-wiring';
import { escapeHtml, escapeAttr } from '../../utils/escape'; import { escapeHtml, escapeAttr, slugId } from '../../utils/escape';
interface ContactFormField { interface ContactFormField {
type: 'text' | 'email' | 'tel' | 'textarea' | 'select'; type: 'text' | 'email' | 'tel' | 'textarea' | 'select';
@@ -180,18 +180,21 @@ ContactForm.craft = {
const inputBorder = props.inputBorder || '#d1d5db'; const inputBorder = props.inputBorder || '#d1d5db';
const inputStyleStr = `width:100%;padding:10px 14px;font-size:14px;font-family:Inter,sans-serif;border:1px solid ${inputBorder};border-radius:6px;background-color:${inputBg};color:#1f2937;box-sizing:border-box;outline:none`; const inputStyleStr = `width:100%;padding:10px 14px;font-size:14px;font-family:Inter,sans-serif;border:1px solid ${inputBorder};border-radius:6px;background-color:${inputBg};color:#1f2937;box-sizing:border-box;outline:none`;
const fieldsHtml = (props.fields || defaultFields).map((field) => { const fieldsHtml = (props.fields || defaultFields).map((field, i) => {
const reqStar = field.required ? '<span style="color:#ef4444;margin-left:2px">*</span>' : ''; const reqStar = field.required ? '<span style="color:#ef4444;margin-left:2px">*</span>' : '';
const labelHtml = `<label style="font-size:14px;font-weight:500;color:${labelColor}">${escapeHtml(field.label)}${reqStar}</label>`; // Deterministic id: index + slugified name, so repeated fields with the
// same name (or no name) still get unique, stable ids -- no Math.random.
const fieldId = `field-${i}-${slugId(field.name)}`;
const labelHtml = `<label for="${escapeAttr(fieldId)}" style="font-size:14px;font-weight:500;color:${labelColor}">${escapeHtml(field.label)}${reqStar}</label>`;
const reqAttr = field.required ? ' required' : ''; const reqAttr = field.required ? ' required' : '';
let inputHtml = ''; let inputHtml = '';
if (field.type === 'textarea') { if (field.type === 'textarea') {
inputHtml = `<textarea name="${escapeAttr(field.name)}" placeholder="${escapeAttr(field.placeholder)}" rows="4" style="${inputStyleStr};resize:vertical"${reqAttr}></textarea>`; inputHtml = `<textarea id="${escapeAttr(fieldId)}" name="${escapeAttr(field.name)}" placeholder="${escapeAttr(field.placeholder)}" rows="4" style="${inputStyleStr};resize:vertical"${reqAttr}></textarea>`;
} else if (field.type === 'select') { } else if (field.type === 'select') {
const opts = (field.options || []).map((o) => `<option value="${escapeAttr(o)}">${escapeHtml(o)}</option>`).join(''); const opts = (field.options || []).map((o) => `<option value="${escapeAttr(o)}">${escapeHtml(o)}</option>`).join('');
inputHtml = `<select name="${escapeAttr(field.name)}" style="${inputStyleStr};cursor:pointer"${reqAttr}><option value="">${escapeHtml(field.placeholder || 'Select...')}</option>${opts}</select>`; inputHtml = `<select id="${escapeAttr(fieldId)}" name="${escapeAttr(field.name)}" style="${inputStyleStr};cursor:pointer"${reqAttr}><option value="">${escapeHtml(field.placeholder || 'Select...')}</option>${opts}</select>`;
} else { } else {
inputHtml = `<input type="${field.type}" name="${escapeAttr(field.name)}" placeholder="${escapeAttr(field.placeholder)}" style="${inputStyleStr}"${reqAttr} />`; inputHtml = `<input id="${escapeAttr(fieldId)}" type="${field.type}" name="${escapeAttr(field.name)}" placeholder="${escapeAttr(field.placeholder)}" style="${inputStyleStr}"${reqAttr} />`;
} }
return `<div style="display:flex;flex-direction:column;gap:6px">${labelHtml}${inputHtml}</div>`; return `<div style="display:flex;flex-direction:column;gap:6px">${labelHtml}${inputHtml}</div>`;
}).join('\n '); }).join('\n ');
@@ -0,0 +1,29 @@
import { describe, test, expect } from 'vitest';
import { InputField } from './InputField';
const toHtml = (InputField as any).toHtml;
describe('InputField.toHtml accessibility (F2.1)', () => {
test('label for= matches input id=', () => {
const { html } = toHtml({ label: 'Your Name', name: 'name' }, '');
const forMatch = html.match(/<label for="([^"]+)"/);
const idMatch = html.match(/<input id="([^"]+)"/);
expect(forMatch).toBeTruthy();
expect(idMatch).toBeTruthy();
expect(forMatch![1]).toBe(idMatch![1]);
});
test('id is deterministic (derived from name, not random) -- stable across calls', () => {
const { html: html1 } = toHtml({ label: 'Email', name: 'email' }, '');
const { html: html2 } = toHtml({ label: 'Email', name: 'email' }, '');
const id1 = html1.match(/<input id="([^"]+)"/)![1];
const id2 = html2.match(/<input id="([^"]+)"/)![1];
expect(id1).toBe(id2);
});
test('no visible label: input gets aria-label from placeholder', () => {
const { html } = toHtml({ label: '', name: 'phone', placeholder: 'Phone number' }, '');
expect(html).not.toContain('<label');
expect(html).toContain('aria-label="Phone number"');
});
});
+9 -3
View File
@@ -1,7 +1,7 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr } from '../../utils/escape'; import { escapeHtml, escapeAttr, slugId } from '../../utils/escape';
interface InputFieldProps { interface InputFieldProps {
label?: string; label?: string;
@@ -95,13 +95,19 @@ InputField.craft = {
...props.style, ...props.style,
}); });
const reqAttr = props.required ? ' required' : ''; const reqAttr = props.required ? ' required' : '';
// Deterministic id derived from the field name (pure function of props,
// no Math.random) so the <label for> always matches the <input id>.
const fieldId = 'field-' + slugId(props.name, 'field');
const labelHtml = props.label const labelHtml = props.label
? `<label style="font-size:14px;font-weight:500;color:#18181b">${escapeHtml(props.label)}${props.required ? '<span style="color:#ef4444"> *</span>' : ''}</label>` ? `<label for="${escapeAttr(fieldId)}" style="font-size:14px;font-weight:500;color:#18181b">${escapeHtml(props.label)}${props.required ? '<span style="color:#ef4444"> *</span>' : ''}</label>`
: '';
const ariaLabelAttr = !props.label
? ` aria-label="${escapeAttr(props.placeholder || props.name || 'Input field')}"`
: ''; : '';
return { return {
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}> html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
${labelHtml} ${labelHtml}
<input type="${props.type || 'text'}" name="${escapeAttr(props.name || 'field')}" placeholder="${escapeAttr(props.placeholder || '')}"${reqAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box" /> <input id="${escapeAttr(fieldId)}" type="${props.type || 'text'}" name="${escapeAttr(props.name || 'field')}" placeholder="${escapeAttr(props.placeholder || '')}"${reqAttr}${ariaLabelAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box" />
</div>`, </div>`,
}; };
}; };
@@ -0,0 +1,21 @@
import { describe, test, expect } from 'vitest';
import { TextareaField } from './TextareaField';
const toHtml = (TextareaField as any).toHtml;
describe('TextareaField.toHtml accessibility (F2.1)', () => {
test('label for= matches textarea id=', () => {
const { html } = toHtml({ label: 'Message', name: 'message' }, '');
const forMatch = html.match(/<label for="([^"]+)"/);
const idMatch = html.match(/<textarea id="([^"]+)"/);
expect(forMatch).toBeTruthy();
expect(idMatch).toBeTruthy();
expect(forMatch![1]).toBe(idMatch![1]);
});
test('no visible label: textarea gets aria-label from placeholder', () => {
const { html } = toHtml({ label: '', name: 'notes', placeholder: 'Anything else?' }, '');
expect(html).not.toContain('<label');
expect(html).toContain('aria-label="Anything else?"');
});
});
+9 -3
View File
@@ -1,7 +1,7 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr } from '../../utils/escape'; import { escapeHtml, escapeAttr, slugId } from '../../utils/escape';
interface TextareaFieldProps { interface TextareaFieldProps {
label?: string; label?: string;
@@ -97,13 +97,19 @@ TextareaField.craft = {
...props.style, ...props.style,
}); });
const reqAttr = props.required ? ' required' : ''; const reqAttr = props.required ? ' required' : '';
// Deterministic id derived from the field name (pure function of props,
// no Math.random) so the <label for> always matches the <textarea id>.
const fieldId = 'field-' + slugId(props.name, 'message');
const labelHtml = props.label const labelHtml = props.label
? `<label style="font-size:14px;font-weight:500;color:#18181b">${escapeHtml(props.label)}${props.required ? '<span style="color:#ef4444"> *</span>' : ''}</label>` ? `<label for="${escapeAttr(fieldId)}" style="font-size:14px;font-weight:500;color:#18181b">${escapeHtml(props.label)}${props.required ? '<span style="color:#ef4444"> *</span>' : ''}</label>`
: '';
const ariaLabelAttr = !props.label
? ` aria-label="${escapeAttr(props.placeholder || props.name || 'Textarea field')}"`
: ''; : '';
return { return {
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}> html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
${labelHtml} ${labelHtml}
<textarea name="${escapeAttr(props.name || 'message')}" placeholder="${escapeAttr(props.placeholder || '')}" rows="${props.rows || 4}"${reqAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box;resize:vertical;font-family:inherit"></textarea> <textarea id="${escapeAttr(fieldId)}" name="${escapeAttr(props.name || 'message')}" placeholder="${escapeAttr(props.placeholder || '')}" rows="${props.rows || 4}"${reqAttr}${ariaLabelAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box;resize:vertical;font-family:inherit"></textarea>
</div>`, </div>`,
}; };
}; };
@@ -0,0 +1,16 @@
import { describe, test, expect } from 'vitest';
import { MapEmbed } from './MapEmbed';
const toHtml = (MapEmbed as any).toHtml;
describe('MapEmbed.toHtml iframe accessibility (F2.4)', () => {
test('iframe has a non-empty title attribute', () => {
const { html } = toHtml({ address: 'New York, NY' }, '');
expect(html).toMatch(/<iframe[^>]*title="[^"]+"/);
});
test('title reflects the configured address', () => {
const { html } = toHtml({ address: 'Golden Gate Bridge' }, '');
expect(html).toContain('title="Map of Golden Gate Bridge"');
});
});
+1 -1
View File
@@ -93,6 +93,6 @@ MapEmbed.craft = {
const src = buildMapUrl(address, zoom); const src = buildMapUrl(address, zoom);
return { return {
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><iframe src="${escapeAttr(safeUrl(src))}" loading="lazy" referrerpolicy="no-referrer-when-downgrade" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div>`, html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><iframe src="${escapeAttr(safeUrl(src))}" title="${escapeAttr(`Map of ${address}`)}" loading="lazy" referrerpolicy="no-referrer-when-downgrade" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div>`,
}; };
}; };
@@ -59,3 +59,15 @@ describe('VideoBlock URL parsing (D4)', () => {
expect(html).not.toContain('javascript:'); expect(html).not.toContain('javascript:');
}); });
}); });
describe('VideoBlock.toHtml iframe accessibility (F2.4)', () => {
test('normal-mode YouTube/Vimeo iframe has a title attribute', () => {
const { html } = toHtml({ videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' }, '');
expect(html).toMatch(/<iframe[^>]*title="[^"]+"/);
});
test('background-mode YouTube/Vimeo iframe has a title attribute', () => {
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789', isBackground: true }, '');
expect(html).toMatch(/<iframe[^>]*title="[^"]+"/);
});
});
+2 -2
View File
@@ -403,7 +403,7 @@ VideoBlock.craft = {
zIndex: '0', zIndex: '0',
pointerEvents: 'none', pointerEvents: 'none',
}); });
videoHtml = `<iframe src="${escapeAttr(safeUrl(iframeSrc))}" allow="autoplay; encrypted-media" allowfullscreen${ifrStyle ? ` style="${ifrStyle}"` : ''}></iframe>`; videoHtml = `<iframe src="${escapeAttr(safeUrl(iframeSrc))}" title="Embedded video" allow="autoplay; encrypted-media" allowfullscreen${ifrStyle ? ` style="${ifrStyle}"` : ''}></iframe>`;
} }
return { return {
@@ -436,7 +436,7 @@ VideoBlock.craft = {
border: 'none', border: 'none',
}); });
return { return {
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><div${containerStyle ? ` style="${containerStyle}"` : ''}><iframe src="${escapeAttr(safeUrl(iframeSrc))}" allow="autoplay; encrypted-media; picture-in-picture" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div></div>`, html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><div${containerStyle ? ` style="${containerStyle}"` : ''}><iframe src="${escapeAttr(safeUrl(iframeSrc))}" title="Embedded video" allow="autoplay; encrypted-media; picture-in-picture" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div></div>`,
}; };
} }
@@ -37,3 +37,12 @@ describe('Testimonials.toHtml single-layout export parity', () => {
expect(html).toContain('Name Three'); expect(html).toContain('Name Three');
}); });
}); });
describe('Testimonials.toHtml decorative star icons (F2.5)', () => {
test('star glyphs are aria-hidden', () => {
const { html } = toHtml({ testimonials, layout: 'grid' }, '');
const stars = html.match(/<i class="fa fa-star[^"]*"[^>]*>/g) || [];
expect(stars.length).toBeGreaterThan(0);
stars.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"'));
});
});
@@ -42,9 +42,9 @@ function renderStars(count: number, color: string): React.ReactNode {
function starsHtml(count: number, color: string): string { function starsHtml(count: number, color: string): string {
const stars = [1, 2, 3, 4, 5].map((i) => const stars = [1, 2, 3, 4, 5].map((i) =>
`<i class="fa ${i <= count ? 'fa-star' : 'fa-star-o'}" style="color:${color};font-size:14px"></i>` `<i class="fa ${i <= count ? 'fa-star' : 'fa-star-o'}" style="color:${color};font-size:14px" aria-hidden="true"></i>`
).join(''); ).join('');
return `<div style="display:flex;gap:2px;justify-content:center;margin-bottom:12px">${stars}</div>`; return `<div style="display:flex;gap:2px;justify-content:center;margin-bottom:12px" role="img" aria-label="Rating: ${count} out of 5">${stars}</div>`;
} }
export const Testimonials: UserComponent<TestimonialsProps> = ({ export const Testimonials: UserComponent<TestimonialsProps> = ({
+11
View File
@@ -78,3 +78,14 @@ export function safeUrl(s: string): string {
return trimmed; return trimmed;
} }
/**
* Derives a deterministic, HTML-id-safe slug from a field's `name`/`label`
* for wiring `<label for>` to a matching `<input id>` in exported markup.
* Pure function of the input string -- no Math.random/Date.now -- so the
* same field always gets the same id across export runs.
*/
export function slugId(s: string | undefined, fallback = 'field'): string {
const base = (s || '').toString().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
return base || fallback;
}