MDX/Youtube
Aspect Ratio4x3
Default
Multiple Videos
Short Url
With Caption
With Class Name
youtube.tsx
import type { FunctionComponent } from 'preact';
import { captionClass } from './caption-class';
/**
* The canonical youtube-nocookie embed-player URL for a video ID — the single
* source of truth for the embed-player domain. Both the on-page <iframe>
* (`toEmbedSrc`) and the VideoObject JSON-LD `embedUrl` (`buildVideoJsonLdProps`)
* derive from this, so the structured-data player URL can never drift from the
* rendered player. Google's video crawler associates a VideoObject with the
* on-page player by URL; a domain mismatch (e.g. JSON-LD pointing at
* `youtube.com` while the iframe is `youtube-nocookie.com`) is a known cause of
* the GSC "No video found on the video-playback page" indexing failure.
*/
export const youtubeNocookieEmbedUrl = (videoId: string): string =>
`https://www.youtube-nocookie.com/embed/${videoId}`;
/**
* Convert a YouTube watch/share URL to a youtube-nocookie.com embed URL.
* Returns null for any unrecognised or malformed URL instead of throwing,
* so callers that run at build time (e.g. <Youtube> SSR) do not abort the
* production build when content contains a bad URL.
*/
export const toEmbedSrc = (url: string): string | null => {
const parsed = parseYoutubeUrl(url);
if (!parsed) return null;
const { videoId, startTime } = parsed;
const embedUrl = youtubeNocookieEmbedUrl(videoId);
if (startTime !== null && Number.isFinite(startTime) && startTime >= 0) {
return `${embedUrl}?start=${startTime}`;
}
return embedUrl;
};
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 embedSrc = toEmbedSrc(url);
// Render a visible fallback for malformed URLs so a bad content URL does not
// abort the build or silently produce a broken iframe.
const frame = embedSrc ? (
<div
className={`border border-zd-white lg:max-w-3/4 mx-auto ${className}`}
style={{ aspectRatio }}
>
<iframe
width="560"
height="315"
src={embedSrc}
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>
) : (
<div className={`border border-zd-white/30 lg:max-w-3/4 mx-auto ${className}`}>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="block p-hgap-md text-center text-zd-subtext underline break-all"
>
{url}
</a>
</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 };
youtube.stories.tsx
import { Youtube } from './youtube';
import type { ControlsMap } from '../../sub-packages/styleguide-v2/src/data/control-types';
/**
* Youtube Component
*
* Embeds YouTube videos in MDX articles with privacy-enhanced mode.
* Features:
* - Supports multiple URL formats (youtube.com, youtu.be)
* - Uses youtube-nocookie.com for privacy
* - Responsive 16:9 aspect ratio
* - Lazy loading for performance
*/
export const meta = {
title: 'MDX/Youtube',
};
export const controls: ControlsMap = {
url: { type: 'text', default: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' },
};
/**
* Default - Standard YouTube URL
*
* Uses the standard youtube.com/watch?v= format
*/
export const Default = {
args: { url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' },
render: ({ url }: { url: string }) => <Youtube url={url} />,
};
/**
* Short URL format (youtu.be)
*/
export const ShortUrl = () => <Youtube url="https://youtu.be/dQw4w9WgXcQ" />;
/**
* With custom className
*/
export const WithClassName = () => (
<Youtube url="https://www.youtube.com/watch?v=dQw4w9WgXcQ" className="border-2 border-zd-link" />
);
/**
* Custom aspect ratio (4:3)
*/
export const AspectRatio4x3 = () => (
<Youtube url="https://www.youtube.com/watch?v=dQw4w9WgXcQ" aspectRatio="4 / 3" />
);
/**
* With caption — wraps in <figure> and renders a centered <figcaption>
*
* Uses the canonical caption styling shared with ArticleImage and YouTubeCaptureWip.
*/
export const WithCaption = () => (
<Youtube
url="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
caption="動画キャプションのサンプル: モジュラーシンセのパッチング解説"
/>
);
/**
* Multiple videos in sequence
*/
export const MultipleVideos = () => (
<div className="max-w-[800px]">
<h3 className="text-zd-white pb-vgap-sm">モジュラーシンセ デモ動画</h3>
<Youtube url="https://www.youtube.com/watch?v=dQw4w9WgXcQ" />
<h3 className="text-zd-white pb-vgap-sm">パッチング解説</h3>
<Youtube url="https://youtu.be/dQw4w9WgXcQ" />
</div>
);