diff --git a/gulpfile.js b/gulpfile.js index 8d667b4f..caad6359 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -45,6 +45,15 @@ function buildNativeDev() { .pipe(gulp.dest('build')); } +function buildNativeRenderDev() { + var cloned = _.cloneDeep(webpackConfig); + cloned.output.filename = 'native-render.js'; + + return gulp.src(['src/nativeRender.js']) + .pipe(webpackStream(cloned)) + .pipe(gulp.dest('build')); +} + function buildCookieSync() { let cloned = _.cloneDeep(webpackConfig); delete cloned.devtool; @@ -122,6 +131,18 @@ function buildNative() { .pipe(gulp.dest('dist')); } +function buildNativeRender() { + var cloned = _.cloneDeep(webpackConfig); + delete cloned.devtool; + cloned.output.filename = 'native-render.js'; + + return gulp.src(['src/nativeRender.js']) + .pipe(webpackStream(cloned)) + .pipe(uglify()) + .pipe(header('/* v<%= creative.version %>\n' + dateString + ' */\n', { creative: creative })) + .pipe(gulp.dest('dist')); +} + function buildUid() { var cloned = _.cloneDeep(webpackConfig); delete cloned.devtool; @@ -179,7 +200,7 @@ function setupE2E(done) { gulp.task('test', gulp.series(clean, test)); -gulp.task('e2e-test', gulp.series(clean, setupE2E, gulp.parallel(buildDev, buildCookieSync, buildCookieSyncWithConsent, buildNativeDev, buildUidDev, watch), test)); +gulp.task('e2e-test', gulp.series(clean, setupE2E, gulp.parallel(buildDev, buildCookieSync, buildCookieSyncWithConsent, buildNativeDev, buildNativeRenderDev, buildUidDev, watch), test)); function watch(done) { const mainWatcher = gulp.watch([ @@ -193,8 +214,8 @@ function watch(done) { port, root: './' }); - - mainWatcher.on('all', gulp.series(clean, gulp.parallel(buildDev, buildNativeDev, buildCookieSync, buildCookieSyncWithConsent, buildUidDev), test)); + + mainWatcher.on('all', gulp.series(clean, gulp.parallel(buildDev, buildNativeDev, buildNativeRenderDev, buildCookieSync, buildCookieSyncWithConsent, buildUidDev), test)); done(); } @@ -202,9 +223,9 @@ function openWebPage() { return opens(`${(argv.https) ? 'https' : 'http'}://localhost:${port}`); } -gulp.task('serve', gulp.series(clean, gulp.parallel(buildDev, buildNativeDev, buildCookieSync, buildCookieSyncWithConsent, buildUidDev, watch, test), openWebPage)); +gulp.task('serve', gulp.series(clean, gulp.parallel(buildDev, buildNativeDev, buildNativeRenderDev, buildCookieSync, buildCookieSyncWithConsent, buildUidDev, watch, test), openWebPage)); -gulp.task('build', gulp.parallel(buildProd, buildCookieSync, buildCookieSyncWithConsent, buildNative, buildUid)); +gulp.task('build', gulp.parallel(buildProd, buildCookieSync, buildCookieSyncWithConsent, buildNative, buildNativeRender, buildUid)); gulp.task('test-coverage', (done) => { new KarmaServer(karmaConfMaker(true, false, false), newKarmaCallback(done)).start(); diff --git a/src/nativeAssetManager.js b/src/nativeAssetManager.js index 2fd91d0b..15470af2 100644 --- a/src/nativeAssetManager.js +++ b/src/nativeAssetManager.js @@ -3,7 +3,7 @@ * values in native creative templates. */ -import { sendRequest } from './utils'; +import { sendRequest, loadScript } from './utils'; /* * Native asset->key mapping from Prebid.js/src/constants.json @@ -27,6 +27,7 @@ const NATIVE_KEYS = { phone: 'hb_native_phone', price: 'hb_native_price', salePrice: 'hb_native_saleprice', + rendererUrl: 'hb_renderer_url', }; // Asset type mapping as per Native IAB spec 1.2 @@ -160,15 +161,27 @@ export function newNativeAssetManager(win) { /* * Entry point to search for placeholderes and set up postmessage roundtrip * to retrieve native assets. Looks for placeholders for the given adId and - * fires a callback after the native html is updated. + * fires a callback after the native html is updated. If no placeholders found + * and requestAllAssets flag is set in the tag, postmessage roundtrip + * to retrieve native assets that have a value on the corresponding bid */ function loadAssets(adId, cb) { - const placeholders = scanForPlaceholders(adId); + const placeholders = scanForPlaceholders(adId), flag = (typeof win.pbNativeData !== 'undefined'); - if (placeholders.length > 0) { + if (flag && win.pbNativeData.hasOwnProperty('assetsToReplace')) { + win.pbNativeData.assetsToReplace.forEach((asset) => { + const key = (asset.match(/hb_native_/i)) ? asset : NATIVE_KEYS[asset]; + if (key) {placeholders.push(key);} + }); + } + + if (flag && win.pbNativeData.hasOwnProperty('requestAllAssets') && win.pbNativeData.requestAllAssets) { + callback = cb; + requestAllAssets(adId); + } else if (placeholders.length > 0) { callback = cb; requestAssets(adId, placeholders); - } + } } /* @@ -176,17 +189,19 @@ export function newNativeAssetManager(win) { */ function scanForPlaceholders(adId) { let placeholders = []; + const doc = win.document; + const flag = (typeof win.pbNativeData !== 'undefined'); Object.keys(NATIVE_KEYS).forEach(key => { const placeholderKey = NATIVE_KEYS[key]; - const placeholder = (adId) ? `${placeholderKey}:${adId}` : `${placeholderKey}`; - const placeholderIndex = win.document.body.innerHTML.indexOf(placeholder); - + const placeholder = (adId && !flag) ? `${placeholderKey}:${adId}` : `${placeholderKey}`; + const placeholderIndex = (~doc.body.innerHTML.indexOf(placeholder)) ? doc.body.innerHTML.indexOf(placeholder) : (doc.head.innerHTML && doc.head.innerHTML.indexOf(placeholder)); + if (~placeholderIndex) { placeholders.push(placeholderKey); } }); - + return placeholders; } @@ -204,6 +219,37 @@ export function newNativeAssetManager(win) { assets, }; + + win.parent.postMessage(JSON.stringify(message), '*'); + } + + /* + * Sends postmessage to Prebid for asset placeholders found in the native + * creative template, and setups up a listener for when Prebid responds. + */ + function requestAllAssets(adId) { + win.addEventListener('message', replaceAssets, false); + + const message = { + message: 'Prebid Native', + action: 'allAssetRequest', + adId, + }; + + win.parent.postMessage(JSON.stringify(message), '*'); + } + + /* + * Sends postmessage to Prebid for native resize + */ + function requestHeightResize(adId, height) { + const message = { + message: 'Prebid Native', + action: 'resizeNativeHeight', + adId, + height, + }; + win.parent.postMessage(JSON.stringify(message), '*'); } @@ -228,11 +274,54 @@ export function newNativeAssetManager(win) { if (data.message === 'assetResponse') { const body = win.document.body.innerHTML; - const newHtml = replace(body, data); + const head = win.document.head.innerHTML; + const flag = (typeof win.pbNativeData !== 'undefined'); + + if (flag && data.adId !== win.pbNativeData.adId) return; + + if (head) win.document.head.innerHTML = replace(head, data); - win.document.body.innerHTML = newHtml; - callback && callback(); - win.removeEventListener('message', replaceAssets); + if ((data.hasOwnProperty('rendererUrl') && data.rendererUrl) || (flag && win.pbNativeData.hasOwnProperty('rendererUrl'))) { + if (win.renderAd) { + const newHtml = (win.renderAd && win.renderAd(data.assets)) || ''; + + win.document.body.innerHTML = body + newHtml; + callback && callback(); + win.removeEventListener('message', replaceAssets); + requestHeightResize(data.adId, (document.body.clientHeight || document.body.offsetHeight)); + } else if (document.getElementById('pb-native-renderer')) { + document.getElementById('pb-native-renderer').addEventListener('load', function() { + const newHtml = (win.renderAd && win.renderAd(data.assets)) || ''; + + win.document.body.innerHTML = body + newHtml; + callback && callback(); + win.removeEventListener('message', replaceAssets); + requestHeightResize(data.adId, (document.body.clientHeight || document.body.offsetHeight)); + }); + } else { + loadScript(win, ((flag && win.pbNativeData.hasOwnProperty('rendererUrl') && win.pbNativeData.rendererUrl) || data.rendererUrl), function() { + const newHtml = (win.renderAd && win.renderAd(data.assets)) || ''; + + win.document.body.innerHTML = body + newHtml; + callback && callback(); + win.removeEventListener('message', replaceAssets); + requestHeightResize(data.adId, (document.body.clientHeight || document.body.offsetHeight)); + }) + } + } else if ((data.hasOwnProperty('adTemplate') && data.adTemplate)||(flag && win.pbNativeData.hasOwnProperty('adTemplate'))) { + const template = (flag && win.pbNativeData.hasOwnProperty('adTemplate') && win.pbNativeData.adTemplate) || data.adTemplate; + const newHtml = replace(template, data); + win.document.body.innerHTML = body + newHtml; + callback && callback(); + win.removeEventListener('message', replaceAssets); + requestHeightResize(data.adId, (document.body.clientHeight || document.body.offsetHeight)); + } else { + const newHtml = replace(body, data); + + win.document.body.innerHTML = newHtml; + callback && callback(); + win.removeEventListener('message', replaceAssets); + } } } @@ -244,7 +333,8 @@ export function newNativeAssetManager(win) { let html = document; (assets || []).forEach(asset => { - const searchString = (adId) ? `${NATIVE_KEYS[asset.key]}:${adId}` : `${NATIVE_KEYS[asset.key]}`; + const flag = (typeof win.pbNativeData !== 'undefined'); + const searchString = (adId && !flag) ? `${NATIVE_KEYS[asset.key]}:${adId}` : ((flag) ? '##'+`${NATIVE_KEYS[asset.key]}`+'##' : `${NATIVE_KEYS[asset.key]}`); const searchStringRegex = new RegExp(searchString, 'g'); html = html.replace(searchStringRegex, asset.value); }); diff --git a/src/nativeRender.js b/src/nativeRender.js new file mode 100644 index 00000000..8636abed --- /dev/null +++ b/src/nativeRender.js @@ -0,0 +1,7 @@ +import { newNativeRenderManager } from './nativeRenderManager'; + +window.pbNativeTag = (window.pbNativeTag || {}); +const nativeRenderManager = newNativeRenderManager(window); + +window.pbNativeTag.renderNativeAd = nativeRenderManager.renderNativeAd; + diff --git a/src/nativeRenderManager.js b/src/nativeRenderManager.js new file mode 100644 index 00000000..f18866ac --- /dev/null +++ b/src/nativeRenderManager.js @@ -0,0 +1,78 @@ +/* + * Script to handle firing impression and click trackers from native teamplates + */ +import { parseUrl, triggerPixel, transformAuctionTargetingData } from './utils'; +import { newNativeAssetManager } from './nativeAssetManager'; + +const AD_ANCHOR_CLASS_NAME = 'pb-click'; +const AD_DATA_ADID_ATTRIBUTE = 'pbAdId'; + +export function newNativeRenderManager(win) { + let publisherDomain; + + + function findAdElements(className) { + let adElements = win.document.getElementsByClassName(className); + return adElements || []; + } + + function loadClickTrackers(event, adId) { + fireTracker(adId, 'click'); + } + + function fireTracker(adId, action) { + if (adId === '') { + console.warn('Prebid tracking event was missing \'adId\'. Was adId macro set in the HTML attribute ' + AD_DATA_ADID_ATTRIBUTE + 'on the ad\'s anchor element'); + } else { + let message = { message: 'Prebid Native', adId: adId }; + + // fires click trackers when called via link + if (action === 'click') { + message.action = 'click'; + } + + win.parent.postMessage(JSON.stringify(message), publisherDomain); + } + } + + function fireNativeImpTracker(adId) { + fireTracker(adId, 'impression'); + } + + function fireNativeCallback() { + const adElements = findAdElements(AD_ANCHOR_CLASS_NAME); + for (let i = 0; i < adElements.length; i++) { + adElements[i].addEventListener('click', function(event) { + loadClickTrackers(event, window.pbNativeData.adId); + }, true); + } + } + + // START OF MAIN CODE + let renderNativeAd = function(nativeTag) { + window.pbNativeData = nativeTag; + const targetingData = transformAuctionTargetingData(nativeTag); + const nativeAssetManager = newNativeAssetManager(window); + + if (nativeTag.hasOwnProperty('adId')) { + let parsedUrl = parseUrl(window.pbNativeData.pubUrl); + publisherDomain = parsedUrl.protocol + '://' + parsedUrl.host; + + if (nativeTag.hasOwnProperty('rendererUrl') && !nativeTag.rendererUrl.match(/##.*##/i)) { + const scr = document.createElement('SCRIPT'); + scr.src = nativeTag.rendererUrl, + scr.id = 'pb-native-renderer'; + document.body.appendChild(scr); + } + nativeAssetManager.loadAssets(nativeTag.adId,fireNativeCallback); + fireNativeCallback(); + fireNativeImpTracker(nativeTag.adId); + } else { + console.warn('Prebid Native Tag object was missing \'adId\'.'); + } + } + + return { + renderNativeAd + } +} diff --git a/src/nativeTrackers.js b/src/nativeTrackers.js index e2e0c428..d9e20fd7 100644 --- a/src/nativeTrackers.js +++ b/src/nativeTrackers.js @@ -3,4 +3,5 @@ import { newNativeTrackerManager } from './nativeTrackerManager'; window.pbNativeTag = (window.pbNativeTag || {}); const nativeTrackerManager = newNativeTrackerManager(window); -window.pbNativeTag.startTrackers = nativeTrackerManager.startTrackers; \ No newline at end of file +window.pbNativeTag.startTrackers = nativeTrackerManager.startTrackers; + diff --git a/test/helpers/mocks.js b/test/helpers/mocks.js index 7aba47a9..bf186fb2 100644 --- a/test/helpers/mocks.js +++ b/test/helpers/mocks.js @@ -2,6 +2,7 @@ export const mocks = { createFakeWindow: function (href) { return { document: { + head: {}, body: {} }, location: { diff --git a/test/spec/nativeAssetManager_spec.js b/test/spec/nativeAssetManager_spec.js index 4b5098e0..d6665b17 100644 --- a/test/spec/nativeAssetManager_spec.js +++ b/test/spec/nativeAssetManager_spec.js @@ -5,6 +5,27 @@ import { mocks } from 'test/helpers/mocks'; import * as utils from 'src/utils'; const AD_ID = 'abc123'; +const AD_ID2 = 'def456'; +const NATIVE_KEYS = { + title: 'hb_native_title', + body: 'hb_native_body', + body2: 'hb_native_body2', + privacyLink: 'hb_native_privacy', + sponsoredBy: 'hb_native_brand', + image: 'hb_native_image', + icon: 'hb_native_icon', + clickUrl: 'hb_native_linkurl', + displayUrl: 'hb_native_displayurl', + cta: 'hb_native_cta', + rating: 'hb_native_rating', + address: 'hb_native_address', + downloads: 'hb_native_downloads', + likes: 'hb_native_likes', + phone: 'hb_native_phone', + price: 'hb_native_price', + salePrice: 'hb_native_saleprice', + rendererUrl: 'hb_renderer_url', +}; const mockDocument = { getWindowObject: function() { @@ -17,15 +38,48 @@ const mockDocument = { }; // creates mock postmessage response from prebid's native.js:getAssetMessage -function createResponder(assets) { +function createResponder(assets,url,template) { return function(type, listener) { if (type !== 'message') { return; } - const data = { message: 'assetResponse', adId: AD_ID, assets }; + const data = { message: 'assetResponse', adId: AD_ID, assets, adTemplate:template, rendererUrl:url }; listener({ data: JSON.stringify(data) }); }; } +// creates mock postmessage response from prebid's native.js:getAssetMessage +function createAllResponder(assets,url,template) { + return function(type, listener) { + if (type !== 'message') { return; } + + const data = { message: 'assetResponse', adId: AD_ID, assets, adTemplate:template, rendererUrl:url }; + listener({ data: JSON.stringify(data) }); + }; +} + +// creates mock postmessage response from prebid's native.js:getAssetMessage using alternative id +function createAltAllResponder(assets,url,template) { + return function(type, listener) { + if (type !== 'message') { return; } + + const data = { message: 'assetResponse', adId: AD_ID2, assets, adTemplate:template, rendererUrl:url }; + listener({ data: JSON.stringify(data) }); + }; +} + +// creates mock html markup responsse from renderUrl +function generateRenderer(assets) { + let newhtml = '
##hb_native_body##<\/p>\r\n \t
##hb_native_body##<\/p>\r\n \t
Body content
`); + }); + + it('loads rendererUrl and passes assets to renderAd - writes response to innerHtml', () => { + const html = ``; + win.pbNativeData = { + pubUrl : 'https://www.url.com', + adId : AD_ID, + rendererUrl : 'https://www.renderer.com/render.js', + requestAllAssets : true + }; + + win.document.body.innerHTML = html; + win.renderAd = generateRenderer; + + win.addEventListener = createAllResponder([ + { key: 'body', value: 'Body content' }, + { key: 'title', value: 'new value' }, + { key: 'clickUrl', value: 'http://www.example.com' }, + { key: 'image', value: 'http://www.image.com/picture.jpg' }, + ],null,null); + + const nativeAssetManager = newNativeAssetManager(win); + nativeAssetManager.loadAssets(AD_ID); + + expect(win.document.body.innerHTML).to.include(`new value`); + expect(win.document.body.innerHTML).to.include(``); + expect(win.document.body.innerHTML).to.include(`Body content
`); + }); + + it('adId does not match, so assets are not replaced', () => { + const html = ``; + win.pbNativeData = { + pubUrl : 'https://www.url.com', + adId : 'OTHERID123', + rendererUrl : 'https://www.renderer.com/render.js', + requestAllAssets : true + }; + + win.document.body.innerHTML = html; + win.renderAd = generateRenderer; + + win.addEventListener = createAllResponder([ + { key: 'body', value: 'Body content' }, + { key: 'title', value: 'new value' }, + { key: 'clickUrl', value: 'http://www.example.com' }, + { key: 'image', value: 'http://www.image.com/picture.jpg' }, + ],null,null); + + const nativeAssetManager = newNativeAssetManager(win); + nativeAssetManager.loadAssets(AD_ID); + + expect(win.document.body.innerHTML).to.equal(``); + }); + + it('adId does not match on first response, so assets are not replaced until match on second response', () => { + const html = ``; + win.pbNativeData = { + pubUrl : 'https://www.url.com', + adId : 'def456', + rendererUrl : 'https://www.renderer.com/render.js', + requestAllAssets : true + }; + + win.document.body.innerHTML = html; + win.renderAd = generateRenderer; + + win.addEventListener = createAllResponder([ + { key: 'body', value: 'Body No Replace' }, + { key: 'title', value: 'new value no replace' }, + { key: 'clickUrl', value: 'http://www.example.com/noreplace' }, + { key: 'image', value: 'http://www.image.com/picture.jpg?noreplace=true' }, + ],null,null); + + const nativeAssetManager = newNativeAssetManager(win); + nativeAssetManager.loadAssets(AD_ID2); + + expect(win.document.body.innerHTML).to.equal(``); + + win.addEventListener = createAltAllResponder([ + { key: 'body', value: 'Body content' }, + { key: 'title', value: 'new value' }, + { key: 'clickUrl', value: 'http://www.example.com' }, + { key: 'image', value: 'http://www.image.com/picture.jpg' }, + ],null,null); + + nativeAssetManager.loadAssets(AD_ID2); + + expect(win.document.body.innerHTML).to.include(`new value`); + expect(win.document.body.innerHTML).to.include(``); + expect(win.document.body.innerHTML).to.include(`Body content
`); + }); + + it('no placeholders found but requests all assets flag set - rendererUrl', () => { + const html = ``, + url = 'https://www.renderer.com/render.js'; + win.pbNativeData = { + pubUrl : 'https://www.url.com', + adId : AD_ID, + rendererUrl : 'https://www.renderer.com/render.js', + requestAllAssets : true + }; + + win.document.body.innerHTML = html; + win.renderAd = generateRenderer; + + win.addEventListener = createAllResponder([ + { key: 'body', value: 'Body content' }, + { key: 'title', value: 'new value' }, + { key: 'clickUrl', value: 'http://www.example.com' }, + { key: 'image', value: 'http://www.image.com/picture.jpg' }, + ],url,null); + + const nativeAssetManager = newNativeAssetManager(win); + nativeAssetManager.loadAssets(AD_ID); + + expect(win.document.body.innerHTML).to.include(`new value`); + expect(win.document.body.innerHTML).to.include(``); + expect(win.document.body.innerHTML).to.include(`Body content
`); + }); + + it('no placeholders found but requests all assets flag set - adTemplate', () => { + const html = ``, + template = '##hb_native_body##<\/p>\r\n \t
Body content
`); + }); + + it('no placeholders found but assets defined in nativeTag - adTemplate', () => { + const html = ``, + template = '##hb_native_body##<\/p>\r\n \t
Body content
`); + }); + it('does not replace anything if no placeholders found', () => { const html = `