Takazudo Modular Styleguide

MDX/Youtube

Aspect Ratio4x3
Default
Multiple Videos
Short Url
With Caption
With Class Name

Component Source

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 };

Story Source

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>
);