diff --git a/.changeset/product-videos-pdp.md b/.changeset/product-videos-pdp.md new file mode 100644 index 0000000000..863822b168 --- /dev/null +++ b/.changeset/product-videos-pdp.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +Display product videos (YouTube) on the PDP gallery. The Storefront GraphQL API exposes product videos as a `{ title, url }` pair (`Product.videos`); Catalyst now fetches them and renders each one alongside the product images using [`lite-youtube-embed`](https://github.com/paulirish/lite-youtube-embed) — a lightweight facade that loads the YouTube player only when a shopper clicks. A small `getYouTubeId()` helper extracts the video id from the watch URL the API returns. diff --git a/core/app/[locale]/(default)/product/[slug]/page-data.ts b/core/app/[locale]/(default)/product/[slug]/page-data.ts index 1d65655b6c..b9aa12ff30 100644 --- a/core/app/[locale]/(default)/product/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/product/[slug]/page-data.ts @@ -293,6 +293,18 @@ const StreamableProductQuery = graphql( altText url: urlTemplate(lossy: true) } + # Product videos. The Storefront GraphQL API only returns the video + # title and url (a YouTube watch URL); the gallery renders it via + # lite-youtube-embed. 25 covers realistic product video counts without + # needing pagination. + videos(first: 25) { + edges { + node { + title + url + } + } + } sku weight { value diff --git a/core/app/[locale]/(default)/product/[slug]/page.tsx b/core/app/[locale]/(default)/product/[slug]/page.tsx index 6b4914234f..cf24446680 100644 --- a/core/app/[locale]/(default)/product/[slug]/page.tsx +++ b/core/app/[locale]/(default)/product/[slug]/page.tsx @@ -189,11 +189,19 @@ export default async function Product({ params, searchParams }: Props) { alt: image.altText, })); + // POC: Storefront GraphQL returns product videos as { title, url }. We pass + // them straight through; the gallery turns each url into an embed player. + const videos = removeEdgesAndNodes(product.videos).map((video) => ({ + url: video.url, + title: video.title, + })); + return { images: product.defaultImage ? [{ src: product.defaultImage.url, alt: product.defaultImage.altText }, ...images] : images, pageInfo: product.images.pageInfo, + videos, }; }); diff --git a/core/messages/en.json b/core/messages/en.json index 56a54ac058..0fd0678fb2 100644 --- a/core/messages/en.json +++ b/core/messages/en.json @@ -468,6 +468,8 @@ "backorderQuantity": "{quantity, number} will be on backorder", "loadingMoreImages": "Loading more images", "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", + "playVideo": "Play video", + "viewVideo": "View video", "Submit": { "addToCart": "Add to cart", "outOfStock": "Out of stock", diff --git a/core/next.config.ts b/core/next.config.ts index afe17e2eb8..d79b2ac28b 100644 --- a/core/next.config.ts +++ b/core/next.config.ts @@ -66,6 +66,10 @@ export default async (): Promise => { experimental: { optimizePackageImports: ['@icons-pack/react-simple-icons'], }, + images: { + // Allow product-video poster thumbnails (YouTube) through next/image. + remotePatterns: [{ protocol: 'https', hostname: 'i.ytimg.com', pathname: '/vi/**' }], + }, typescript: { ignoreBuildErrors: !!process.env.CI, }, diff --git a/core/package.json b/core/package.json index 70f787d247..7270794c4a 100644 --- a/core/package.json +++ b/core/package.json @@ -49,14 +49,15 @@ "clsx": "^2.1.1", "content-security-policy-builder": "^2.3.0", "deepmerge": "^4.3.1", + "dompurify": "^3.3.1", "embla-carousel": "9.0.0-rc01", "embla-carousel-autoplay": "9.0.0-rc01", "embla-carousel-fade": "9.0.0-rc01", "embla-carousel-react": "9.0.0-rc01", "gql.tada": "^1.8.10", "graphql": "^16.11.0", - "dompurify": "^3.3.1", "jose": "^5.10.0", + "lite-youtube-embed": "^0.3.4", "lodash.debounce": "^4.0.8", "lru-cache": "^11.1.0", "lucide-react": "^0.474.0", diff --git a/core/vibes/soul/sections/product-detail/index.tsx b/core/vibes/soul/sections/product-detail/index.tsx index de0afc1c6d..55a82c16ac 100644 --- a/core/vibes/soul/sections/product-detail/index.tsx +++ b/core/vibes/soul/sections/product-detail/index.tsx @@ -28,6 +28,7 @@ interface ProductDetailProduct { images: Streamable<{ images: Array<{ src: string; alt: string }>; pageInfo?: { hasNextPage: boolean; endCursor: string | null }; + videos?: Array<{ url: string; title: string }>; }>; price?: Streamable; subtitle?: string; @@ -140,6 +141,7 @@ export function ProductDetail({ loadMoreAction={loadMoreImagesAction} pageInfo={imagesData.pageInfo} productId={Number(product.id)} + videos={imagesData.videos} /> )} @@ -210,6 +212,7 @@ export function ProductDetail({ pageInfo={imagesData.pageInfo} productId={Number(product.id)} thumbnailLabel={thumbnailLabel} + videos={imagesData.videos} /> )} diff --git a/core/vibes/soul/sections/product-detail/lite-youtube.tsx b/core/vibes/soul/sections/product-detail/lite-youtube.tsx new file mode 100644 index 0000000000..740751f457 --- /dev/null +++ b/core/vibes/soul/sections/product-detail/lite-youtube.tsx @@ -0,0 +1,47 @@ +'use client'; + +// lite-youtube-embed is a tiny, dependency-free custom element that renders a +// YouTube facade (poster + play button) and only injects the real iframe on +// click — the standard "third-party facade" pattern. The CSS styles the element. +import 'lite-youtube-embed/src/lite-yt-embed.css'; + +import type { CSSProperties } from 'react'; + +// Register the custom element on the client only: the package +// subclasses HTMLElement at import time, which isn't defined during SSR. The +// element still renders as inert markup on the server and upgrades on hydration. +if (typeof window !== 'undefined') { + void import('lite-youtube-embed'); +} + +declare module 'react' { + namespace JSX { + interface IntrinsicElements { + 'lite-youtube': React.DetailedHTMLProps, HTMLElement> & { + videoid: string; + playlabel?: string; + params?: string; + }; + } + } +} + +export interface LiteYouTubeProps { + videoId: string; + /** Visually-hidden label for the play button (accessibility). */ + playLabel: string; + className?: string; + style?: CSSProperties; +} + +export function LiteYouTube({ videoId, playLabel, className, style }: LiteYouTubeProps) { + return ( + + ); +} diff --git a/core/vibes/soul/sections/product-detail/product-gallery.tsx b/core/vibes/soul/sections/product-detail/product-gallery.tsx index b11c42cea3..e1a62bb5c7 100644 --- a/core/vibes/soul/sections/product-detail/product-gallery.tsx +++ b/core/vibes/soul/sections/product-detail/product-gallery.tsx @@ -9,6 +9,14 @@ import { startTransition, useCallback, useEffect, useRef, useState } from 'react import * as Skeleton from '@/vibes/soul/primitives/skeleton'; import { Image } from '~/components/image'; +import { LiteYouTube } from './lite-youtube'; +import { getYouTubeId, getYouTubePosterUrl } from './video-embed'; + +export interface ProductVideo { + url: string; + title: string; +} + export type ProductGalleryLoadMoreAction = ( productId: number, cursor: string, @@ -20,6 +28,8 @@ export type ProductGalleryLoadMoreAction = ( export interface ProductGalleryProps { images: Array<{ alt: string; src: string }>; + /** Product videos (YouTube), rendered as players after the images. */ + videos?: ProductVideo[]; className?: string; thumbnailLabel?: string; aspectRatio?: @@ -57,6 +67,7 @@ export interface ProductGalleryProps { */ export function ProductGallery({ images: initialImages, + videos = [], className, thumbnailLabel = 'View image number', aspectRatio = '4:5', @@ -67,6 +78,12 @@ export function ProductGallery({ }: ProductGalleryProps) { const t = useTranslations('Product.ProductDetails'); + // Resolve product videos to YouTube ids up front; non-YouTube URLs are skipped + // (YouTube is the only supported product-video provider). + const youtubeVideos = videos + .map((video) => ({ id: getYouTubeId(video.url), title: video.title })) + .filter((video): video is { id: string; title: string } => video.id !== null); + const [images, setImages] = useState(initialImages); const [pageInfo, setPageInfo] = useState(initialPageInfo); const [hasMoreToLoad, setHasMoreToLoad] = useState(initialPageInfo?.hasNextPage ?? false); @@ -77,6 +94,9 @@ export function ProductGallery({ const scrollListenerRef = useRef<() => void>(() => undefined); const listenForScrollRef = useRef(true); const hasMoreToLoadRef = useRef(hasMoreToLoad); + // Number of loaded image slides; used to anchor the load-more sentinel to the + // image thumbnails (which always precede the video thumbnails). + const imageCountRef = useRef(images.length); const [emblaRef, emblaApi] = useEmblaCarousel(); const [emblaThumbsRef, emblaThumbsApi] = useEmblaCarousel({ @@ -84,11 +104,15 @@ export function ProductGallery({ dragFree: true, }); - // Keep ref in sync with state + // Keep refs in sync with state useEffect(() => { hasMoreToLoadRef.current = hasMoreToLoad; }, [hasMoreToLoad]); + useEffect(() => { + imageCountRef.current = images.length; + }, [images.length]); + const onThumbClick = useCallback( (index: number) => { if (!emblaApi || !emblaThumbsApi) return; @@ -99,8 +123,8 @@ export function ProductGallery({ const onSelect = useCallback(() => { if (!emblaApi || !emblaThumbsApi) return; - setSelectedIndex(emblaApi.selectedSnap()); + setSelectedIndex(emblaApi.selectedSnap()); emblaThumbsApi.goTo(emblaApi.selectedSnap()); }, [emblaApi, emblaThumbsApi]); @@ -191,14 +215,16 @@ export function ProductGallery({ (thumbsApi: EmblaCarouselType) => { if (!listenForScrollRef.current) return; - const slideCount = thumbsApi.slideNodes().length; - const lastSlideIndex = slideCount - 1; - const secondLastSlideIndex = slideCount - 2; + // Anchor the sentinel to the IMAGE thumbnails (always the first slides), + // not the overall slide count — video thumbnails render after the images + // and the load-more skeleton, so using slideNodes().length would delay + // pagination until the shopper scrolled past the videos. + const lastImageIndex = imageCountRef.current - 1; const slidesInView = thumbsApi.slidesInView(); - // Trigger when last or second-to-last thumbnail is in view + // Trigger when the last or second-to-last image thumbnail is in view const shouldLoadMore = - slidesInView.includes(lastSlideIndex) || slidesInView.includes(secondLastSlideIndex); + slidesInView.includes(lastImageIndex) || slidesInView.includes(lastImageIndex - 1); if (shouldLoadMore) { loadMore(thumbsApi); @@ -232,6 +258,20 @@ export function ProductGallery({ }; }, [emblaThumbsApi, addThumbsScrollListener, onSlideChanges]); + const aspectRatioClass = { + '5:6': 'aspect-[5/6]', + '3:4': 'aspect-[3/4]', + '4:5': 'aspect-[4/5]', + '3:2': 'aspect-[3/2]', + '2:3': 'aspect-[2/3]', + '16:9': 'aspect-[16/9]', + '9:16': 'aspect-[9/16]', + '6:5': 'aspect-[6/5]', + '5:4': 'aspect-[5/4]', + '4:3': 'aspect-[4/3]', + '1:1': 'aspect-square', + }[aspectRatio]; + return (
@@ -241,22 +281,7 @@ export function ProductGallery({
{images.map((image, idx) => (
))} + {youtubeVideos.map((video, idx) => { + // Video slides follow every image, so the carousel index continues + // from images.length. + const slideIndex = images.length + idx; + + return ( +
+ +
+ ); + })}
@@ -319,6 +369,56 @@ export function ProductGallery({
)} + {youtubeVideos.map((video, vIdx) => { + // Video slides are rendered after every image in the main + // carousel, so the carousel index continues from images.length. + const slideIndex = images.length + vIdx; + + return ( + + ); + })} diff --git a/core/vibes/soul/sections/product-detail/video-embed.ts b/core/vibes/soul/sections/product-detail/video-embed.ts new file mode 100644 index 0000000000..0248e184e8 --- /dev/null +++ b/core/vibes/soul/sections/product-detail/video-embed.ts @@ -0,0 +1,56 @@ +/** + * Product videos come from the Storefront GraphQL API as a YouTube watch URL + * (e.g. `https://www.youtube.com/watch?v=...`). The `` player + * (see ./lite-youtube) needs the bare video id, and the gallery thumbnail needs + * a poster image — both are derived here. The embedding itself (iframe, facade, + * autoplay) is handled by the lite-youtube-embed library. + * + * Product videos are YouTube-only; any other URL yields a null id and is skipped. + */ + +// YouTube ids are short alphanumeric tokens; reject anything else so a malformed +// path tail or encoded query material can't leak through. +const YOUTUBE_ID = /^[\w-]{6,}$/; +const YOUTUBE_HOSTS = new Set(['youtube.com', 'm.youtube.com', 'youtu.be']); + +// Extract the YouTube video id from a watch/share URL, or null if it isn't a +// (safe http/https) YouTube URL. +export function getYouTubeId(rawUrl: string): string | null { + let url: URL; + + try { + url = new URL(rawUrl); + } catch { + return null; + } + + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; + + const host = url.hostname.replace(/^www\./, ''); + + if (!YOUTUBE_HOSTS.has(host)) return null; + + // https://www.youtube.com/watch?v=ID + const vParam = url.searchParams.get('v'); + + if (vParam && YOUTUBE_ID.test(vParam)) return vParam; + + // https://www.youtube.com/embed/ID, /shorts/ID, /v/ID + const match = /\/(?:embed|shorts|v)\/([\w-]+)/.exec(url.pathname); + + if (match?.[1] && YOUTUBE_ID.test(match[1])) return match[1]; + + // https://youtu.be/ID — first path segment only, ignoring any tail. + if (host === 'youtu.be') { + const [, first] = url.pathname.split('/'); + + if (first && YOUTUBE_ID.test(first)) return first; + } + + return null; +} + +// Poster/thumbnail image URL for a YouTube video id (used by the gallery thumbnail). +export function getYouTubePosterUrl(videoId: string): string { + return `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4dcb7d3b37..cd220ae6bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -158,6 +158,9 @@ importers: jose: specifier: ^5.10.0 version: 5.10.0 + lite-youtube-embed: + specifier: ^0.3.4 + version: 0.3.4 lodash.debounce: specifier: ^4.0.8 version: 4.0.8 @@ -4765,6 +4768,7 @@ packages: '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unlighthouse/cli@0.16.3': resolution: {integrity: sha512-4erwxa9mij2qxbtMtJe3OB0ONjjg6ZpWcHr4tQHwgjewn9tNVyhdnIiMneig8EfyoKbxnvY0HBJOG4VBMJcaVA==} @@ -5305,7 +5309,7 @@ packages: basic-ftp@5.0.5: resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} engines: {node: '>=10.0.0'} - deprecated: Security vulnerability fixed in 5.2.0, please upgrade + deprecated: Security vulnerability fixed in 5.2.1, please upgrade better-opn@3.0.2: resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} @@ -6838,7 +6842,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported glob@9.3.5: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} @@ -7712,6 +7716,9 @@ packages: resolution: {integrity: sha512-I8oW2+QL5KJo8zXNWX046M134WchxsXC7SawLPvRQpogCbkyQIaFxPE89A2HiwR7vAK2Dm2ERBAmyjTYGYEpBg==} hasBin: true + lite-youtube-embed@0.3.4: + resolution: {integrity: sha512-aXgxpwK7AIW58GEbRzA8EYaY4LWvF3FKak6B9OtSJmuNyLhX2ouD4cMTxz/yR5HFInhknaYd2jLWOTRTvT8oAw==} + load-tsconfig@0.2.5: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -8842,7 +8849,6 @@ packages: puppeteer@24.10.0: resolution: {integrity: sha512-Oua9VkGpj0S2psYu5e6mCer6W9AU9POEQh22wRgSXnLXASGH+MwLUVWgLCLeP9QPHHcJ7tySUlg4Sa9OJmaLpw==} engines: {node: '>=18'} - deprecated: < 24.15.0 is no longer supported hasBin: true pure-rand@6.1.0: @@ -9565,8 +9571,8 @@ packages: third-party-web@0.26.6: resolution: {integrity: sha512-GsjP92xycMK8qLTcQCacgzvffYzEqe29wyz3zdKVXlfRD5Kz1NatCTOZEeDaSd6uCZXvGd2CNVtQ89RNIhJWvA==} - third-party-web@0.29.0: - resolution: {integrity: sha512-nBDSJw5B7Sl1YfsATG2XkW5qgUPODbJhXw++BKygi9w6O/NKS98/uY/nR/DxDq2axEjL6halHW1v+jhm/j1DBQ==} + third-party-web@0.29.2: + resolution: {integrity: sha512-fegtha91tq2DHphyoiBXVHjVi2YG9zFaRnboT9C28tO1en9Y3wJsfspuy40F+u5wl3hHVbw7cnd1b67kEGHb8g==} through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} @@ -10015,6 +10021,7 @@ packages: uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-compile-cache-lib@3.0.1: @@ -11667,9 +11674,9 @@ snapshots: '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) eslint: 8.57.1 eslint-config-prettier: 9.1.0(eslint@8.57.1) - eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1) eslint-plugin-gettext: 1.2.0 - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1) eslint-plugin-jest: 28.11.0(@typescript-eslint/eslint-plugin@8.28.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(jest@29.7.0(@types/node@22.15.30)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.15.30)(typescript@5.8.3)))(typescript@5.8.3) eslint-plugin-jest-dom: 5.5.0(eslint@8.57.1) eslint-plugin-jest-formatting: 3.1.0(eslint@8.57.1) @@ -13909,7 +13916,7 @@ snapshots: '@paulirish/trace_engine@0.0.53': dependencies: legacy-javascript: 0.0.1 - third-party-web: 0.29.0 + third-party-web: 0.29.2 '@pkgjs/parseargs@0.11.0': optional: true @@ -17709,8 +17716,8 @@ snapshots: '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.4(eslint@8.57.1) eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1) @@ -17737,21 +17744,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.1 - eslint: 8.57.1 - get-tsconfig: 4.10.0 - is-bun-module: 1.3.0 - rspack-resolver: 1.2.2 - stable-hash: 0.0.5 - tinyglobby: 0.2.14 - optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) - transitivePeerDependencies: - - supports-color - eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 @@ -17767,18 +17759,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) - eslint: 8.57.1 - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1) - transitivePeerDependencies: - - supports-color - - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: @@ -17799,35 +17780,6 @@ snapshots: dependencies: gettext-parser: 4.2.0 - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.8 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 8.57.1 - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) - hasown: 2.0.2 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 3.1.2 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.28.0(eslint@8.57.1)(typescript@5.8.3) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 @@ -17839,7 +17791,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.1)(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.28.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.9.1(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -19731,6 +19683,8 @@ snapshots: untun: 0.1.3 uqr: 0.1.2 + lite-youtube-embed@0.3.4: {} + load-tsconfig@0.2.5: {} local-pkg@1.1.1: @@ -21725,7 +21679,7 @@ snapshots: third-party-web@0.26.6: {} - third-party-web@0.29.0: {} + third-party-web@0.29.2: {} through@2.3.8: {}