import type { FunctionComponent } from 'preact';
import { captionClass } from './caption-class';
export const toEmbedSrc = (url: string): string => {
try {
const urlObj = new URL(url);
const allowedHosts = ['youtube.com', 'www.youtube.com', 'youtu.be', 'm.youtube.com'];
if (!allowedHosts.includes(urlObj.hostname)) {
throw new Error(`Invalid YouTube host: ${urlObj.hostname}`);
}
// Extract video ID from different YouTube URL formats
let videoId: string | null = null;
if (urlObj.hostname === 'youtu.be') {
// youtu.be/VIDEO_ID format
videoId = urlObj.pathname.slice(1).split('?')[0];
} else {
// youtube.com/watch?v=VIDEO_ID format
videoId = urlObj.searchParams.get('v');
}
if (!videoId || videoId.length !== 11) {
throw new Error(`Invalid YouTube video ID: ${videoId}`);
}
// Use privacy-enhanced mode (youtube-nocookie.com)
const embedUrl = `https://www.youtube-nocookie.com/embed/${videoId}`;
// Preserve start time (?t=N → ?start=N for embed).
// Sanitize: only emit ?start=N when the parsed integer is finite and non-negative.
const startTimeRaw = urlObj.searchParams.get('t');
if (startTimeRaw !== null) {
const startTime = parseInt(startTimeRaw, 10);
if (Number.isFinite(startTime) && startTime >= 0) {
return `${embedUrl}?start=${startTime}`;
}
}
return embedUrl;
} catch (error) {
throw new Error(`Invalid YouTube URL: ${url} - ${error}`);
}
};
interface YoutubeProps {
url: string;
className?: string;
aspectRatio?: string;
/** Optional user-visible caption rendered below the video frame */
caption?: string;
}
const Youtube: FunctionComponent<YoutubeProps> = ({
url,
className = '',
aspectRatio = '16 / 9',
caption,
}) => {
const frame = (
<div
className={`border border-zd-white lg:max-w-3/4 mx-auto ${className}`}
style={{ aspectRatio }}
>
<iframe
width="560"
height="315"
src={toEmbedSrc(url)}
title="YouTube video player"
allow="accelerometer; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
className={'block w-full h-full border-none'}
></iframe>
</div>
);
return (
<div className="pb-vgap-lg">
{caption ? (
<figure>
{frame}
<figcaption className={captionClass}>{caption}</figcaption>
</figure>
) : (
frame
)}
</div>
);
};
/**
* Parse YouTube URL to extract video ID and start time.
* Returns null if the URL is invalid.
*/
export const parseYoutubeUrl = (
url: string,
): { videoId: string; startTime: number | null } | null => {
try {
const urlObj = new URL(url);
const allowedHosts = ['youtube.com', 'www.youtube.com', 'youtu.be', 'm.youtube.com'];
if (!allowedHosts.includes(urlObj.hostname)) return null;
let videoId: string | null = null;
if (urlObj.hostname === 'youtu.be') {
videoId = urlObj.pathname.slice(1).split('?')[0];
} else {
videoId = urlObj.searchParams.get('v');
}
if (!videoId || videoId.length !== 11) return null;
const t = urlObj.searchParams.get('t');
const startTime = t ? parseInt(t, 10) : null;
return { videoId, startTime };
} catch {
return null;
}
};
export { Youtube };