import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'; import { GlobeIcon } from 'lucide-react'; import { classNames } from '../../lib/utils'; import { SERP_FONTS, SERP_WIDTHS, textWidth } from '../lib/text-width'; type Device = 'desktop' | 'mobile'; /** Where Google cuts, roughly, on each device, in characters. */ const LIMITS: Record = { desktop: { title: 60, description: 160 }, mobile: { title: 78, description: 120 }, }; const cut = (text: string, max: number): string => text.length > max ? `${text.slice(0, max - 3).trimEnd()}…` : text; /** * Cut where Google cuts on a desktop: by the width the text takes in its * font, at a word, with an ellipsis. Without a canvas to measure with, by * characters. */ const cutToWidth = ( text: string, font: string, maxPixels: number, maxChars: number ): string => { if (textWidth(text, font) === 0) { return cut(text, maxChars); } if (textWidth(text, font) <= maxPixels) { return text; } const words = text.split(' '); let shown = ''; for (const word of words) { const next = shown ? `${shown} ${word}` : word; if (textWidth(`${next} …`, font) > maxPixels) { break; } shown = next; } return `${(shown || text.slice(0, maxChars)).replace(/[\s,;:.]+$/, '')} …`; }; /** Google's desktop result, padding included: the title fits on one line in it. */ const DESKTOP_WIDTH = 640; /** Smaller than this the result can no longer be read: it scrolls sideways instead. */ const MIN_SCALE = 0.75; /** * The desktop result at Google's own width, scaled down to fit a narrow * sidebar: in the sidebar's 280 pixels the title wrapped over three or * four lines where Google shows one. Past a readable size it stops * shrinking and the box scrolls sideways. * * @param props - Component props. * @param props.children - The result. * @return The result at Google's width. */ const AtGoogleWidth = ({ children }: { children: ReactNode }): JSX.Element => { const outer = useRef(null); const inner = useRef(null); const [fit, setFit] = useState<{ scale: number; height?: number }>({ scale: 1, }); useLayoutEffect(() => { const box = outer.current; const content = inner.current; if (!box || !content || typeof ResizeObserver === 'undefined') { return undefined; } const measure = (): void => { const scale = Math.max( MIN_SCALE, Math.min(1, box.clientWidth / DESKTOP_WIDTH) ); setFit({ scale, height: content.offsetHeight * scale }); }; measure(); const observer = new ResizeObserver(measure); observer.observe(box); observer.observe(content); return () => observer.disconnect(); }, []); return (
{children}
); }; /** * How the page looks in Google's results, on a desktop or a phone: the * site line, the title and the description, cut where Google cuts them. */ export const SnippetPreview = ({ siteName, url, title, description, }: { siteName: string; url: string; title: string; description: string; }): JSX.Element => { const [device, setDevice] = useState('desktop'); const mobile = device === 'mobile'; const shownUrl = url .replace(/^https?:\/\//, '') .replace(/\/$/, '') .split('/') .join(' › '); const shownTitle = mobile ? cut(title, LIMITS.mobile.title) : cutToWidth( title, SERP_FONTS.title, SERP_WIDTHS.title, LIMITS.desktop.title ); const shownDescription = mobile ? cut(description, LIMITS.mobile.description) : cutToWidth( description, SERP_FONTS.description, SERP_WIDTHS.description, LIMITS.desktop.description ); const result = (
{siteName} {shownUrl}

{shownTitle || Your title}

{shownDescription || ( Your description, or the first sentences of the content. )}

); return (
{(['desktop', 'mobile'] as Device[]).map(option => ( ))}
{mobile ? result : {result}}
); };