Site builder: security & data-loss hardening + unified asset picker + audit backlog #3
@@ -14,3 +14,16 @@ describe('MapEmbed.toHtml iframe accessibility (F2.4)', () => {
|
|||||||
expect(html).toContain('title="Map of Golden Gate Bridge"');
|
expect(html).toContain('title="Map of Golden Gate Bridge"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('MapEmbed.toHtml iframe src ampersand encoding (F-export review Minor)', () => {
|
||||||
|
test('the iframe src (built by string concatenation with literal &) emits & in the attribute, not a raw &', () => {
|
||||||
|
const { html } = toHtml({ address: 'New York, NY', zoom: 14 }, '');
|
||||||
|
const srcMatch = html.match(/<iframe src="([^"]+)"/);
|
||||||
|
expect(srcMatch).toBeTruthy();
|
||||||
|
// The raw src is `...q=...&z=14&output=embed` -- concatenated with
|
||||||
|
// literal `&`s -- so the emitted attribute must HTML-encode them.
|
||||||
|
expect(srcMatch![1]).toContain('&z=14');
|
||||||
|
expect(srcMatch![1]).toContain('&output=embed');
|
||||||
|
expect(srcMatch![1]).not.toMatch(/&(?!amp;)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -71,3 +71,18 @@ describe('VideoBlock.toHtml iframe accessibility (F2.4)', () => {
|
|||||||
expect(html).toMatch(/<iframe[^>]*title="[^"]+"/);
|
expect(html).toMatch(/<iframe[^>]*title="[^"]+"/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('VideoBlock.toHtml iframe src ampersand encoding (F-export review Minor)', () => {
|
||||||
|
test('embed params joined with literal & are HTML-entity-encoded in the emitted src attribute', () => {
|
||||||
|
// autoplay+muted+controls=false forces buildEmbedParams to concatenate
|
||||||
|
// multiple query params onto the URL with literal `&`s.
|
||||||
|
const { html } = toHtml(
|
||||||
|
{ videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', autoplay: true, muted: true, controls: false },
|
||||||
|
''
|
||||||
|
);
|
||||||
|
const srcMatch = html.match(/<iframe src="([^"]+)"/);
|
||||||
|
expect(srcMatch).toBeTruthy();
|
||||||
|
expect(srcMatch![1]).toMatch(/&/);
|
||||||
|
expect(srcMatch![1]).not.toMatch(/&(?!amp;)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ describe('ContentSlider.toHtml accessibility (F1.1)', () => {
|
|||||||
expect(html).toMatch(/<button[^>]*aria-label="Go to slide 3"/);
|
expect(html).toMatch(/<button[^>]*aria-label="Go to slide 3"/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('slides are wrapped in an aria-live="polite" region', () => {
|
test('slides are wrapped in an aria-live region', () => {
|
||||||
const { html } = toHtml({ slides }, '');
|
const { html } = toHtml({ slides }, '');
|
||||||
expect(html).toContain('aria-live="polite"');
|
expect(html).toMatch(/aria-live="(polite|off)"/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('decorative chevron icons in arrows are aria-hidden', () => {
|
test('decorative chevron icons in arrows are aria-hidden', () => {
|
||||||
@@ -34,3 +34,60 @@ describe('ContentSlider.toHtml accessibility (F1.1)', () => {
|
|||||||
expect(html).toMatch(/<i class="fa fa-chevron-right" aria-hidden="true"><\/i>/);
|
expect(html).toMatch(/<i class="fa fa-chevron-right" aria-hidden="true"><\/i>/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ContentSlider.toHtml deterministic + unique scope ids (thread node id)', () => {
|
||||||
|
test('same node id -> identical output across calls (deterministic, no Math.random)', () => {
|
||||||
|
const { html: html1 } = toHtml({ slides }, '', 'node-cs1');
|
||||||
|
const { html: html2 } = toHtml({ slides }, '', 'node-cs1');
|
||||||
|
expect(html1).toBe(html2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('different node ids -> different, non-colliding scope ids (identical slides, no collision)', () => {
|
||||||
|
const { html: html1 } = toHtml({ slides }, '', 'node-cs1');
|
||||||
|
const { html: html2 } = toHtml({ slides }, '', 'node-cs2');
|
||||||
|
const id1 = html1.match(/<section id="([^"]+)"/)![1];
|
||||||
|
const id2 = html2.match(/<section id="([^"]+)"/)![1];
|
||||||
|
expect(id1).not.toBe(id2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no nodeId (legacy 2-arg call): still deterministic across repeated calls, not random', () => {
|
||||||
|
const { html: html1 } = toHtml({ slides }, '');
|
||||||
|
const { html: html2 } = toHtml({ slides }, '');
|
||||||
|
expect(html1).toBe(html2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ContentSlider.toHtml autoplay silences aria-live and is pausable (F-export review Minor)', () => {
|
||||||
|
test('aria-live is "off" while autoplay is running, to avoid announcing every auto-rotation', () => {
|
||||||
|
const { html } = toHtml({ slides, autoplay: true }, '');
|
||||||
|
expect(html).toContain('aria-live="off"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aria-live stays "polite" when autoplay is disabled', () => {
|
||||||
|
const { html } = toHtml({ slides, autoplay: false }, '');
|
||||||
|
expect(html).toContain('aria-live="polite"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual navigation (next/prev/dot) marks the live region "polite"', () => {
|
||||||
|
const { html } = toHtml({ slides, autoplay: true }, '');
|
||||||
|
expect(html).toMatch(/setAttribute\(["']aria-live["'],\s*["']polite["']\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('autoplay pauses on hover and resumes on mouse leave', () => {
|
||||||
|
const { html } = toHtml({ slides, autoplay: true }, '');
|
||||||
|
expect(html).toMatch(/addEventListener\(["']mouseenter["']/);
|
||||||
|
expect(html).toMatch(/addEventListener\(["']mouseleave["']/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('autoplay pauses when the tab is hidden (visibilitychange) and the interval is clearable', () => {
|
||||||
|
const { html } = toHtml({ slides, autoplay: true }, '');
|
||||||
|
expect(html).toMatch(/visibilitychange/);
|
||||||
|
expect(html).toMatch(/clearInterval\(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no autoplay: no setInterval/hover/visibility wiring at all', () => {
|
||||||
|
const { html } = toHtml({ slides, autoplay: false }, '');
|
||||||
|
expect(html).not.toMatch(/setInterval\(/);
|
||||||
|
expect(html).not.toMatch(/visibilitychange/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { CSSProperties, useState, useEffect, useRef, useCallback } from 'react';
|
import React, { CSSProperties, useState, useEffect, useRef, useCallback } 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, safeUrl } from '../../utils/escape';
|
import { escapeHtml, escapeAttr, safeUrl, scopeId } from '../../utils/escape';
|
||||||
|
|
||||||
interface Slide {
|
interface Slide {
|
||||||
type: 'image' | 'content';
|
type: 'image' | 'content';
|
||||||
@@ -238,7 +238,7 @@ ContentSlider.craft = {
|
|||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(ContentSlider as any).toHtml = (props: ContentSliderProps, _childrenHtml: string) => {
|
(ContentSlider as any).toHtml = (props: ContentSliderProps, _childrenHtml: string, nodeId?: string) => {
|
||||||
const {
|
const {
|
||||||
slides = defaultSlides,
|
slides = defaultSlides,
|
||||||
autoplay = true,
|
autoplay = true,
|
||||||
@@ -250,7 +250,11 @@ ContentSlider.craft = {
|
|||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const items = slides.length > 0 ? slides : defaultSlides;
|
const items = slides.length > 0 ? slides : defaultSlides;
|
||||||
const uid = 'cs_' + Math.random().toString(36).slice(2, 8);
|
// Deterministic AND unique id, scoped on the Craft node id, for this
|
||||||
|
// slider's slide/dot element ids and inline-script globals -- so two
|
||||||
|
// ContentSlider instances (e.g. both left at default slides) don't
|
||||||
|
// collide and end up driving each other's rotation.
|
||||||
|
const uid = scopeId(nodeId, JSON.stringify(items) + interval, 'cs');
|
||||||
|
|
||||||
const sectionStyle = cssPropsToString({
|
const sectionStyle = cssPropsToString({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -299,9 +303,18 @@ ContentSlider.craft = {
|
|||||||
</div>`
|
</div>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
// Autoplay ticks call show() directly (internal), while manual nav goes
|
||||||
|
// through the exposed window[...] functions -- that split lets us mark
|
||||||
|
// the live region "polite" only on manual navigation, and keep it "off"
|
||||||
|
// while autoplay is silently auto-rotating, so screen readers aren't
|
||||||
|
// spammed with an announcement every `interval` ms (F-export review
|
||||||
|
// Minor).
|
||||||
|
const autoplayActive = autoplay && items.length > 1;
|
||||||
|
const liveAttr = autoplayActive ? 'off' : 'polite';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
html: `<section id="${uid}"${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||||
<div aria-live="polite">
|
<div id="${uid}_live" aria-live="${liveAttr}">
|
||||||
${slidesHtml}
|
${slidesHtml}
|
||||||
</div>
|
</div>
|
||||||
${arrowsHtml}
|
${arrowsHtml}
|
||||||
@@ -309,6 +322,8 @@ ContentSlider.craft = {
|
|||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
var current=0, total=${items.length}, uid="${uid}";
|
var current=0, total=${items.length}, uid="${uid}";
|
||||||
|
var liveRegion=document.getElementById(uid+"_live");
|
||||||
|
function markManualNav(){ if(liveRegion){ liveRegion.setAttribute("aria-live","polite"); } }
|
||||||
function show(idx){
|
function show(idx){
|
||||||
document.getElementById(uid+"_s"+current).style.opacity="0";
|
document.getElementById(uid+"_s"+current).style.opacity="0";
|
||||||
${showDots ? `document.getElementById(uid+"_d"+current).style.backgroundColor="rgba(255,255,255,0.5)";` : ''}
|
${showDots ? `document.getElementById(uid+"_d"+current).style.backgroundColor="rgba(255,255,255,0.5)";` : ''}
|
||||||
@@ -316,10 +331,23 @@ ContentSlider.craft = {
|
|||||||
document.getElementById(uid+"_s"+current).style.opacity="1";
|
document.getElementById(uid+"_s"+current).style.opacity="1";
|
||||||
${showDots ? `document.getElementById(uid+"_d"+current).style.backgroundColor="#ffffff";` : ''}
|
${showDots ? `document.getElementById(uid+"_d"+current).style.backgroundColor="#ffffff";` : ''}
|
||||||
}
|
}
|
||||||
window["${uid}_go"]=show;
|
window["${uid}_go"]=function(idx){ markManualNav(); show(idx); };
|
||||||
window["${uid}_next"]=function(){show(current+1);};
|
window["${uid}_next"]=function(){ markManualNav(); show(current+1); };
|
||||||
window["${uid}_prev"]=function(){show(current-1);};
|
window["${uid}_prev"]=function(){ markManualNav(); show(current-1); };
|
||||||
${autoplay && items.length > 1 ? `setInterval(function(){show(current+1);},${interval});` : ''}
|
${autoplayActive ? `
|
||||||
|
var timer=null;
|
||||||
|
function start(){ if(!timer && document.visibilityState!=="hidden"){ timer=setInterval(function(){show(current+1);},${interval}); } }
|
||||||
|
function stop(){ if(timer){ clearInterval(timer); timer=null; } }
|
||||||
|
var root=document.getElementById(uid);
|
||||||
|
if(root){
|
||||||
|
root.addEventListener("mouseenter", stop);
|
||||||
|
root.addEventListener("mouseleave", start);
|
||||||
|
}
|
||||||
|
document.addEventListener("visibilitychange", function(){
|
||||||
|
if(document.visibilityState==="hidden"){ stop(); } else { start(); }
|
||||||
|
});
|
||||||
|
start();
|
||||||
|
` : ''}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</section>`,
|
</section>`,
|
||||||
|
|||||||
Reference in New Issue
Block a user