From 6041c537483c0edbdd38eba7d4ae6431495fffde Mon Sep 17 00:00:00 2001 From: Sean Yesmunt Date: Thu, 11 Mar 2021 16:27:03 -0500 Subject: [PATCH] pre-roll ads demo --- .env.defaults | 1 + config.js | 1 + package.json | 1 + ui/analytics.js | 40 ++- ui/component/viewers/videoViewer/index.js | 15 +- .../viewers/videoViewer/internal/videojs.jsx | 28 +- ui/component/viewers/videoViewer/view.jsx | 255 ++++++++++++------ ui/effects/use-get-ads.js | 53 ++++ ui/scss/component/_ads.scss | 49 ++++ ui/scss/component/_main.scss | 1 + yarn.lock | 12 + 11 files changed, 344 insertions(+), 112 deletions(-) create mode 100644 ui/effects/use-get-ads.js diff --git a/.env.defaults b/.env.defaults index 757306814..420ed8c6c 100644 --- a/.env.defaults +++ b/.env.defaults @@ -32,6 +32,7 @@ ENABLE_FILE_REACTIONS=false ENABLE_CREATOR_REACTIONS=false ENABLE_NO_SOURCE_CLAIMS=false CHANNEL_STAKED_LEVEL_VIDEO_COMMENTS=4 +ENABLE_PREROLL_ADS=false # OG OG_TITLE_SUFFIX=| lbry.tv diff --git a/config.js b/config.js index be6d23e47..47dba3318 100644 --- a/config.js +++ b/config.js @@ -37,6 +37,7 @@ const config = { ENABLE_CREATOR_REACTIONS: process.env.ENABLE_CREATOR_REACTIONS === 'true', ENABLE_NO_SOURCE_CLAIMS: process.env.ENABLE_NO_SOURCE_CLAIMS === 'true', CHANNEL_STAKED_LEVEL_VIDEO_COMMENTS: process.env.CHANNEL_STAKED_LEVEL_VIDEO_COMMENTS, + ENABLE_PREROLL_ADS: process.env.ENABLE_PREROLL_ADS === 'true', SIMPLE_SITE: process.env.SIMPLE_SITE === 'true', SHOW_ADS: process.env.SHOW_ADS === 'true', PINNED_URI_1: process.env.PINNED_URI_1, diff --git a/package.json b/package.json index 0eb392759..4f6122c4a 100644 --- a/package.json +++ b/package.json @@ -203,6 +203,7 @@ "tree-kill": "^1.1.0", "unist-util-visit": "^2.0.3", "uuid": "^8.3.2", + "vast-client": "^3.1.1", "video.js": "^7.10.1", "videojs-contrib-quality-levels": "^2.0.9", "videojs-event-tracking": "^1.0.1", diff --git a/ui/analytics.js b/ui/analytics.js index 32d6bdd94..35fe85823 100644 --- a/ui/analytics.js +++ b/ui/analytics.js @@ -26,10 +26,10 @@ if (isProduction) { // @endif type Analytics = { - error: string => Promise, + error: (string) => Promise, sentryError: ({} | string, {}) => Promise, pageView: (string, ?string) => void, - setUser: Object => void, + setUser: (Object) => void, toggleInternal: (boolean, ?boolean) => void, apiLogView: (string, string, string, ?number, ?() => void) => Promise, apiLogPublish: (ChannelClaim | StreamClaim) => void, @@ -51,13 +51,16 @@ type Analytics = { readyState: number, } ) => void, + adsFetchedEvent: () => void, + adsReceivedEvent: (any) => void, + adsErrorEvent: (any) => void, emailProvidedEvent: () => void, emailVerifiedEvent: () => void, rewardEligibleEvent: () => void, startupEvent: () => void, - purchaseEvent: number => void, - readyEvent: number => void, - openUrlEvent: string => void, + purchaseEvent: (number) => void, + readyEvent: (number) => void, + openUrlEvent: (string) => void, }; type LogPublishParams = { @@ -75,8 +78,8 @@ if (window.localStorage.getItem(SHARE_INTERNAL) === 'true') internalAnalyticsEna // @endif const analytics: Analytics = { - error: message => { - return new Promise(resolve => { + error: (message) => { + return new Promise((resolve) => { if (internalAnalyticsEnabled && isProduction) { return Lbryio.call('event', 'desktop_error', { error_message: message }).then(() => { resolve(true); @@ -87,9 +90,9 @@ const analytics: Analytics = { }); }, sentryError: (error, errorInfo) => { - return new Promise(resolve => { + return new Promise((resolve) => { if (internalAnalyticsEnabled && isProduction) { - Sentry.withScope(scope => { + Sentry.withScope((scope) => { scope.setExtras(errorInfo); const eventId = Sentry.captureException(error); resolve(eventId); @@ -114,7 +117,7 @@ const analytics: Analytics = { MatomoInstance.trackPageView(params); } }, - setUser: userId => { + setUser: (userId) => { if (internalAnalyticsEnabled && userId) { window._paq.push(['setUserId', String(userId)]); // @if TARGET='app' @@ -188,7 +191,7 @@ const analytics: Analytics = { } }, - apiSyncTags: params => { + apiSyncTags: (params) => { if (internalAnalyticsEnabled && isProduction) { Lbryio.call('content_tags', 'sync', params); } @@ -238,10 +241,19 @@ const analytics: Analytics = { }); } }, - playerLoadedEvent: embedded => { + adsFetchedEvent: () => { + sendMatomoEvent('Media', 'AdsFetched'); + }, + adsReceivedEvent: (response) => { + sendMatomoEvent('Media', 'AdsReceived', JSON.stringify(response)); + }, + adsErrorEvent: (response) => { + sendMatomoEvent('Media', 'AdsError', JSON.stringify(response)); + }, + playerLoadedEvent: (embedded) => { sendMatomoEvent('Player', 'Loaded', embedded ? 'embedded' : 'onsite'); }, - playerStartedEvent: embedded => { + playerStartedEvent: (embedded) => { sendMatomoEvent('Player', 'Started', embedded ? 'embedded' : 'onsite'); }, tagFollowEvent: (tag, following) => { @@ -316,7 +328,7 @@ analytics.pageView( // Listen for url changes and report // This will include search queries -history.listen(location => { +history.listen((location) => { const { pathname, search } = location; const page = `${pathname}${search}`; diff --git a/ui/component/viewers/videoViewer/index.js b/ui/component/viewers/videoViewer/index.js index 732d76c8f..52fe1db17 100644 --- a/ui/component/viewers/videoViewer/index.js +++ b/ui/component/viewers/videoViewer/index.js @@ -7,8 +7,9 @@ import { makeSelectContentPositionForUri } from 'redux/selectors/content'; import VideoViewer from './view'; import { withRouter } from 'react-router'; import { doClaimEligiblePurchaseRewards } from 'redux/actions/rewards'; -import { makeSelectClientSetting } from 'redux/selectors/settings'; +import { makeSelectClientSetting, selectHomepageData } from 'redux/selectors/settings'; import { toggleVideoTheaterMode, doSetClientSetting } from 'redux/actions/settings'; +import { selectUserVerifiedEmail } from 'redux/selectors/user'; const select = (state, props) => { const { search } = props.location; @@ -26,19 +27,21 @@ const select = (state, props) => { hasFileInfo: Boolean(makeSelectFileInfoForUri(props.uri)(state)), thumbnail: makeSelectThumbnailForUri(props.uri)(state), claim: makeSelectClaimForUri(props.uri)(state), + homepageData: selectHomepageData(state), + authenticated: selectUserVerifiedEmail(state), }; }; -const perform = dispatch => ({ - changeVolume: volume => dispatch(doChangeVolume(volume)), +const perform = (dispatch) => ({ + changeVolume: (volume) => dispatch(doChangeVolume(volume)), savePosition: (uri, position) => dispatch(savePosition(uri, position)), - clearPosition: uri => dispatch(clearPosition(uri)), - changeMute: muted => dispatch(doChangeMute(muted)), + clearPosition: (uri) => dispatch(clearPosition(uri)), + changeMute: (muted) => dispatch(doChangeMute(muted)), doAnalyticsView: (uri, timeToStart) => dispatch(doAnalyticsView(uri, timeToStart)), doAnalyticsBuffer: (uri, bufferData) => dispatch(doAnalyticsBuffer(uri, bufferData)), claimRewards: () => dispatch(doClaimEligiblePurchaseRewards()), toggleVideoTheaterMode: () => dispatch(toggleVideoTheaterMode()), - setVideoPlaybackRate: rate => dispatch(doSetClientSetting(SETTINGS.VIDEO_PLAYBACK_RATE, rate)), + setVideoPlaybackRate: (rate) => dispatch(doSetClientSetting(SETTINGS.VIDEO_PLAYBACK_RATE, rate)), }); export default withRouter(connect(select, perform)(VideoViewer)); diff --git a/ui/component/viewers/videoViewer/internal/videojs.jsx b/ui/component/viewers/videoViewer/internal/videojs.jsx index a3834ea43..44335ab45 100644 --- a/ui/component/viewers/videoViewer/internal/videojs.jsx +++ b/ui/component/viewers/videoViewer/internal/videojs.jsx @@ -49,6 +49,7 @@ type Props = { startMuted: boolean, autoplay: boolean, toggleVideoTheaterMode: () => void, + adUrl: ?string, }; type VideoJSOptions = { @@ -171,7 +172,17 @@ class LbryVolumeBarClass extends videojs.getComponent(VIDEOJS_VOLUME_BAR_CLASS) properties for this component should be kept to ONLY those that if changed should REQUIRE an entirely new videojs element */ export default React.memo(function VideoJs(props: Props) { - const { autoplay, startMuted, source, sourceType, poster, isAudio, onPlayerReady, toggleVideoTheaterMode } = props; + const { + autoplay, + startMuted, + source, + sourceType, + poster, + isAudio, + onPlayerReady, + toggleVideoTheaterMode, + adUrl, + } = props; const [reload, setReload] = useState('initial'); @@ -194,6 +205,13 @@ export default React.memo(function VideoJs(props: Props) { }, }; + // if (adUrl) { + // // Add the adUrl to the first entry in `sources` + // // After the ad is finished, it will be removed as a prop to this component + // videoJsOptions.sources.unshift({ src: adUrl, type: 'video/mp4' }); + // console.log('added ad'); + // } + const tapToUnmuteRef = useRef(); const tapToRetryRef = useRef(); @@ -333,9 +351,11 @@ export default React.memo(function VideoJs(props: Props) { } } - function onEnded() { - showTapButton(TAP.NONE); - } + const onEnded = React.useCallback(() => { + if (!adUrl) { + showTapButton(TAP.NONE); + } + }, [adUrl]); function handleKeyDown(e: KeyboardEvent) { const player = playerRef.current; diff --git a/ui/component/viewers/videoViewer/view.jsx b/ui/component/viewers/videoViewer/view.jsx index 75a2ffda0..b9f5cf11c 100644 --- a/ui/component/viewers/videoViewer/view.jsx +++ b/ui/component/viewers/videoViewer/view.jsx @@ -1,4 +1,7 @@ // @flow +import { ENABLE_PREROLL_ADS } from 'config'; +import * as PAGES from 'constants/pages'; +import * as ICONS from 'constants/icons'; import React, { useEffect, useState, useContext, useCallback } from 'react'; import { stopContextMenu } from 'util/context-menu'; import type { Player } from './internal/videojs'; @@ -13,6 +16,10 @@ import FileViewerEmbeddedEnded from 'web/component/fileViewerEmbeddedEnded'; import FileViewerEmbeddedTitle from 'component/fileViewerEmbeddedTitle'; import LoadingScreen from 'component/common/loading-screen'; import { addTheaterModeButton } from './internal/theater-mode'; +import { useGetAds } from 'effects/use-get-ads'; +import Button from 'component/button'; +import I18nMessage from 'component/i18nMessage'; +import { useHistory } from 'react-router'; const PLAY_TIMEOUT_ERROR = 'play_timeout_error'; const PLAY_TIMEOUT_LIMIT = 2000; @@ -39,6 +46,16 @@ type Props = { clearPosition: (string) => void, toggleVideoTheaterMode: () => void, setVideoPlaybackRate: (number) => void, + authenticated: boolean, + homepageData: { + PRIMARY_CONTENT_CHANNEL_IDS?: Array, + ENLIGHTENMENT_CHANNEL_IDS?: Array, + GAMING_CHANNEL_IDS?: Array, + SCIENCE_CHANNEL_IDS?: Array, + TECHNOLOGY_CHANNEL_IDS?: Array, + COMMUNITY_CHANNEL_IDS?: Array, + FINCANCE_CHANNEL_IDS?: Array, + }, }; /* @@ -69,22 +86,48 @@ function VideoViewer(props: Props) { desktopPlayStartTime, toggleVideoTheaterMode, setVideoPlaybackRate, + homepageData, + authenticated, } = props; + const { + PRIMARY_CONTENT_CHANNEL_IDS = [], + ENLIGHTENMENT_CHANNEL_IDS = [], + GAMING_CHANNEL_IDS = [], + SCIENCE_CHANNEL_IDS = [], + TECHNOLOGY_CHANNEL_IDS = [], + COMMUNITY_CHANNEL_IDS = [], + FINCANCE_CHANNEL_IDS = [], + } = homepageData; + const adApprovedChannelIds = [ + ...PRIMARY_CONTENT_CHANNEL_IDS, + ...ENLIGHTENMENT_CHANNEL_IDS, + ...GAMING_CHANNEL_IDS, + ...SCIENCE_CHANNEL_IDS, + ...TECHNOLOGY_CHANNEL_IDS, + ...COMMUNITY_CHANNEL_IDS, + ...FINCANCE_CHANNEL_IDS, + ]; const claimId = claim && claim.claim_id; + const channelClaimId = claim && claim.signing_channel && claim.signing_channel.claim_id; const isAudio = contentType.includes('audio'); const forcePlayer = FORCE_CONTENT_TYPE_PLAYER.includes(contentType); + const { + location: { pathname }, + } = useHistory(); const [isPlaying, setIsPlaying] = useState(false); const [showAutoplayCountdown, setShowAutoplayCountdown] = useState(false); const [isEndededEmbed, setIsEndededEmbed] = useState(false); const vjsCallbackDataRef: any = React.useRef(); + const previousUri = usePrevious(uri); + const embedded = useContext(EmbedContext); + const approvedVideo = Boolean(channelClaimId) && adApprovedChannelIds.includes(channelClaimId); + const adsEnabled = ENABLE_PREROLL_ADS && !authenticated && !embedded && approvedVideo; + const [adUrl, setAdUrl, isFetchingAd] = useGetAds(adsEnabled); /* isLoading was designed to show loading screen on first play press, rather than completely black screen, but breaks because some browsers (e.g. Firefox) block autoplay but leave the player.play Promise pending */ const [isLoading, setIsLoading] = useState(false); - const previousUri = usePrevious(uri); - const embedded = useContext(EmbedContext); - // force everything to recent when URI changes, can cause weird corner cases otherwise (e.g. navigate while autoplay is true) useEffect(() => { if (uri && previousUri && uri !== previousUri) { @@ -123,13 +166,18 @@ function VideoViewer(props: Props) { }); } - function onEnded() { + const onEnded = React.useCallback(() => { + if (adUrl) { + setAdUrl(null); + return; + } + if (embedded) { setIsEndededEmbed(true); } else if (autoplaySetting) { setShowAutoplayCountdown(true); } - } + }, [embedded, setIsEndededEmbed, autoplaySetting, setShowAutoplayCountdown, adUrl, setAdUrl]); function onPlay() { setIsLoading(false); @@ -151,86 +199,83 @@ function VideoViewer(props: Props) { player.playbackRate(vjsCallbackDataRef.current.videoPlaybackRate); } } + const playerReadyDependencyList = [uri, adUrl, embedded, autoplayIfEmbedded]; + if (!IS_WEB) { + playerReadyDependencyList.push(desktopPlayStartTime); + } - const onPlayerReady = useCallback( - (player: Player) => { - if (!embedded) { - player.muted(muted); - player.volume(volume); - player.playbackRate(videoPlaybackRate); - addTheaterModeButton(player, toggleVideoTheaterMode); - } + const onPlayerReady = useCallback((player: Player) => { + if (!embedded) { + player.muted(muted); + player.volume(volume); + player.playbackRate(videoPlaybackRate); + addTheaterModeButton(player, toggleVideoTheaterMode); + } - const shouldPlay = !embedded || autoplayIfEmbedded; - // https://blog.videojs.com/autoplay-best-practices-with-video-js/#Programmatic-Autoplay-and-Success-Failure-Detection - if (shouldPlay) { - const playPromise = player.play(); - const timeoutPromise = new Promise((resolve, reject) => - setTimeout(() => reject(PLAY_TIMEOUT_ERROR), PLAY_TIMEOUT_LIMIT) - ); + const shouldPlay = !embedded || autoplayIfEmbedded; + // https://blog.videojs.com/autoplay-best-practices-with-video-js/#Programmatic-Autoplay-and-Success-Failure-Detection + if (shouldPlay) { + const playPromise = player.play(); + const timeoutPromise = new Promise((resolve, reject) => + setTimeout(() => reject(PLAY_TIMEOUT_ERROR), PLAY_TIMEOUT_LIMIT) + ); - Promise.race([playPromise, timeoutPromise]).catch((error) => { - if (PLAY_TIMEOUT_ERROR) { - const retryPlayPromise = player.play(); - Promise.race([retryPlayPromise, timeoutPromise]).catch((error) => { - setIsLoading(false); - setIsPlaying(false); - }); - } else { + Promise.race([playPromise, timeoutPromise]).catch((error) => { + if (PLAY_TIMEOUT_ERROR) { + const retryPlayPromise = player.play(); + Promise.race([retryPlayPromise, timeoutPromise]).catch((error) => { setIsLoading(false); setIsPlaying(false); - } - }); + }); + } else { + setIsLoading(false); + setIsPlaying(false); + } + }); + } + + setIsLoading(shouldPlay); // if we are here outside of an embed, we're playing + + // PR: #5535 + // Move the restoration to a later `loadedmetadata` phase to counter the + // delay from the header-fetch. This is a temp change until the next + // re-factoring. + player.on('loadedmetadata', () => restorePlaybackRate(player)); + + player.on('tracking:buffered', doTrackingBuffered); + player.on('tracking:firstplay', doTrackingFirstPlay); + player.on('ended', onEnded); + player.on('play', onPlay); + player.on('pause', () => { + setIsPlaying(false); + handlePosition(player); + }); + player.on('error', () => { + const error = player.error(); + + if (error) { + analytics.sentryError('Video.js error', error); } - - setIsLoading(shouldPlay); // if we are here outside of an embed, we're playing - - // PR: #5535 - // Move the restoration to a later `loadedmetadata` phase to counter the - // delay from the header-fetch. This is a temp change until the next - // re-factoring. - player.on('loadedmetadata', () => restorePlaybackRate(player)); - - player.on('tracking:buffered', doTrackingBuffered); - player.on('tracking:firstplay', doTrackingFirstPlay); - player.on('ended', onEnded); - player.on('play', onPlay); - player.on('pause', () => { - setIsPlaying(false); - handlePosition(player); - }); - player.on('error', () => { - const error = player.error(); - if (error) { - analytics.sentryError('Video.js error', error); - } - }); - player.on('volumechange', () => { - if (player) { - changeVolume(player.volume()); - changeMute(player.muted()); - } - }); - player.on('ratechange', () => { - const HAVE_NOTHING = 0; // https://docs.videojs.com/player#readyState - if (player && player.readyState() !== HAVE_NOTHING) { - // The playbackRate occasionally resets to 1, typically when loading a fresh video or when 'src' changes. - // Videojs says it's a browser quirk (https://github.com/videojs/video.js/issues/2516). - // [x] Don't update 'videoPlaybackRate' in this scenario. - // [ ] Ideally, the controlBar should be hidden to prevent users from changing the rate while loading. - setVideoPlaybackRate(player.playbackRate()); - } - }); - - if (position) { - player.currentTime(position); + }); + player.on('volumechange', () => { + if (player) { + changeVolume(player.volume()); + changeMute(player.muted()); } - player.on('dispose', () => { - handlePosition(player); - }); - }, - IS_WEB ? [uri] : [uri, desktopPlayStartTime] - ); + }); + player.on('ratechange', () => { + if (player) { + setVideoPlaybackRate(player.playbackRate()); + } + }); + + if (position) { + player.currentTime(position); + } + player.on('dispose', () => { + handlePosition(player); + }); + }, playerReadyDependencyList); return (
} {/* disable this loading behavior because it breaks when player.play() promise hangs */} {isLoading && } - + + {!isFetchingAd && adUrl && ( + <> + + {__('Advertisement')}{' '} +
); } diff --git a/ui/effects/use-get-ads.js b/ui/effects/use-get-ads.js new file mode 100644 index 000000000..da48f32eb --- /dev/null +++ b/ui/effects/use-get-ads.js @@ -0,0 +1,53 @@ +// @flow +import React from 'react'; +import { VASTClient } from 'vast-client'; +import analytics from 'analytics'; + +// const PRE_ROLL_ADS_PROVIDER = '`https://tag.targeting.unrulymedia.com/rmp/216276/0/vast2?vastfw=vpaid&w=300&h=500&url='; + +// Ignores any call made 1 minutes or less after the last successful ad +const ADS_CAP_LEVEL = 1 * 60 * 1000; +const vastClient = new VASTClient(0, ADS_CAP_LEVEL); + +export function useGetAds(adsEnabled: boolean): [?string, (?string) => void, boolean] { + const [isFetching, setIsFetching] = React.useState(true); + const [adUrl, setAdUrl] = React.useState(); + + React.useEffect(() => { + // if (!adsEnabled) { + // setIsFetching(false); + // return; + // } + + analytics.adsFetchedEvent(); + // const encodedHref = encodeURI(window.location.href); + // const url = `${PRE_ROLL_ADS_PROVIDER}${encodedHref}`; + + const url = 'https://raw.githubusercontent.com/dailymotion/vast-client-js/master/test/vastfiles/sample.xml'; + + vastClient + .get(url) + .then((res) => { + if (res.ads.length > 0) { + // Let this line error if res.ads is empty + // I took this from an example response from Dailymotion + // It will be caught below and sent to matomo to figure out if there if this needs to be something changed to deal with unrulys data + // const adUrl = res.ads[0].creatives[0].mediaFiles[0].fileURL; + + // Dummy video file + const adUrl = 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4'; + + if (adUrl) { + setAdUrl(adUrl); + } + } + + setIsFetching(false); + }) + .catch(() => { + setIsFetching(false); + }); + }, [adsEnabled]); + + return [adUrl, setAdUrl, isFetching]; +} diff --git a/ui/scss/component/_ads.scss b/ui/scss/component/_ads.scss index e206a1da8..2349edfd0 100644 --- a/ui/scss/component/_ads.scss +++ b/ui/scss/component/_ads.scss @@ -83,3 +83,52 @@ .ads__claim-text--small { font-size: var(--font-small); } + +// Pre-roll ads +.ads__video-nudge, +.ads__video-notify { + position: absolute; + z-index: 3; +} + +.ads__video-nudge { + right: 0; + left: 0; + bottom: 0; + background-color: var(--color-primary); + color: var(--color-white); + font-weight: bold; + padding: var(--spacing-xs); +} + +.ads__video-notify { + display: flex; + align-items: center; + right: 0; + top: 0; + background-color: black; + border-bottom-left-radius: var(--border-radius); + color: var(--color-white); + font-size: var(--font-small); + padding: var(--spacing-xs); +} + +.ads__video-link.button--secondary { + font-size: var(--font-small); + padding: var(--spacing-xs); + height: 1.5rem; +} + +.ads__video-close { + margin-left: var(--spacing-s); + border-radius: var(--border-radius); + + .icon { + stroke: var(--color-white); + + &:hover { + stroke: var(--color-black); + background-color: var(--color-white); + } + } +} diff --git a/ui/scss/component/_main.scss b/ui/scss/component/_main.scss index 690fab7a1..60c0d65b0 100644 --- a/ui/scss/component/_main.scss +++ b/ui/scss/component/_main.scss @@ -137,6 +137,7 @@ padding-right: 0; margin-left: 0; margin-right: 0; + margin-top: 0; width: 100vw; max-width: none; diff --git a/yarn.lock b/yarn.lock index d9a9c0ae0..f00b79a82 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11498,6 +11498,13 @@ vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" +vast-client@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/vast-client/-/vast-client-3.1.1.tgz#75f044ff1554e0f193302dfa1c20c7f7006fd1f8" + integrity sha512-ED32RnLthWgAjQiEPsbqqC4LkN8+EhFyevHVh2SsmlPr6auugjswdbv+VgaQ/d7KUH/vpZ675HzVkIqkB2ibiQ== + dependencies: + xmldom "^0.3.0" + vendors@^1.0.0: version "1.0.4" resolved "https://registry.yarnpkg.com/vendors/-/vendors-1.0.4.tgz#e2b800a53e7a29b93506c3cf41100d16c4c4ad8e" @@ -11998,6 +12005,11 @@ xmldom@^0.1.27: resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.31.tgz#b76c9a1bd9f0a9737e5a72dc37231cf38375e2ff" integrity sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ== +xmldom@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.3.0.tgz#e625457f4300b5df9c2e1ecb776147ece47f3e5a" + integrity sha512-z9s6k3wxE+aZHgXYxSTpGDo7BYOUfJsIRyoZiX6HTjwpwfS2wpQBQKa2fD+ShLyPkqDYo5ud7KitmLZ2Cd6r0g== + xpipe@*: version "1.0.5" resolved "https://registry.yarnpkg.com/xpipe/-/xpipe-1.0.5.tgz#8dd8bf45fc3f7f55f0e054b878f43a62614dafdf"