@automattic/social-previews
Version:
A suite of components to generate previews for a post for both social and search engines.
1 lines • 193 kB
Source Map (JSON)
{"version":3,"sources":["../src/helpers.tsx","../src/site-icon-with-fallback.tsx","../src/icons/globe-icon.tsx","../src/google-search-preview/index.tsx","../src/twitter-preview/card.tsx","../src/twitter-preview/footer.tsx","../src/twitter-preview/header.tsx","../src/twitter-preview/media.tsx","../src/twitter-preview/quote-tweet.tsx","../src/avatar-with-fallback.tsx","../src/twitter-preview/sidebar.tsx","../src/twitter-preview/text.tsx","../src/twitter-preview/post-preview.tsx","../src/twitter-preview/link-preview.tsx","../src/twitter-preview/previews.tsx","../src/shared/section-heading/index.tsx","../src/linkedin-preview/post-preview.tsx","../src/shared/expandable-text/index.tsx","../src/linkedin-preview/constants.ts","../src/linkedin-preview/link-preview.tsx","../src/linkedin-preview/previews.tsx","../src/tumblr-preview/link-preview.tsx","../src/tumblr-preview/helpers.ts","../src/tumblr-preview/post/actions/index.tsx","../src/tumblr-preview/post/icons/index.tsx","../src/tumblr-preview/post/header/index.tsx","../src/tumblr-preview/post-preview.tsx","../src/tumblr-preview/previews.tsx","../src/facebook-preview/previews.tsx","../src/facebook-preview/link-preview.tsx","../src/constants.ts","../src/facebook-preview/helpers.ts","../src/facebook-preview/custom-text.tsx","../src/facebook-preview/hooks/use-image-hook.ts","../src/facebook-preview/post/actions/index.tsx","../src/facebook-preview/post/icons/index.tsx","../src/facebook-preview/post/header/index.tsx","../src/facebook-preview/link-preview-details.tsx","../src/facebook-preview/post-preview.tsx","../src/mastodon-preview/post/actions/index.tsx","../src/mastodon-preview/post/card/index.tsx","../src/mastodon-preview/constants.ts","../src/mastodon-preview/helpers.ts","../src/mastodon-preview/post/header/index.tsx","../src/mastodon-preview/post/icons/index.tsx","../src/mastodon-preview/link-preview.tsx","../src/mastodon-preview/post-preview.tsx","../src/mastodon-preview/post/body/index.tsx","../src/mastodon-preview/previews.tsx","../src/nextdoor-preview/post-preview.tsx","../src/nextdoor-preview/constants.ts","../src/nextdoor-preview/footer-actions.tsx","../src/nextdoor-preview/icons/comment-icon.tsx","../src/nextdoor-preview/icons/like-icon.tsx","../src/nextdoor-preview/icons/share-icon.tsx","../src/nextdoor-preview/icons/chevron-icon.tsx","../src/nextdoor-preview/icons/default-image.tsx","../src/nextdoor-preview/icons/globe-icon.tsx","../src/nextdoor-preview/link-preview.tsx","../src/nextdoor-preview/previews.tsx","../src/bluesky-preview/post-preview.tsx","../src/bluesky-preview/post/actions/index.tsx","../src/bluesky-preview/helpers.ts","../src/bluesky-preview/post/body/index.tsx","../src/bluesky-preview/post/card/index.tsx","../src/bluesky-preview/post/header/index.tsx","../src/bluesky-preview/post/sidebar/index.tsx","../src/bluesky-preview/link-preview.tsx","../src/bluesky-preview/previews.tsx","../src/threads-preview/link-preview.tsx","../src/threads-preview/card.tsx","../src/threads-preview/helpers.ts","../src/threads-preview/footer.tsx","../src/threads-preview/header.tsx","../src/threads-preview/media.tsx","../src/threads-preview/sidebar.tsx","../src/threads-preview/post-preview.tsx","../src/threads-preview/previews.tsx","../src/instagram-preview/post-preview.tsx","../src/instagram-preview/constants.tsx","../src/instagram-preview/icons/bookmark.tsx","../src/instagram-preview/icons/comment.tsx","../src/instagram-preview/icons/heart.tsx","../src/instagram-preview/icons/menu.tsx","../src/instagram-preview/icons/share.tsx","../src/instagram-preview/previews.tsx"],"sourcesContent":["import { createInterpolateElement } from '@wordpress/element';\nimport { sprintf } from '@wordpress/i18n';\n\nexport type Formatter< Options = unknown > = ( text: string, options?: Options ) => string;\ntype AugmentFormatterReturnType< T extends Formatter, TNewReturn > = (\n\t...a: Parameters< T >\n) => ReturnType< T > | TNewReturn;\ntype ConditionalFormatter = AugmentFormatterReturnType< Formatter, boolean >;\ntype NullableFormatter = AugmentFormatterReturnType< Formatter, undefined >;\n\nexport const baseDomain: Formatter = url => {\n\t// Strip leading protocol\n\tconst withoutProtocol = url.replace( /^[^/]+:\\/\\//, '' );\n\t// Strip everything after the domain using indexOf to avoid ReDoS\n\tconst slashIndex = withoutProtocol.indexOf( '/' );\n\treturn slashIndex === -1 ? withoutProtocol : withoutProtocol.substring( 0, slashIndex );\n};\n\n/**\n * Counts Unicode codepoints rather than UTF-16 code units, so an emoji like 🚀\n * is one character (matching PHP `mb_strlen`) rather than two. Lets the JS\n * preview's truncation align with the backend's logical-char counting.\n *\n * @param text - The string to measure.\n * @return The codepoint count.\n */\nconst codepointLength = ( text: string ): number => Array.from( text ).length;\n\n/**\n * Slices a string by Unicode codepoints rather than UTF-16 code units, so\n * surrogate pairs (most emoji) are never split mid-character.\n *\n * @param text - The string to slice.\n * @param start - Start index, in codepoints.\n * @param end - End index, in codepoints (exclusive).\n * @return The sliced string.\n */\nconst codepointSlice = ( text: string, start: number, end?: number ): string =>\n\tArray.from( text ).slice( start, end ).join( '' );\n\nexport const shortEnough: ( n: number ) => ConditionalFormatter = limit => title =>\n\tcodepointLength( title ) <= limit ? title : false;\n\nexport const truncatedAtSpace: ( a: number, b: number ) => ConditionalFormatter =\n\t( lower, upper ) => fullTitle => {\n\t\tconst title = fullTitle.slice( 0, upper );\n\t\tconst lastSpace = title.lastIndexOf( ' ' );\n\n\t\treturn lastSpace > lower && lastSpace < upper\n\t\t\t? title.slice( 0, lastSpace ).concat( '…' )\n\t\t\t: false;\n\t};\n\nexport const hardTruncation: ( n: number ) => Formatter = limit => title =>\n\tcodepointSlice( title, 0, limit ).concat( '…' );\n\nexport const firstValid: ( ...args: ConditionalFormatter[] ) => NullableFormatter =\n\t( ...predicates ) =>\n\ta =>\n\t\t( predicates.find( p => false !== p( a ) ) as Formatter )?.( a );\n\nexport const stripHtmlTags: Formatter< Array< string > > = ( description, allowedTags = [] ) => {\n\tconst pattern = new RegExp( `(<([^${ allowedTags.join( '' ) }>]+)>)`, 'gi' );\n\n\treturn description ? description.replace( pattern, '' ) : '';\n};\n\n/**\n * For social note posts we use the first 50 characters of the description.\n * @param description - The post description.\n * @return The first 50 characters of the description.\n */\nexport const getTitleFromDescription = ( description: string ): string => {\n\treturn stripHtmlTags( description ).substring( 0, 50 );\n};\n\nexport const hasTag = ( text: string, tag: string ): boolean => {\n\tconst pattern = new RegExp( `<${ tag }[^>]*>`, 'gi' );\n\n\treturn pattern.test( text );\n};\n\nexport const formatNextdoorDate = new Intl.DateTimeFormat( 'en-GB', {\n\t// Result: \"7 Oct\", \"31 Dec\"\n\tday: 'numeric',\n\tmonth: 'short',\n} ).format;\n\nexport const formatThreadsDate = new Intl.DateTimeFormat( 'en-US', {\n\t// Result: \"'06/21/2024\"\n\tday: '2-digit',\n\tmonth: '2-digit',\n\tyear: 'numeric',\n} ).format;\n\nexport const formatTweetDate = new Intl.DateTimeFormat( 'en-US', {\n\t// Result: \"Apr 7\", \"Dec 31\"\n\tmonth: 'short',\n\tday: 'numeric',\n} ).format;\n\nexport const formatMastodonDate = new Intl.DateTimeFormat( 'en-US', {\n\t// Result: \"Apr 7, 2024\", \"Dec 31, 2023\"\n\tmonth: 'short',\n\tday: 'numeric',\n\tyear: 'numeric',\n} ).format;\n\nexport type Platform =\n\t| 'bluesky'\n\t| 'facebook'\n\t| 'instagram'\n\t| 'linkedin'\n\t| 'mastodon'\n\t| 'nextdoor'\n\t| 'threads'\n\t| 'tumblr'\n\t| 'twitter';\n\ntype PreviewTextOptions = {\n\tplatform: Platform;\n\tmaxChars?: number;\n\tmaxLines?: number;\n\thyperlinkUrls?: boolean;\n\thyperlinkHashtags?: boolean;\n\thashtagDomain?: string;\n};\n\nexport const hashtagUrlMap = {\n\ttwitter: 'https://twitter.com/hashtag/%1$s',\n\tfacebook: 'https://www.facebook.com/hashtag/%1$s',\n\tlinkedin: 'https://www.linkedin.com/feed/hashtag/?keywords=%1$s',\n\tinstagram: 'https://www.instagram.com/explore/tags/%1$s',\n\tmastodon: 'https://%2$s/tags/%1$s',\n\tnextdoor: 'https://nextdoor.com/hashtag/%1$s',\n\tthreads: 'https://www.threads.net/search?q=%1$s&serp_type=tags',\n\ttumblr: 'https://www.tumblr.com/tagged/%1$s',\n\tbluesky: 'https://bsky.app/hashtag/%1$s',\n} as const;\n\n/**\n * Prepares the text for the preview.\n * @param {string} text - The text to prepare.\n * @param {PreviewTextOptions} options - The options for preparing the text.\n * @return The prepared text as React nodes.\n */\nexport function preparePreviewText( text: string, options: PreviewTextOptions ): React.ReactNode {\n\tconst {\n\t\tplatform,\n\t\tmaxChars,\n\t\tmaxLines,\n\t\thyperlinkHashtags = true,\n\t\t// Instagram doesn't support hyperlink URLs at the moment.\n\t\thyperlinkUrls = 'instagram' !== platform,\n\t} = options;\n\n\tlet result = stripHtmlTags( text );\n\n\t// Replace multiple new lines (2+) with 2 new lines\n\t// There can be any whitespace characters in empty lines\n\t// That is why \"\\s*\"\n\tresult = result.replaceAll( /(?:\\s*[\\n\\r]){2,}/g, '\\n\\n' );\n\n\tif ( maxChars && codepointLength( result ) > maxChars ) {\n\t\tresult = hardTruncation( maxChars )( result );\n\t}\n\n\tif ( maxLines ) {\n\t\tconst lines = result.split( '\\n' );\n\n\t\tif ( lines.length > maxLines ) {\n\t\t\tresult = lines.slice( 0, maxLines ).join( '\\n' );\n\t\t}\n\t}\n\n\tconst componentMap: Record< string, React.ReactElement > = {};\n\n\tif ( hyperlinkUrls ) {\n\t\t// Convert URLs to hyperlinks.\n\t\t// TODO: Use a better regex here to match the URLs without protocol.\n\t\tconst urls = result.match( /(https?:\\/\\/\\S+)/g ) || [];\n\n\t\t/**\n\t\t * BEFORE:\n\t\t * result = 'Check out this cool site: https://wordpress.org and this one: https://wordpress.com'\n\t\t */\n\t\turls.forEach( ( url, index ) => {\n\t\t\t// Add the element to the component map.\n\t\t\tcomponentMap[ `Link${ index }` ] = (\n\t\t\t\t<a href={ url } rel=\"noopener noreferrer\" target=\"_blank\">\n\t\t\t\t\t{ url }\n\t\t\t\t</a>\n\t\t\t);\n\t\t\t// Replace the URL with the component placeholder.\n\t\t\tresult = result.replace( url, `<Link${ index } />` );\n\t\t} );\n\t\t/**\n\t\t * AFTER:\n\t\t * result = 'Check out this cool site: <Link0 /> and this one: <Link1 />'\n\t\t * componentMap = {\n\t\t * Link0: <a href=\"https://wordpress.org\" ...>https://wordpress.org</a>,\n\t\t * Link1: <a href=\"https://wordpress.com\" ...>https://wordpress.com</a>\n\t\t * }\n\t\t */\n\t}\n\n\t// Convert hashtags to hyperlinks.\n\tif ( hyperlinkHashtags && hashtagUrlMap[ platform ] ) {\n\t\t/**\n\t\t * We need to ensure that only the standalone hashtags are matched.\n\t\t * For example, we don't want to match the hash in the URL.\n\t\t * Thus we need to match the whitespace character before the hashtag or the beginning of the string.\n\t\t */\n\t\tconst hashtags = result.matchAll( /(^|\\s)#(\\w+)/g );\n\n\t\tconst hashtagUrl = hashtagUrlMap[ platform ];\n\n\t\t/**\n\t\t * BEFORE:\n\t\t * result = `#breaking text with a #hashtag on the #web\n\t\t * with a url https://github.com/Automattic/wp-calypso#security that has a hash in it`\n\t\t */\n\t\t[ ...hashtags ].forEach( ( [ fullMatch, whitespace, hashtag ], index ) => {\n\t\t\tconst url = sprintf( hashtagUrl, hashtag, options.hashtagDomain );\n\n\t\t\t// Add the element to the component map.\n\t\t\tcomponentMap[ `Hashtag${ index }` ] = (\n\t\t\t\t<a href={ url } rel=\"noopener noreferrer\" target=\"_blank\">\n\t\t\t\t\t{ `#${ hashtag }` }\n\t\t\t\t</a>\n\t\t\t);\n\n\t\t\t// Replace the hashtag with the component placeholder.\n\t\t\tresult = result.replace( fullMatch, `${ whitespace }<Hashtag${ index } />` );\n\t\t} );\n\t\t/**\n\t\t * AFTER:\n\t\t * result = `<Hashtag0 /> text with a <Hashtag1 /> on the <Hashtag2 />\n\t\t * with a url https://github.com/Automattic/wp-calypso#security that has a hash in it`\n\t\t *\n\t\t * componentMap = {\n\t\t * Hashtag0: <a href=\"https://twitter.com/hashtag/breaking\" ...>#breaking</a>,\n\t\t * Hashtag1: <a href=\"https://twitter.com/hashtag/hashtag\" ...>#hashtag</a>,\n\t\t * Hashtag2: <a href=\"https://twitter.com/hashtag/web\" ...>#web</a>\n\t\t * }\n\t\t */\n\t}\n\n\t// Convert newlines to <br> tags.\n\t/**\n\t * BEFORE:\n\t * result = 'This is a text\\nwith a newline\\nin it'\n\t */\n\tresult = result.replace( /\\n/g, '<br />' );\n\tcomponentMap.br = <br />;\n\t/**\n\t * AFTER:\n\t * result = 'This is a text<br />with a newline<br />in it'\n\t * componentMap = { br: <br /> }\n\t */\n\n\treturn createInterpolateElement( result, componentMap );\n}\n","import { useCallback, useState } from 'react';\nimport { GlobeIcon } from './icons/globe-icon';\n\nexport type SiteIconWithFallbackProps = {\n\talt?: string;\n\tsrc?: string;\n\tclassName?: string;\n\tfallback?: React.ReactNode;\n};\n\n/**\n * Renders a default site icon: a neutral grey circle with a globe glyph,\n * matching what Google's search results show for sites without a favicon.\n * The wrapping span adopts the caller's `className` so the size is inherited\n * from whatever rule the preview already has on that class.\n *\n * @param {Pick< SiteIconWithFallbackProps, 'className' >} props - The wrapper props.\n * @return The DefaultSiteIcon component.\n */\nexport function DefaultSiteIcon( { className }: Pick< SiteIconWithFallbackProps, 'className' > ) {\n\treturn (\n\t\t<span\n\t\t\tclassName={ className }\n\t\t\taria-hidden=\"true\"\n\t\t\tstyle={ {\n\t\t\t\tdisplay: 'inline-flex',\n\t\t\t\talignItems: 'center',\n\t\t\t\tjustifyContent: 'center',\n\t\t\t\tbackgroundColor: '#e8eaed',\n\t\t\t\tcolor: '#5f6368',\n\t\t\t\tborderRadius: '50%',\n\t\t\t} }\n\t\t>\n\t\t\t<GlobeIcon style={ { width: '60%', height: '60%' } } />\n\t\t</span>\n\t);\n}\n\n/**\n * Renders a site icon image with a fallback to a default globe icon if no URL\n * is provided or the URL fails to load.\n *\n * @param {SiteIconWithFallbackProps} props - The props for the SiteIconWithFallback component.\n *\n * @return The SiteIconWithFallback component.\n */\nexport function SiteIconWithFallback( {\n\tsrc: siteIconUrl,\n\talt = '',\n\tclassName,\n\tfallback = <DefaultSiteIcon className={ className } />,\n}: SiteIconWithFallbackProps ) {\n\t// Use state to track if the image URL has encountered an error\n\tconst [ imageUrlWithError, setImageUrlWithError ] = useState( '' );\n\n\tconst onError = useCallback< React.ReactEventHandler< HTMLImageElement > >( event => {\n\t\tsetImageUrlWithError( ( event.target as HTMLImageElement ).src );\n\t}, [] );\n\n\tconst showIcon =\n\t\tsiteIconUrl &&\n\t\t// Check if the image URL with error is different from the provided site icon URL\n\t\t// to ensure that a change in siteIconUrl resets the error state\n\t\timageUrlWithError !== siteIconUrl;\n\n\treturn showIcon ? (\n\t\t<img src={ siteIconUrl } alt={ alt } onError={ onError } className={ className } />\n\t) : (\n\t\tfallback\n\t);\n}\n","/**\n * Globe Icon Component.\n *\n * Uses Google's globe icon to match what Google Search results show for sites\n * without a favicon.\n *\n * Accepts any standard SVG props (e.g. `width`, `height`, `className`, `style`)\n * so consumers can size and style it to fit their context.\n *\n * @param props - Standard SVG props.\n * @return The Globe SVG icon component.\n */\nexport function GlobeIcon( props: React.SVGProps< SVGSVGElement > ) {\n\treturn (\n\t\t<svg\n\t\t\tfocusable=\"false\"\n\t\t\taria-hidden=\"true\"\n\t\t\txmlns=\"http://www.w3.org/2000/svg\"\n\t\t\tviewBox=\"0 0 24 24\"\n\t\t\twidth=\"14\"\n\t\t\theight=\"14\"\n\t\t\t{ ...props }\n\t\t>\n\t\t\t<path\n\t\t\t\tfill=\"currentColor\"\n\t\t\t\td=\"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z\"\n\t\t\t></path>\n\t\t</svg>\n\t);\n}\n","import {\n\tfirstValid,\n\thardTruncation,\n\tshortEnough,\n\ttruncatedAtSpace,\n\tstripHtmlTags,\n\tbaseDomain,\n} from '../helpers';\nimport { SiteIconWithFallback } from '../site-icon-with-fallback';\nimport { SocialPreviewBaseProps } from '../types';\n\nimport './style.scss';\n\nconst URL_LENGTH = 68;\nconst TITLE_LENGTH = 63;\nconst DESCRIPTION_LENGTH = 160;\n\nconst googleUrl = ( url: string ) => {\n\tconst protocol = url.startsWith( 'https://' ) ? 'https://' : 'http://';\n\n\tconst breadcrumb = protocol + url.replace( protocol, '' ).split( '/' ).join( ' › ' );\n\n\tconst truncateBreadcrumb = firstValid( shortEnough( URL_LENGTH ), hardTruncation( URL_LENGTH ) );\n\n\treturn truncateBreadcrumb( breadcrumb );\n};\n\nconst googleTitle = firstValid(\n\tshortEnough( TITLE_LENGTH ),\n\ttruncatedAtSpace( TITLE_LENGTH - 40, TITLE_LENGTH + 10 ),\n\thardTruncation( TITLE_LENGTH )\n);\n\nconst googleDescription = firstValid(\n\tshortEnough( DESCRIPTION_LENGTH ),\n\ttruncatedAtSpace( DESCRIPTION_LENGTH - 80, DESCRIPTION_LENGTH + 10 ),\n\thardTruncation( DESCRIPTION_LENGTH )\n);\n\nexport type GoogleSearchPreviewProps = Omit< SocialPreviewBaseProps, 'image' > & {\n\tsiteIcon?: string;\n\tsiteTitle?: string;\n};\n\nexport const GoogleSearchPreview: React.FC< Partial< GoogleSearchPreviewProps > > = ( {\n\tdescription = '',\n\tsiteIcon,\n\tsiteTitle,\n\ttitle = '',\n\turl = '',\n} ) => {\n\tconst domain = baseDomain( url );\n\n\treturn (\n\t\t<div className=\"search-preview\">\n\t\t\t<div className=\"search-preview__display\">\n\t\t\t\t<div className=\"search-preview__header\">\n\t\t\t\t\t<div className=\"search-preview__branding\">\n\t\t\t\t\t\t<SiteIconWithFallback className=\"search-preview__icon\" src={ siteIcon } />\n\t\t\t\t\t\t<div className=\"search-preview__site\">\n\t\t\t\t\t\t\t<div className=\"search-preview__site--title\">{ siteTitle || domain }</div>\n\t\t\t\t\t\t\t<div className=\"search-preview__url\">{ googleUrl( url ) }</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"search-preview__menu\">\n\t\t\t\t\t\t<svg focusable=\"false\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\">\n\t\t\t\t\t\t\t<path d=\"M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z\"></path>\n\t\t\t\t\t\t</svg>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"search-preview__title\">{ googleTitle( title ) }</div>\n\t\t\t\t<div className=\"search-preview__description\">\n\t\t\t\t\t{ googleDescription( stripHtmlTags( description ) ) }\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t);\n};\n","import clsx from 'clsx';\nimport { baseDomain, firstValid, hardTruncation, shortEnough, stripHtmlTags } from '../helpers';\nimport { TwitterCardProps } from './types';\n\nconst DESCRIPTION_LENGTH = 280;\n\nconst twitterDescription = firstValid(\n\tshortEnough( DESCRIPTION_LENGTH ),\n\thardTruncation( DESCRIPTION_LENGTH )\n);\n\nexport const Card: React.FC< TwitterCardProps > = ( {\n\tdescription,\n\timage,\n\ttitle,\n\tcardType,\n\turl,\n} ) => {\n\tconst cardClassNames = clsx( `twitter-preview__card-${ cardType }`, {\n\t\t'twitter-preview__card-has-image': !! image,\n\t} );\n\n\treturn (\n\t\t<div className=\"twitter-preview__card\">\n\t\t\t<div className={ cardClassNames }>\n\t\t\t\t{ image && <img className=\"twitter-preview__card-image\" src={ image } alt=\"\" /> }\n\t\t\t\t<div className=\"twitter-preview__card-body\">\n\t\t\t\t\t<div className=\"twitter-preview__card-url\">{ baseDomain( url || '' ) }</div>\n\t\t\t\t\t<div className=\"twitter-preview__card-title\">{ title }</div>\n\t\t\t\t\t<div className=\"twitter-preview__card-description\">\n\t\t\t\t\t\t{ twitterDescription( stripHtmlTags( description ) ) }\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t);\n};\n","export const Footer: React.FC = () => {\n\treturn (\n\t\t<div className=\"twitter-preview__footer\">\n\t\t\t<span className=\"twitter-preview__icon-replies\">\n\t\t\t\t<svg viewBox=\"0 0 24 24\">\n\t\t\t\t\t<path d=\"M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01zm8.005-6c-3.317 0-6.005 2.69-6.005 6 0 3.37 2.77 6.08 6.138 6.01l.351-.01h1.761v2.3l5.087-2.81c1.951-1.08 3.163-3.13 3.163-5.36 0-3.39-2.744-6.13-6.129-6.13H9.756z\"></path>\n\t\t\t\t</svg>\n\t\t\t</span>\n\t\t\t<span className=\"twitter-preview__icon-retweets\">\n\t\t\t\t<svg viewBox=\"0 0 24 24\">\n\t\t\t\t\t<path d=\"M4.5 3.88l4.432 4.14-1.364 1.46L5.5 7.55V16c0 1.1.896 2 2 2H13v2H7.5c-2.209 0-4-1.79-4-4V7.55L1.432 9.48.068 8.02 4.5 3.88zM16.5 6H11V4h5.5c2.209 0 4 1.79 4 4v8.45l2.068-1.93 1.364 1.46-4.432 4.14-4.432-4.14 1.364-1.46 2.068 1.93V8c0-1.1-.896-2-2-2z\"></path>\n\t\t\t\t</svg>\n\t\t\t</span>\n\t\t\t<span className=\"twitter-preview__icon-likes\">\n\t\t\t\t<svg viewBox=\"0 0 24 24\">\n\t\t\t\t\t<path d=\"M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91zm4.187 7.69c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.09 3.368.56 4.798 2.01 1.429-1.45 3.146-2.1 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z\"></path>\n\t\t\t\t</svg>\n\t\t\t</span>\n\t\t\t<span className=\"twitter-preview__icon-analytics\">\n\t\t\t\t<svg viewBox=\"0 0 24 24\">\n\t\t\t\t\t<path d=\"M8.75 21V3h2v18h-2zM18 21V8.5h2V21h-2zM4 21l.004-10h2L6 21H4zm9.248 0v-7h2v7h-2z\"></path>\n\t\t\t\t</svg>\n\t\t\t</span>\n\t\t\t<span className=\"twitter-preview__icon-share\">\n\t\t\t\t<svg viewBox=\"0 0 24 24\">\n\t\t\t\t\t<path d=\"M12 2.59l5.7 5.7-1.41 1.42L13 6.41V16h-2V6.41l-3.3 3.3-1.41-1.42L12 2.59zM21 15l-.02 3.51c0 1.38-1.12 2.49-2.5 2.49H5.5C4.11 21 3 19.88 3 18.5V15h2v3.5c0 .28.22.5.5.5h12.98c.28 0 .5-.22.5-.5L19 15h2z\"></path>\n\t\t\t\t</svg>\n\t\t\t</span>\n\t\t</div>\n\t);\n};\n","import { __ } from '@wordpress/i18n';\nimport { formatTweetDate } from '../helpers';\nimport { HeaderProps } from './types';\n\nexport const Header: React.FC< HeaderProps > = ( { name, screenName, date } ) => {\n\treturn (\n\t\t<div className=\"twitter-preview__header\">\n\t\t\t<span className=\"twitter-preview__name\">\n\t\t\t\t{ name || __( 'Account Name', 'social-previews' ) }\n\t\t\t</span>\n\t\t\t<span className=\"twitter-preview__screen-name\">{ screenName || '@account' }</span>\n\t\t\t<span>·</span>\n\t\t\t<span className=\"twitter-preview__date\">{ formatTweetDate( date || Date.now() ) }</span>\n\t\t</div>\n\t);\n};\n","import clsx from 'clsx';\nimport { Fragment } from 'react';\nimport { MediaProps } from './types';\n\nexport const Media: React.FC< MediaProps > = ( { media } ) => {\n\t// Ensure we're only trying to show valid media, and the correct quantity.\n\tconst filteredMedia = media\n\t\t// Only image/ and video/ mime types are supported.\n\t\t.filter(\n\t\t\tmediaItem => mediaItem.type.startsWith( 'image/' ) || mediaItem.type.startsWith( 'video/' )\n\t\t)\n\t\t.filter( ( mediaItem, idx, array ) => {\n\t\t\t// We'll always keep the first item.\n\t\t\tif ( 0 === idx ) {\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\t// If the first item was a video or GIF, discard all subsequent items.\n\t\t\tif ( array[ 0 ].type.startsWith( 'video/' ) || 'image/gif' === array[ 0 ].type ) {\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\t// The first item wasn't a video or GIF, so discard all subsequent videos and GIFs.\n\t\t\tif ( mediaItem.type.startsWith( 'video/' ) || 'image/gif' === mediaItem.type ) {\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\treturn true;\n\t\t} )\n\t\t// We only want the first four items of the array, at most.\n\t\t.slice( 0, 4 );\n\n\tif ( 0 === filteredMedia.length ) {\n\t\treturn null;\n\t}\n\n\tconst isVideo = filteredMedia[ 0 ].type.startsWith( 'video/' );\n\n\tconst mediaClasses = clsx( [\n\t\t'twitter-preview__media',\n\t\t'twitter-preview__media-children-' + filteredMedia.length,\n\t] );\n\n\treturn (\n\t\t<div className={ mediaClasses }>\n\t\t\t{ filteredMedia.map( ( mediaItem, index ) => (\n\t\t\t\t<Fragment key={ `twitter-preview__media-item-${ index }` }>\n\t\t\t\t\t{ isVideo ? (\n\t\t\t\t\t\t<video controls>\n\t\t\t\t\t\t\t<source src={ mediaItem.url } type={ mediaItem.type } />\n\t\t\t\t\t\t</video>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<img alt={ mediaItem.alt || '' } src={ mediaItem.url } />\n\t\t\t\t\t) }\n\t\t\t\t</Fragment>\n\t\t\t) ) }\n\t\t</div>\n\t);\n};\n","import { SandBox } from '@wordpress/components';\nimport { QuoteTweetProps } from './types';\n\nexport const QuoteTweet: React.FC< QuoteTweetProps > = ( { tweetUrl } ) => {\n\tif ( ! tweetUrl ) {\n\t\treturn null;\n\t}\n\n\treturn (\n\t\t<div className=\"twitter-preview__quote-tweet\">\n\t\t\t<SandBox\n\t\t\t\thtml={ `<blockquote class=\"twitter-tweet\" data-conversation=\"none\" data-dnt=\"true\"><a href=\"${ tweetUrl }\"></a></blockquote>` }\n\t\t\t\tscripts={ [ 'https://platform.twitter.com/widgets.js' ] }\n\t\t\t\ttitle=\"Embedded tweet\"\n\t\t\t/>\n\t\t\t<div className=\"twitter-preview__quote-tweet-overlay\" />\n\t\t</div>\n\t);\n};\n","import { useCallback, useState } from 'react';\n\nexport type AvatarWithFallbackProps = {\n\talt?: string;\n\tsrc?: string;\n\tclassName?: string;\n\tfallback?: React.ReactNode;\n};\n\n/**\n * Renders a default avatar SVG.\n *\n * @param {Pick< AvatarWithFallbackProps, 'className' >} props - The SVG props.\n * @return The DefaultAvatar component.\n */\nexport function DefaultAvatar( props: Pick< AvatarWithFallbackProps, 'className' > ) {\n\treturn (\n\t\t<svg\n\t\t\txmlns=\"http://www.w3.org/2000/svg\"\n\t\t\tviewBox=\"0 0 340 340\"\n\t\t\twidth=\"36\"\n\t\t\theight=\"36\"\n\t\t\taria-hidden=\"true\"\n\t\t\t{ ...props }\n\t\t>\n\t\t\t<path\n\t\t\t\tfill=\"#DDD\"\n\t\t\t\td=\"m169,.5a169,169 0 1,0 2,0zm0,86a76,76 0 1 1-2,0zM57,287q27-35 67-35h92q40,0 67,35a164,164 0 0,1-226,0\"\n\t\t\t/>\n\t\t</svg>\n\t);\n}\n\n/**\n * Renders an avatar image with a fallback to a default avatar if no URL is provided or if the URL fails to load.\n *\n * @param {AvatarWithFallbackProps} props - The props for the AvatarWithFallback component.\n *\n * @return The AvatarWithFallback component.\n */\nexport function AvatarWithFallback( {\n\tsrc: avatarUrl,\n\talt = '',\n\tclassName,\n\tfallback = <DefaultAvatar className={ className } />,\n}: AvatarWithFallbackProps ) {\n\t// Use state to track if the image URL has encountered an error\n\tconst [ imageUrlWithError, setImageUrlWithError ] = useState( '' );\n\n\tconst onError = useCallback< React.ReactEventHandler< HTMLImageElement > >( event => {\n\t\tsetImageUrlWithError( ( event.target as HTMLImageElement ).src );\n\t}, [] );\n\n\tconst showAvatar =\n\t\t!! avatarUrl &&\n\t\t// Check if the image URL with error is different from the provided avatar URL\n\t\t// to ensure that a change in avatarUrl resets the error state\n\t\timageUrlWithError !== avatarUrl;\n\n\treturn showAvatar ? (\n\t\t<img src={ avatarUrl } alt={ alt } onError={ onError } className={ className } />\n\t) : (\n\t\tfallback\n\t);\n}\n","import { AvatarWithFallback } from '../avatar-with-fallback';\nimport { SidebarProps } from './types';\n\nexport const Sidebar: React.FC< SidebarProps > = ( { profileImage, showThreadConnector } ) => {\n\treturn (\n\t\t<div className=\"twitter-preview__sidebar\">\n\t\t\t<div className=\"twitter-preview__profile-image\">\n\t\t\t\t<AvatarWithFallback src={ profileImage } />\n\t\t\t</div>\n\t\t\t{ showThreadConnector && <div className=\"twitter-preview__connector\" /> }\n\t\t</div>\n\t);\n};\n","import { preparePreviewText } from '../helpers';\nimport { TextProps } from './types';\n\nexport const Text: React.FC< TextProps > = ( { text } ) => {\n\tif ( ! text ) {\n\t\treturn null;\n\t}\n\n\treturn (\n\t\t<div className=\"twitter-preview__text\">\n\t\t\t{ preparePreviewText( text, { platform: 'twitter' } ) }\n\t\t</div>\n\t);\n};\n","import { Card } from './card';\nimport { Footer } from './footer';\nimport { Header } from './header';\nimport { Media } from './media';\nimport { QuoteTweet } from './quote-tweet';\nimport { Sidebar } from './sidebar';\nimport { Text } from './text';\nimport { TwitterPreviewProps } from './types';\n\nimport './style.scss';\n\nexport const TwitterPostPreview: React.FC< TwitterPreviewProps > = ( {\n\tdate,\n\tdescription,\n\timage,\n\tmedia,\n\tname,\n\tprofileImage,\n\tscreenName,\n\tshowThreadConnector,\n\ttext,\n\ttitle,\n\ttweetUrl,\n\tcardType,\n\turl,\n} ) => {\n\tconst hasMedia = !! media?.length;\n\n\treturn (\n\t\t<div className=\"twitter-preview__wrapper\">\n\t\t\t<div className=\"twitter-preview__container\">\n\t\t\t\t<Sidebar profileImage={ profileImage } showThreadConnector={ showThreadConnector } />\n\t\t\t\t<div className=\"twitter-preview__main\">\n\t\t\t\t\t<Header name={ name } screenName={ screenName } date={ date } />\n\t\t\t\t\t<div className=\"twitter-preview__content\">\n\t\t\t\t\t\t{ text ? <Text text={ text } /> : null }\n\t\t\t\t\t\t{ hasMedia ? <Media media={ media } /> : null }\n\t\t\t\t\t\t{ tweetUrl ? <QuoteTweet tweetUrl={ tweetUrl } /> : null }\n\t\t\t\t\t\t{ ! hasMedia && url && (\n\t\t\t\t\t\t\t<Card\n\t\t\t\t\t\t\t\tdescription={ description || '' }\n\t\t\t\t\t\t\t\timage={ image }\n\t\t\t\t\t\t\t\ttitle={ title || '' }\n\t\t\t\t\t\t\t\tcardType={ cardType || '' }\n\t\t\t\t\t\t\t\turl={ url }\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t) }\n\t\t\t\t\t</div>\n\t\t\t\t\t<Footer />\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t);\n};\n","import { TwitterPostPreview } from './post-preview';\nimport { TwitterPreviewProps } from './types';\n\nexport const TwitterLinkPreview: React.FC< TwitterPreviewProps > = props => {\n\treturn (\n\t\t<TwitterPostPreview\n\t\t\t{ ...props }\n\t\t\t// Override the props that are irrelevant to link preview\n\t\t\ttext=\"\"\n\t\t\tmedia={ undefined }\n\t\t/>\n\t);\n};\n","import { __ } from '@wordpress/i18n';\nimport SectionHeading from '../shared/section-heading';\nimport { TwitterLinkPreview } from './link-preview';\nimport { TwitterPostPreview } from './post-preview';\nimport type { TwitterPreviewsProps } from './types';\n\nexport const TwitterPreviews: React.FC< TwitterPreviewsProps > = ( {\n\theadingLevel,\n\thideLinkPreview,\n\thidePostPreview,\n\ttweets,\n} ) => {\n\tif ( ! tweets?.length ) {\n\t\treturn null;\n\t}\n\n\treturn (\n\t\t<div className=\"social-preview twitter-preview\">\n\t\t\t{ ! hidePostPreview && (\n\t\t\t\t<section className=\"social-preview__section twitter-preview__section\">\n\t\t\t\t\t<SectionHeading level={ headingLevel }>\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t// translators: refers to a social post on Twitter\n\t\t\t\t\t\t\t__( 'Your post', 'social-previews' )\n\t\t\t\t\t\t}\n\t\t\t\t\t</SectionHeading>\n\t\t\t\t\t<p className=\"social-preview__section-desc\">\n\t\t\t\t\t\t{ __( 'This is what your social post will look like on X:', 'social-previews' ) }\n\t\t\t\t\t</p>\n\t\t\t\t\t{ tweets.map( ( tweet, index ) => {\n\t\t\t\t\t\tconst isLast = index + 1 === tweets.length;\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<TwitterPostPreview\n\t\t\t\t\t\t\t\tkey={ `twitter-preview__tweet-${ index }` }\n\t\t\t\t\t\t\t\t{ ...tweet }\n\t\t\t\t\t\t\t\tshowThreadConnector={ ! isLast }\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t);\n\t\t\t\t\t} ) }\n\t\t\t\t</section>\n\t\t\t) }\n\t\t\t{ ! hideLinkPreview && (\n\t\t\t\t<section className=\"social-preview__section twitter-preview__section\">\n\t\t\t\t\t<SectionHeading level={ headingLevel }>\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t// translators: refers to a link to a Twitter post\n\t\t\t\t\t\t\t__( 'Link preview', 'social-previews' )\n\t\t\t\t\t\t}\n\t\t\t\t\t</SectionHeading>\n\t\t\t\t\t<p className=\"social-preview__section-desc\">\n\t\t\t\t\t\t{ __(\n\t\t\t\t\t\t\t'This is what it will look like when someone shares the link to your WordPress post on X.',\n\t\t\t\t\t\t\t'social-previews'\n\t\t\t\t\t\t) }\n\t\t\t\t\t</p>\n\t\t\t\t\t<TwitterLinkPreview { ...tweets[ 0 ] } name=\"\" profileImage=\"\" screenName=\"\" />\n\t\t\t\t</section>\n\t\t\t) }\n\t\t</div>\n\t);\n};\n","const HEADING_LEVELS = [ 2, 3, 4, 5, 6 ] as const;\n\nexport type SectionHeadingProps = {\n\tclassName?: string;\n\tlevel?: ( typeof HEADING_LEVELS )[ number ];\n\tchildren?: React.ReactNode;\n};\n\nexport const SectionHeading: React.FC< SectionHeadingProps > = ( {\n\tclassName,\n\tlevel,\n\tchildren,\n} ) => {\n\tconst Tag = `h${ level && HEADING_LEVELS.includes( level ) ? level : 3 }` as const;\n\n\treturn (\n\t\t<Tag className={ `social-preview__section-heading ${ className ?? '' }` }>{ children }</Tag>\n\t);\n};\n\nexport default SectionHeading;\n","import { __, sprintf } from '@wordpress/i18n';\nimport { AvatarWithFallback } from '../avatar-with-fallback';\nimport { baseDomain, getTitleFromDescription, preparePreviewText } from '../helpers';\nimport { ExpandableText } from '../shared/expandable-text';\nimport { FEED_TEXT_MAX_LENGTH } from './constants';\nimport { LinkedInPreviewProps } from './types';\nimport './style.scss';\n\n/**\n * LinkedIn Post Preview Component\n *\n * @param {LinkedInPreviewProps} props - The props for the LinkedIn post preview.\n *\n * @return The LinkedIn post preview component.\n */\nexport function LinkedInPostPreview( {\n\tarticleReadTime = 5,\n\timage,\n\tjobTitle,\n\tname,\n\tprofileImage,\n\tdescription,\n\tmedia,\n\ttitle,\n\turl,\n}: LinkedInPreviewProps ) {\n\tconst hasMedia = !! media?.length;\n\n\treturn (\n\t\t<div className=\"linkedin-preview__wrapper\">\n\t\t\t<section className={ `linkedin-preview__container ${ hasMedia ? 'has-media' : '' }` }>\n\t\t\t\t<div className=\"linkedin-preview__header\">\n\t\t\t\t\t<div className=\"linkedin-preview__header--avatar\">\n\t\t\t\t\t\t<AvatarWithFallback src={ profileImage } />\n\t\t\t\t\t</div>\n\t\t\t\t\t<div className=\"linkedin-preview__header--profile\">\n\t\t\t\t\t\t<div className=\"linkedin-preview__header--profile-info\">\n\t\t\t\t\t\t\t<div className=\"linkedin-preview__header--profile-name\">\n\t\t\t\t\t\t\t\t{ name || __( 'Account Name', 'social-previews' ) }\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<span>•</span>\n\t\t\t\t\t\t\t<div className=\"linkedin-preview__header--profile-actor\">\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t// translators: refers to the actor level of the post being shared, e.g. \"1st\", \"2nd\", \"3rd\", etc.\n\t\t\t\t\t\t\t\t\t__( '1st', 'social-previews' )\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{ jobTitle ? (\n\t\t\t\t\t\t\t<div className=\"linkedin-preview__header--profile-title\">{ jobTitle }</div>\n\t\t\t\t\t\t) : null }\n\t\t\t\t\t\t<div className=\"linkedin-preview__header--profile-meta\">\n\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t// translators: refers to the time since the post was published, e.g. \"1h\"\n\t\t\t\t\t\t\t\t\t__( '1h', 'social-previews' )\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<span>•</span>\n\t\t\t\t\t\t\t{ /* This is the Globe SVG that represents visibility to be \"public\" */ }\n\t\t\t\t\t\t\t<svg viewBox=\"0 0 16 16\" fill=\"currentColor\" width=\"16\" height=\"16\" focusable=\"false\">\n\t\t\t\t\t\t\t\t<path d=\"M8 1a7 7 0 107 7 7 7 0 00-7-7zM3 8a5 5 0 011-3l.55.55A1.5 1.5 0 015 6.62v1.07a.75.75 0 00.22.53l.56.56a.75.75 0 00.53.22H7v.69a.75.75 0 00.22.53l.56.56a.75.75 0 01.22.53V13a5 5 0 01-5-5zm6.24 4.83l2-2.46a.75.75 0 00.09-.8l-.58-1.16A.76.76 0 0010 8H7v-.19a.51.51 0 01.28-.45l.38-.19a.74.74 0 01.68 0L9 7.5l.38-.7a1 1 0 00.12-.48v-.85a.78.78 0 01.21-.53l1.07-1.09a5 5 0 01-1.54 9z\" />\n\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div className=\"linkedin-preview__content\">\n\t\t\t\t\t{ description ? (\n\t\t\t\t\t\t<div className=\"linkedin-preview__caption\">\n\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t<ExpandableText text={ description }>\n\t\t\t\t\t\t\t\t\t{ visibleText =>\n\t\t\t\t\t\t\t\t\t\tpreparePreviewText( visibleText, {\n\t\t\t\t\t\t\t\t\t\t\tplatform: 'linkedin',\n\t\t\t\t\t\t\t\t\t\t\tmaxChars: FEED_TEXT_MAX_LENGTH,\n\t\t\t\t\t\t\t\t\t\t} )\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t</ExpandableText>\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t{ hasMedia && url && ! description.includes( url ) && (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t{ ' - ' }\n\t\t\t\t\t\t\t\t\t<a href={ url } rel=\"nofollow noopener noreferrer\" target=\"_blank\">\n\t\t\t\t\t\t\t\t\t\t{ url }\n\t\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t) }\n\t\t\t\t\t\t</div>\n\t\t\t\t\t) : null }\n\t\t\t\t\t{ hasMedia ? (\n\t\t\t\t\t\t<div className=\"linkedin-preview__media\">\n\t\t\t\t\t\t\t{ media.map( ( mediaItem, index ) => (\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tkey={ `linkedin-preview__media-item-${ index }` }\n\t\t\t\t\t\t\t\t\tclassName=\"linkedin-preview__media-item\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{ mediaItem.type.startsWith( 'video/' ) ? (\n\t\t\t\t\t\t\t\t\t\t<video controls>\n\t\t\t\t\t\t\t\t\t\t\t<source src={ mediaItem.url } type={ mediaItem.type } />\n\t\t\t\t\t\t\t\t\t\t</video>\n\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t<img alt={ mediaItem.alt || '' } src={ mediaItem.url } />\n\t\t\t\t\t\t\t\t\t) }\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t) ) }\n\t\t\t\t\t\t</div>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<article>\n\t\t\t\t\t\t\t{ image ? <img className=\"linkedin-preview__image\" src={ image } alt=\"\" /> : null }\n\t\t\t\t\t\t\t{ url ? (\n\t\t\t\t\t\t\t\t<div className=\"linkedin-preview__description\">\n\t\t\t\t\t\t\t\t\t<h2 className=\"linkedin-preview__description--title\">\n\t\t\t\t\t\t\t\t\t\t{ title || getTitleFromDescription( description ) }\n\t\t\t\t\t\t\t\t\t</h2>\n\t\t\t\t\t\t\t\t\t<div className=\"linkedin-preview__description--meta\">\n\t\t\t\t\t\t\t\t\t\t<span className=\"linkedin-preview__description--url\">\n\t\t\t\t\t\t\t\t\t\t\t{ baseDomain( url ) }\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t<span>•</span>\n\t\t\t\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t\t\t\t{ sprintf(\n\t\t\t\t\t\t\t\t\t\t\t\t// translators: %d is the number of minutes it takes to read the article\n\t\t\t\t\t\t\t\t\t\t\t\t__( '%d min read', 'social-previews' ),\n\t\t\t\t\t\t\t\t\t\t\t\tarticleReadTime\n\t\t\t\t\t\t\t\t\t\t\t) }\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t) : null }\n\t\t\t\t\t\t</article>\n\t\t\t\t\t) }\n\t\t\t\t</div>\n\t\t\t</section>\n\t\t</div>\n\t);\n}\n","import { Button } from '@wordpress/components';\nimport { __ } from '@wordpress/i18n';\nimport { useReducer } from 'react';\nimport { stripHtmlTags } from '../../helpers';\n\nimport './style.scss';\n\nexport const EXPAND_THRESHOLD_CHARS = 400;\n\n/**\n * Counts Unicode codepoints rather than UTF-16 code units, so an emoji like 🚀 is one character (matching PHP `mb_strlen`).\n *\n * @param text - The string to measure.\n * @return The codepoint count.\n */\nfunction codepointLength( text: string ): number {\n\treturn Array.from( text ).length;\n}\n\n/**\n * Truncates `text` to at most `limit` codepoints, preferring the last space\n * within the final 80 codepoints so we don't slice mid-word.\n *\n * @param text - The string to truncate.\n * @param limit - Maximum codepoint length of the returned string.\n * @return The truncated string (without an ellipsis).\n */\nfunction truncateAtWordBoundary( text: string, limit: number ): string {\n\tconst codepoints = Array.from( text );\n\n\tif ( codepoints.length <= limit ) {\n\t\treturn text;\n\t}\n\n\tconst slice = codepoints.slice( 0, limit ).join( '' );\n\tconst lastSpace = slice.lastIndexOf( ' ' );\n\t// Only honor the word boundary if it's reasonably close to the limit;\n\t// otherwise hard-cut to avoid stranding most of the text on a long token.\n\tconst cut = lastSpace > limit - 80 ? lastSpace : slice.length;\n\n\treturn slice.slice( 0, cut );\n}\n\ntype ExpandableTextProps = {\n\t/**\n\t * The full body text to potentially truncate. May contain HTML; visible\n\t * length is measured against the HTML-stripped form.\n\t */\n\ttext: string;\n\t/**\n\t * Render-prop that receives the slice of text to display — either the\n\t * full `text` or a word-boundary-truncated version — and returns the\n\t * formatted node (typically the result of `preparePreviewText`).\n\t */\n\tchildren: ( visibleText: string ) => React.ReactNode;\n};\n\n/**\n * Wraps a body-text formatter with a \"See more\" / \"See less\" toggle when the\n * input exceeds {@link EXPAND_THRESHOLD_CHARS} visible (HTML-stripped)\n * characters.\n *\n * @param props - {@link ExpandableTextProps}.\n * @return The body text node, optionally followed by a See more/See less toggle.\n */\nexport function ExpandableText( props: ExpandableTextProps ) {\n\tconst { text, children } = props;\n\tconst [ expanded, toggle ] = useReducer( state => ! state, false );\n\n\tconst stripped = stripHtmlTags( text );\n\n\tif ( codepointLength( stripped ) <= EXPAND_THRESHOLD_CHARS ) {\n\t\treturn <>{ children( text ) }</>;\n\t}\n\n\tif ( expanded ) {\n\t\treturn (\n\t\t\t<>\n\t\t\t\t{ children( text ) }{ ' ' }\n\t\t\t\t<Button variant=\"link\" className=\"social-previews__expand-toggle\" onClick={ toggle }>\n\t\t\t\t\t{ __( 'See less', 'social-previews' ) }\n\t\t\t\t</Button>\n\t\t\t</>\n\t\t);\n\t}\n\n\tconst truncated = truncateAtWordBoundary( stripped, EXPAND_THRESHOLD_CHARS );\n\n\treturn (\n\t\t<>\n\t\t\t{ children( truncated ) }\n\t\t\t{ '… ' }\n\t\t\t<Button variant=\"link\" className=\"social-previews__expand-toggle\" onClick={ toggle }>\n\t\t\t\t{ __( 'See more', 'social-previews' ) }\n\t\t\t</Button>\n\t\t</>\n\t);\n}\n\nexport default ExpandableText;\n","export const FEED_TEXT_MAX_LENGTH = 3000;\n","import { getTitleFromDescription } from '../helpers';\nimport { LinkedInPostPreview } from './post-preview';\nimport { LinkedInPreviewProps } from './types';\n\ntype OptionalProps = Partial< Pick< LinkedInPreviewProps, 'name' | 'profileImage' > >;\n\nexport type LinkedInLinkPreviewProps = Omit< LinkedInPreviewProps, keyof OptionalProps > &\n\tOptionalProps;\n\n/**\n * LinkedIn Link Preview Component\n * @param {LinkedInLinkPreviewProps} props - The props for the LinkedIn link preview.\n * @return The LinkedIn link preview component.\n */\nexport function LinkedInLinkPreview( props: LinkedInLinkPreviewProps ) {\n\treturn (\n\t\t<LinkedInPostPreview\n\t\t\tname=\"\"\n\t\t\tprofileImage=\"\"\n\t\t\t{ ...props }\n\t\t\t// Override the props that are irrelevant to link preview\n\t\t\tdescription=\"\"\n\t\t\tmedia={ undefined }\n\t\t\ttitle={ props.title || getTitleFromDescription( props.description ) }\n\t\t/>\n\t);\n}\n","import { __ } from '@wordpress/i18n';\nimport SectionHeading from '../shared/section-heading';\nimport { LinkedInLinkPreview } from './link-preview';\nimport { LinkedInPostPreview } from './post-preview';\nimport type { LinkedInPreviewsProps } from './types';\n\nexport const LinkedInPreviews: React.FC< LinkedInPreviewsProps > = ( {\n\theadingLevel,\n\thideLinkPreview,\n\thidePostPreview,\n\t...props\n} ) => {\n\treturn (\n\t\t<div className=\"social-preview linkedin-preview\">\n\t\t\t{ ! hidePostPreview && (\n\t\t\t\t<section className=\"social-preview__section linkedin-preview__section\">\n\t\t\t\t\t<SectionHeading level={ headingLevel }>\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t// translators: refers to a social post on LinkedIn\n\t\t\t\t\t\t\t__( 'Your post', 'social-previews' )\n\t\t\t\t\t\t}\n\t\t\t\t\t</SectionHeading>\n\t\t\t\t\t<p className=\"social-preview__section-desc\">\n\t\t\t\t\t\t{ __( 'This is what your social post will look like on LinkedIn:', 'social-previews' ) }\n\t\t\t\t\t</p>\n\t\t\t\t\t<LinkedInPostPreview { ...props } />\n\t\t\t\t</section>\n\t\t\t) }\n\t\t\t{ ! hideLinkPreview && (\n\t\t\t\t<section className=\"social-preview__section linkedin-preview__section\">\n\t\t\t\t\t<SectionHeading level={ headingLevel }>\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t// translators: refers to a link to a LinkedIn post\n\t\t\t\t\t\t\t__( 'Link preview', 'social-previews' )\n\t\t\t\t\t\t}\n\t\t\t\t\t</SectionHeading>\n\t\t\t\t\t<p className=\"social-preview__section-desc\">\n\t\t\t\t\t\t{ __(\n\t\t\t\t\t\t\t'This is what it will look like when someone shares the link to your WordPress post on LinkedIn.',\n\t\t\t\t\t\t\t'social-previews'\n\t\t\t\t\t\t) }\n\t\t\t\t\t</p>\n\t\t\t\t\t<LinkedInLinkPreview { ...props } name=\"\" profileImage=\"\" />\n\t\t\t\t</section>\n\t\t\t) }\n\t\t</div>\n\t);\n};\n","import { __ } from '@wordpress/i18n';\nimport { baseDomain } from '../helpers';\nimport { tumblrTitle, tumblrDescription } from './helpers';\nimport TumblrPostActions from './post/actions';\nimport TumblrPostHeader from './post/header';\nimport type { TumblrPreviewProps } from './types';\n\nimport './styles.scss';\n\nexport const TumblrLinkPreview: React.FC< TumblrPreviewProps > = ( {\n\ttitle,\n\tdescription,\n\timage,\n\tuser,\n\turl,\n} ) => {\n\tconst avatarUrl = user?.avatarUrl;\n\n\treturn (\n\t\t<div className=\"tumblr-preview__post\">\n\t\t\t{ avatarUrl && <img className=\"tumblr-preview__avatar\" src={ avatarUrl } alt=\"\" /> }\n\t\t\t<div className=\"tumblr-preview__card\">\n\t\t\t\t<TumblrPostHeader user={ user } />\n\t\t\t\t<div className=\"tumblr-preview__window\">\n\t\t\t\t\t{ image && (\n\t\t\t\t\t\t<div className=\"tumblr-preview__window-top\">\n\t\t\t\t\t\t\t<div className=\"tumblr-preview__overlay\">\n\t\t\t\t\t\t\t\t<div className=\"tumblr-preview__title\">{ tumblrTitle( title ) }</div>\n\t\t\t\t\t\t\t</div>\n\n\t\t\t\t\t\t\t<img\n\t\t\t\t\t\t\t\tclassName=\"tumblr-preview__image\"\n\t\t\t\t\t\t\t\tsrc={ image }\n\t\t\t\t\t\t\t\talt={ __( 'Tumblr preview thumbnail', 'social-previews' ) }\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t) }\n\t\t\t\t\t<div className={ `tumblr-preview__window-bottom ${ ! image ? 'is-full' : '' }` }>\n\t\t\t\t\t\t{ ! image && <div className=\"tumblr-preview__title\">{ tumblrTitle( title ) }</div> }\n\t\t\t\t\t\t{ description && image && (\n\t\t\t\t\t\t\t<div className=\"tumblr-preview__description\">\n\t\t\t\t\t\t\t\t{ tumblrDescription( description ) }\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) }\n\t\t\t\t\t\t{ url && <div className=\"tumblr-preview__site-name\">{ baseDomain( url ) }</div> }\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<TumblrPostActions />\n\t\t\t</div>\n\t\t</div>\n\t);\n};\n","import { firstValid, hardTruncation, shortEnough, stripHtmlTags, Formatter } from '../helpers';\n\nconst TITLE_LENGTH = 1000;\nconst DESCRIPTION_LENGTH = 4096;\n\n/**\n * Visible body-text cap used by the preview component. Mirrors the\n * `DESCRIPTION_LENGTH` hard-truncation applied inside {@link tumblrDescription}.\n */\nexport const BODY_CHAR_LIMIT = DESCRIPTION_LENGTH;\n\nexport const tumblrTitle: Formatter = text =>\n\tfirstValid(\n\t\tshortEnough( TITLE_LENGTH ),\n\t\thardTruncation( TITLE_LENGTH )\n\t)( stripHtmlTags( text ) ) || '';\n\nexport const tumblrDescription: Formatter = text => {\n\t// Remove Gutenberg block comments using a safer approach to avoid ReDoS\n\tlet processedText = text;\n\tlet startIndex = processedText.indexOf( '<!--' );\n\twhile ( startIndex !== -1 ) {\n\t\tconst endIndex = processedText.indexOf( '-->', startIndex );\n\t\tif ( endIndex === -1 ) {\n\t\t\t// Incomplete comment, remove from startIndex to end\n\t\t\tprocessedText = processedText.substring( 0, startIndex );\n\t\t\tbreak;\n\t\t}\n\t\t// Remove the comment\n\t\tprocessedText =\n\t\t\tprocessedText.substring( 0, startIndex ) + processedText.substring( endIndex + 3 );\n\t\tstartIndex = processedText.indexOf( '<!--' );\n\t}\n\n\t// Convert closing paragraph tags to line breaks to preserve paragraph structure\n\tprocessedText = processedText.replace( /<\\/p>/g, '</p>\\n\\n' );\n\n\treturn (\n\t\tfirstValid(\n\t\t\tshortEnough( DESCRIPTION_LENGTH ),\n\t\t\thardTruncation( DESCRIPTION_LENGTH )\n\t\t)( stripHtmlTags( processedText ) ) || ''\n\t);\n};\n","import { __ } from '@wordpress/i18n';\nimport TumblrPostIcon from '../icons';\n\nimport './styles.scss';\n\nconst TumblrPostActions: React.FC = () => (\n\t<div className=\"tumblr-preview__post-actions\">\n\t\t<div className=\"tumblr-preview__post-manage-actions\">\n\t\t\t<div className=\"tumblr-preview__post-actions-blaze\">\n\t\t\t\t<TumblrPostIcon name=\"blaze\" />\n\t\t\t\t Blaze\n\t\t\t</div>\n\t\t\t<ul>\n\t\t\t\t{ [\n\t\t\t\t\t{\n\t\t\t\t\t\ticon: 'delete',\n\t\t\t\t\t\t// translators: \"Delete\" action on a Tumblr post\n\t\t\t\t\t\tlabel: __( 'Delete', 'social-previews' ),\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\ticon: 'edit',\n\t\t\t\t\t\t// translators: \"Edit\" action on a Tumblr post\n\t\t\t\t\t\tlabel: __( 'Edit', 'social-previews' ),\n\t\t\t\t\t},\n\t\t\t\t].map( ( { icon, label } ) => (\n\t\t\t\t\t<li key={ icon } aria-label={ label }>\n\t\t\t\t\t\t<TumblrPostIcon name={ icon } />\n\t\t\t\t\t</li>\n\t\t\t\t) ) }\n\t\t\t</ul>\n\t\t</div>\n\t\t<div className=\"tumblr-preview__post-social-actions\">\n\t\t\t<div>\n\t\t\t\t{\n\t\t\t\t\t// translators: count of notes on a Tumblr post\n\t\t\t\t\t__( '0 notes', 'social-previews' )\n\t\t\t\t}\n\t\t\t</div>\n\t\t\t<ul>\n\t\t\t\t{ [\n\t\t\t\t\t{\n\t\t\t\t\t\ticon: 'share',\n\t\t\t\t\t\t// translators: \"Share\" action on a Tumblr post\n\t\t\t\t\t\tlabel: __( 'Share', 'social-previews' ),\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\ticon: 'reply',\n\t\t\t\t\t\t// translators: \"Reply\" action on a Tumblr post\n\t\t\t\t\t\tlabel: __( 'Reply', 'social-previews' ),\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\ticon: 'reblog',\n\t\t\t\t\t\t// translators: \"Reblog\" action on a Tumblr post\n\t\t\t\t\t\tlabel: __( 'Reblog', 'social-previews' ),\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\ticon: 'like',\n\t\t\t\t\t\t// translators: \"Like\" action on a Tumblr post\n\t\t\t\t\t\tlabel: __( 'Like', 'social-previews' ),\n\