modules/sizeMappingV2.js
/**
* This module adds support for the new size mapping spec, Advanced Size Mapping. It's documented here. https://github.com/prebid/Prebid.js/issues/4129
* The implementation is an alternative to global sizeConfig. It introduces 'Ad Unit' & 'Bidder' level sizeConfigs and also supports 'labels' for conditional
* rendering. Read full API documentation on Prebid.org, http://prebid.org/dev-docs/modules/sizeMappingV2.html
*/
import {
deepClone,
getWindowTop,
isArray,
isArrayOfNums,
isValidMediaTypes,
logError,
logInfo,
logWarn
} from '../src/utils.js';
import {includes} from '../src/polyfill.js';
import {getHook} from '../src/hook.js';
import {adUnitSetupChecks} from '../src/prebid.js';
// Allows for stubbing of these functions while writing unit tests.
export const internal = {
checkBidderSizeConfigFormat,
getActiveSizeBucket,
getFilteredMediaTypes,
getAdUnitDetail,
getRelevantMediaTypesForBidder,
isLabelActivated
};
const V2_ADUNITS = new WeakMap();
/*
Returns "true" if at least one of the adUnits in the adUnits array is using an Ad Unit and/or Bidder level sizeConfig,
otherwise, returns "false."
*/
export function isUsingNewSizeMapping(adUnits) {
return !!adUnits.find(adUnit => {
if (V2_ADUNITS.has(adUnit)) return V2_ADUNITS.get(adUnit);
if (adUnit.mediaTypes) {
// checks for the presence of sizeConfig property at the adUnit.mediaTypes object
for (let mediaType of Object.keys(adUnit.mediaTypes)) {
if (adUnit.mediaTypes[mediaType].sizeConfig) {
V2_ADUNITS.set(adUnit, true);
return true;
}
}
for (let bid of adUnit.bids && isArray(adUnit.bids) ? adUnit.bids : []) {
if (bid.sizeConfig) {
V2_ADUNITS.set(adUnit, true);
return true;
}
}
V2_ADUNITS.set(adUnit, false);
return false;
}
});
}
/**
This hooked function executes before the function 'checkAdUnitSetup', that is defined in /src/prebid.js. It's necessary to run this funtion before
because it applies a series of checks in order to determine the correctness of the 'sizeConfig' array, which, the original 'checkAdUnitSetup' function
does not recognize.
@params {Array<AdUnits>} adUnits
@returns {Array<AdUnits>} validateAdUnits - Unrecognized properties are deleted.
*/
export function checkAdUnitSetupHook(adUnits) {
const validateSizeConfig = function (mediaType, sizeConfig, adUnitCode) {
let isValid = true;
const associatedProperty = {
banner: 'sizes',
video: 'playerSize',
native: 'active'
}
const propertyName = associatedProperty[mediaType];
const conditionalLogMessages = {
banner: 'Removing mediaTypes.banner from ad unit.',
video: 'Removing mediaTypes.video.sizeConfig from ad unit.',
native: 'Removing mediaTypes.native.sizeConfig from ad unit.'
}
if (Array.isArray(sizeConfig)) {
sizeConfig.forEach((config, index) => {
const keys = Object.keys(config);
/*
Check #1 (Applies to 'banner', 'video' and 'native' media types.)
Verify that all config objects include 'minViewPort' and 'sizes' property.
If they do not, return 'false'.
*/
if (!(includes(keys, 'minViewPort') && includes(keys, propertyName))) {
logError(`Ad unit ${adUnitCode}: Missing required property 'minViewPort' or 'sizes' from 'mediaTypes.${mediaType}.sizeConfig[${index}]'. ${conditionalLogMessages[mediaType]}`);
isValid = false;
return;
}
/*
Check #2 (Applies to 'banner', 'video' and 'native' media types.)
Verify that 'config.minViewPort' property is in [width, height] format.
If not, return false.
*/
if (!isArrayOfNums(config.minViewPort, 2)) {
logError(`Ad unit ${adUnitCode}: Invalid declaration of 'minViewPort' in 'mediaTypes.${mediaType}.sizeConfig[${index}]'. ${conditionalLogMessages[mediaType]}`);
isValid = false;
return;
}
/*
Check #3 (Applies only to 'banner' and 'video' media types.)
Verify that 'config.sizes' (in case of banner) or 'config.playerSize' (in case of video)
property is in [width, height] format. If not, return 'false'.
*/
if (mediaType === 'banner' || mediaType === 'video') {
let showError = false;
if (Array.isArray(config[propertyName])) {
const validatedSizes = adUnitSetupChecks.validateSizes(config[propertyName]);
if (config[propertyName].length > 0 && validatedSizes.length === 0) {
isValid = false;
showError = true;
}
} else {
// Either 'sizes' or 'playerSize' is not declared as an array, which makes it invalid by default.
isValid = false;
showError = true;
}
if (showError) {
logError(`Ad unit ${adUnitCode}: Invalid declaration of '${propertyName}' in 'mediaTypes.${mediaType}.sizeConfig[${index}]'. ${conditionalLogMessages[mediaType]}`);
return;
}
}
/*
Check #4 (Applies only to 'native' media type)
Verify that 'config.active' is a 'boolean'.
If not, return 'false'.
*/
if (FEATURES.NATIVE && mediaType === 'native') {
if (typeof config[propertyName] !== 'boolean') {
logError(`Ad unit ${adUnitCode}: Invalid declaration of 'active' in 'mediaTypes.${mediaType}.sizeConfig[${index}]'. ${conditionalLogMessages[mediaType]}`);
isValid = false;
}
}
});
} else {
logError(`Ad unit ${adUnitCode}: Invalid declaration of 'sizeConfig' in 'mediaTypes.${mediaType}.sizeConfig'. ${conditionalLogMessages[mediaType]}`);
isValid = false;
return isValid;
}
// If all checks have passed, isValid should equal 'true'
return isValid;
}
const validatedAdUnits = [];
adUnits.forEach(adUnit => {
adUnit = adUnitSetupChecks.validateAdUnit(adUnit);
if (adUnit == null) return;
const mediaTypes = adUnit.mediaTypes;
let validatedBanner, validatedVideo, validatedNative;
if (mediaTypes.banner) {
if (mediaTypes.banner.sizes) {
// Ad unit is using 'mediaTypes.banner.sizes' instead of the new property 'sizeConfig'. Apply the old checks!
validatedBanner = adUnitSetupChecks.validateBannerMediaType(adUnit);
} else if (mediaTypes.banner.sizeConfig) {
// Ad unit is using the 'sizeConfig' property, 'mediaTypes.banner.sizeConfig'. Apply the new checks!
validatedBanner = deepClone(adUnit);
const isBannerValid = validateSizeConfig('banner', mediaTypes.banner.sizeConfig, adUnit.code);
if (!isBannerValid) {
delete validatedBanner.mediaTypes.banner;
} else {
/*
Make sure 'sizes' field is always an array of arrays. If not, make it so.
For example, [] becomes [[]], and [360, 400] becomes [[360, 400]]
*/
validatedBanner.mediaTypes.banner.sizeConfig.forEach(config => {
if (!Array.isArray(config.sizes[0])) {
config.sizes = [config.sizes];
}
});
}
} else {
// Ad unit is invalid since it's mediaType property does not have either 'sizes' or 'sizeConfig' declared.
logError(`Ad unit ${adUnit.code}: 'mediaTypes.banner' does not contain either 'sizes' or 'sizeConfig' property. Removing 'mediaTypes.banner' from ad unit.`);
validatedBanner = deepClone(adUnit);
delete validatedBanner.mediaTypes.banner;
}
}
if (FEATURES.VIDEO && mediaTypes.video) {
if (mediaTypes.video.playerSize) {
// Ad unit is using 'mediaTypes.video.playerSize' instead of the new property 'sizeConfig'. Apply the old checks!
validatedVideo = validatedBanner ? adUnitSetupChecks.validateVideoMediaType(validatedBanner) : adUnitSetupChecks.validateVideoMediaType(adUnit);
} else if (mediaTypes.video.sizeConfig) {
// Ad unit is using the 'sizeConfig' property, 'mediaTypes.video.sizeConfig'. Apply the new checks!
validatedVideo = validatedBanner || deepClone(adUnit);
const isVideoValid = validateSizeConfig('video', mediaTypes.video.sizeConfig, adUnit.code);
if (!isVideoValid) {
delete validatedVideo.mediaTypes.video.sizeConfig;
} else {
/*
Make sure 'playerSize' field is always an array of arrays. If not, make it so.
For example, [] becomes [[]], and [640, 400] becomes [[640, 400]]
*/
validatedVideo.mediaTypes.video.sizeConfig.forEach(config => {
if (!Array.isArray(config.playerSize[0])) {
config.playerSize = [config.playerSize];
}
});
}
}
}
if (FEATURES.NATIVE && mediaTypes.native) {
// Apply the old native checks
validatedNative = validatedVideo ? adUnitSetupChecks.validateNativeMediaType(validatedVideo) : validatedBanner ? adUnitSetupChecks.validateNativeMediaType(validatedBanner) : adUnitSetupChecks.validateNativeMediaType(adUnit);
// Apply the new checks if 'mediaTypes.native.sizeConfig' detected
if (mediaTypes.native.sizeConfig) {
const isNativeValid = validateSizeConfig('native', mediaTypes.native.sizeConfig, adUnit.code);
if (!isNativeValid) {
delete validatedNative.mediaTypes.native.sizeConfig;
}
}
}
const validatedAdUnit = Object.assign({}, validatedBanner, validatedVideo, validatedNative);
validatedAdUnits.push(validatedAdUnit);
});
return validatedAdUnits;
}
getHook('checkAdUnitSetup').before(function (fn, adUnits) {
const usingNewSizeMapping = isUsingNewSizeMapping(adUnits);
if (usingNewSizeMapping) {
// if adUnits are found using the sizeMappingV2 spec, we run additional checks on them for checking the validity of sizeConfig object
// in addition to running the base checks on the mediaType object and return the adUnit without calling the base function.
adUnits = checkAdUnitSetupHook(adUnits);
return fn.bail(adUnits);
} else {
// if presence of sizeMappingV2 spec is not detected on adUnits, we default back to the original checks defined in the base function.
return fn.call(this, adUnits);
}
});
// checks if the sizeConfig object declared at the Bidder level is in the right format or not.
export function checkBidderSizeConfigFormat(sizeConfig) {
let didCheckPass = true;
if (Array.isArray(sizeConfig) && sizeConfig.length > 0) {
sizeConfig.forEach(config => {
const keys = Object.keys(config);
if ((includes(keys, 'minViewPort') &&
includes(keys, 'relevantMediaTypes')) &&
isArrayOfNums(config.minViewPort, 2) &&
Array.isArray(config.relevantMediaTypes) &&
config.relevantMediaTypes.length > 0 &&
(config.relevantMediaTypes.length > 1 ? (config.relevantMediaTypes.every(mt => (includes(['banner', 'video', 'native'], mt))))
: (['none', 'banner', 'video', 'native'].indexOf(config.relevantMediaTypes[0]) > -1))) {
didCheckPass = didCheckPass && true;
} else {
didCheckPass = false;
}
});
} else {
didCheckPass = false;
}
return didCheckPass;
}
getHook('setupAdUnitMediaTypes').before(function (fn, adUnits, labels) {
if (isUsingNewSizeMapping(adUnits)) {
return fn.bail(setupAdUnitMediaTypes(adUnits, labels));
} else {
return fn.call(this, adUnits, labels);
}
});
/**
* Given an Ad Unit or a Bid as an input, returns a boolean telling if the Ad Unit/ Bid is active based on label checks
* @param {Object<BidOrAdUnit>} bidOrAdUnit - Either the Ad Unit object or the Bid object
* @param {Array<string>} activeLabels - List of active labels passed as an argument to pbjs.requestBids function
* @param {string} adUnitCode - Unique string identifier for an Ad Unit.
* @param {number} adUnitInstance - Instance count of an 'Identical' ad unit.
* @returns {boolean} Represents if the Ad Unit or the Bid is active or not
*/
export function isLabelActivated(bidOrAdUnit, activeLabels, adUnitCode, adUnitInstance) {
let labelOperator;
const labelsFound = Object.keys(bidOrAdUnit).filter(prop => prop === 'labelAny' || prop === 'labelAll');
if (labelsFound && labelsFound.length > 1) {
logWarn(`Size Mapping V2:: ${(bidOrAdUnit.code)
? (`Ad Unit: ${bidOrAdUnit.code}(${adUnitInstance}) => Ad unit has multiple label operators. Using the first declared operator: ${labelsFound[0]}`)
: (`Ad Unit: ${adUnitCode}(${adUnitInstance}), Bidder: ${bidOrAdUnit.bidder} => Bidder has multiple label operators. Using the first declared operator: ${labelsFound[0]}`)}`);
}
labelOperator = labelsFound[0];
if (labelOperator && !activeLabels) {
logWarn(`Size Mapping V2:: ${(bidOrAdUnit.code)
? (`Ad Unit: ${bidOrAdUnit.code}(${adUnitInstance}) => Found '${labelOperator}' on ad unit, but 'labels' is not set. Did you pass 'labels' to pbjs.requestBids() ?`)
: (`Ad Unit: ${adUnitCode}(${adUnitInstance}), Bidder: ${bidOrAdUnit.bidder} => Found '${labelOperator}' on bidder, but 'labels' is not set. Did you pass 'labels' to pbjs.requestBids() ?`)}`);
return true;
}
if (labelOperator === 'labelAll' && Array.isArray(bidOrAdUnit[labelOperator])) {
if (bidOrAdUnit.labelAll.length === 0) {
logWarn(`Size Mapping V2:: Ad Unit: ${bidOrAdUnit.code}(${adUnitInstance}) => Ad unit has declared property 'labelAll' with an empty array.`);
return true;
}
return bidOrAdUnit.labelAll.every(label => includes(activeLabels, label));
} else if (labelOperator === 'labelAny' && Array.isArray(bidOrAdUnit[labelOperator])) {
if (bidOrAdUnit.labelAny.length === 0) {
logWarn(`Size Mapping V2:: Ad Unit: ${bidOrAdUnit.code}(${adUnitInstance}) => Ad unit has declared property 'labelAny' with an empty array.`);
return true;
}
return bidOrAdUnit.labelAny.some(label => includes(activeLabels, label));
}
return true;
}
/**
* Processes the MediaTypes object and calculates the active size buckets for each Media Type. Uses `window.innerWidth` and `window.innerHeight`
* to calculate the width and height of the active Viewport.
* @param {MediaTypes} mediaTypes Contains information about supported media types for an Ad Unit and size information for each of those types
* @returns {FilteredMediaTypes} Filtered mediaTypes object with relevant media types filtered by size buckets based on activeViewPort size
*/
export function getFilteredMediaTypes(mediaTypes) {
let
activeViewportWidth,
activeViewportHeight,
transformedMediaTypes;
transformedMediaTypes = deepClone(mediaTypes);
let activeSizeBucket = {
banner: undefined,
video: undefined,
native: undefined
}
try {
activeViewportWidth = getWindowTop().innerWidth;
activeViewportHeight = getWindowTop().innerHeight;
} catch (e) {
logWarn(`SizeMappingv2:: Unfriendly iframe blocks viewport size to be evaluated correctly`);
activeViewportWidth = window.innerWidth;
activeViewportHeight = window.innerHeight;
}
const activeViewport = [activeViewportWidth, activeViewportHeight];
Object.keys(mediaTypes).map(mediaType => {
const sizeConfig = mediaTypes[mediaType].sizeConfig;
if (sizeConfig) {
activeSizeBucket[mediaType] = getActiveSizeBucket(sizeConfig, activeViewport);
const filteredSizeConfig = sizeConfig.filter(config => config.minViewPort === activeSizeBucket[mediaType] && isSizeConfigActivated(mediaType, config));
transformedMediaTypes[mediaType] = Object.assign({ filteredSizeConfig }, mediaTypes[mediaType]);
// transform mediaTypes object
const config = {
banner: 'sizes',
video: 'playerSize'
};
if (transformedMediaTypes[mediaType].filteredSizeConfig.length > 0) {
// map sizes or playerSize property in filteredSizeConfig object to transformedMediaTypes.banner.sizes if mediaType is banner
// or transformedMediaTypes.video.playerSize if the mediaType in video.
// doesn't apply to native mediaType since native doesn't have any property defining 'sizes' or 'playerSize'.
if (mediaType !== 'native') {
transformedMediaTypes[mediaType][config[mediaType]] = transformedMediaTypes[mediaType].filteredSizeConfig[0][config[mediaType]];
}
} else {
delete transformedMediaTypes[mediaType];
}
}
})
// filter out 'undefined' values from activeSizeBucket object and attach sizes/playerSize information against the active size bucket.
const sizeBucketToSizeMap = Object
.keys(activeSizeBucket)
.filter(mediaType => activeSizeBucket[mediaType] !== undefined)
.reduce((sizeBucketToSizeMap, mediaType) => {
sizeBucketToSizeMap[mediaType] = {
activeSizeBucket: activeSizeBucket[mediaType],
activeSizeDimensions: (mediaType === 'banner') ? (
// banner mediaType gets deleted incase no sizes are specified for a given size bucket, that's why this check is necessary
(transformedMediaTypes.banner) ? (transformedMediaTypes.banner.sizes) : ([])
) : ((mediaType === 'video') ? (
// video mediaType gets deleted incase no playerSize is specified for a given size bucket, that's why this check is necessary
(transformedMediaTypes.video) ? (transformedMediaTypes.video.playerSize) : ([])
) : ('NA'))
};
return sizeBucketToSizeMap;
}, {});
return { sizeBucketToSizeMap, activeViewport, transformedMediaTypes };
}
/**
* Evaluates the given sizeConfig object and checks for various properties to determine if the sizeConfig is active or not. For example,
* let's suppose the sizeConfig is for a Banner media type. Then, if the sizes property is found empty, it returns false, else returns true.
* In case of a Video media type, it checks the playerSize property. If found empty, returns false, else returns true.
* In case of a Native media type, it checks the active property. If found false, returns false, if found true, returns true.
* @param {string} mediaType It can be 'banner', 'native' or 'video'
* @param {Object<SizeConfig>} sizeConfig Represents the sizeConfig object which is active based on the current viewport size
* @returns {boolean} Represents if the size config is active or not
*/
export function isSizeConfigActivated(mediaType, sizeConfig) {
switch (mediaType) {
case 'banner':
// we need this check, sizeConfig.sizes[0].length > 0, in place because a sizeBucket can have sizes: [],
// gets converted to sizes: [[]] in the checkAdUnitSetupHook function
return sizeConfig.sizes && sizeConfig.sizes.length > 0 && sizeConfig.sizes[0].length > 0;
case 'video':
// for why we need the last check, read the above comment
return sizeConfig.playerSize && sizeConfig.playerSize.length > 0 && sizeConfig.playerSize[0].length > 0;
case 'native':
return sizeConfig.active;
default:
return false;
}
}
/**
* Returns the active size bucket for a given media type
* @param {Array<SizeConfig>} sizeConfig SizeConfig defines the characteristics of an Ad Unit categorised into multiple size buckets per media type
* @param {Array} activeViewport Viewport size of the browser in the form [w, h] (w -> width, h -> height)
* Calculated at the time of making call to pbjs.requestBids function
* @returns {Array} The active size bucket matching the activeViewPort, for example: [750, 0]
*/
export function getActiveSizeBucket(sizeConfig, activeViewport) {
let activeSizeBucket = [];
sizeConfig
.sort((a, b) => a.minViewPort[0] - b.minViewPort[0])
.forEach(config => {
if (activeViewport[0] >= config.minViewPort[0]) {
if (activeViewport[1] >= config.minViewPort[1]) {
activeSizeBucket = config.minViewPort;
} else {
activeSizeBucket = [];
}
}
})
return activeSizeBucket;
}
export function getRelevantMediaTypesForBidder(sizeConfig, activeViewport) {
const mediaTypes = new Set();
if (internal.checkBidderSizeConfigFormat(sizeConfig)) {
const activeSizeBucket = internal.getActiveSizeBucket(sizeConfig, activeViewport);
sizeConfig.filter(config => config.minViewPort === activeSizeBucket)[0]['relevantMediaTypes'].forEach((mt) => mediaTypes.add(mt));
}
return mediaTypes;
}
export function getAdUnitDetail(adUnit, labels, adUnitInstance) {
const isLabelActivated = internal.isLabelActivated(adUnit, labels, adUnit.code, adUnitInstance);
const { sizeBucketToSizeMap, activeViewport, transformedMediaTypes } = isLabelActivated && internal.getFilteredMediaTypes(adUnit.mediaTypes);
isLabelActivated && logInfo(`Size Mapping V2:: Ad Unit: ${adUnit.code}(${adUnitInstance}) => Active size buckets after filtration: `, sizeBucketToSizeMap);
return {
activeViewport,
transformedMediaTypes,
isLabelActivated,
};
}
export function setupAdUnitMediaTypes(adUnits, labels) {
const duplCounter = {};
return adUnits.reduce((result, adUnit) => {
const instance = (() => {
if (!duplCounter.hasOwnProperty(adUnit.code)) {
duplCounter[adUnit.code] = 1;
}
return duplCounter[adUnit.code]++;
})();
if (adUnit.mediaTypes && isValidMediaTypes(adUnit.mediaTypes)) {
const { activeViewport, transformedMediaTypes, isLabelActivated } = internal.getAdUnitDetail(adUnit, labels, instance);
if (isLabelActivated) {
if (Object.keys(transformedMediaTypes).length === 0) {
logInfo(`Size Mapping V2:: Ad Unit: ${adUnit.code}(${instance}) => Ad unit disabled since there are no active media types after sizeConfig filtration.`);
} else {
adUnit.mediaTypes = transformedMediaTypes;
adUnit.bids = adUnit.bids.reduce((bids, bid) => {
if (internal.isLabelActivated(bid, labels, adUnit.code, instance)) {
if (bid.sizeConfig) {
const relevantMediaTypes = internal.getRelevantMediaTypesForBidder(bid.sizeConfig, activeViewport);
if (relevantMediaTypes.size === 0) {
logError(`Size Mapping V2:: Ad Unit: ${adUnit.code}(${instance}), Bidder: ${bid.bidder} => 'sizeConfig' is not configured properly. This bidder won't be eligible for sizeConfig checks and will remain active.`);
bids.push(bid);
} else if (!relevantMediaTypes.has('none')) {
let modified = false;
const bidderMediaTypes = Object.fromEntries(
Object.entries(transformedMediaTypes)
.filter(([key, val]) => {
if (!relevantMediaTypes.has(key)) {
modified = true;
return false;
}
return true;
})
);
if (Object.keys(bidderMediaTypes).length > 0) {
if (modified) {
bid.mediaTypes = bidderMediaTypes;
}
bids.push(bid);
} else {
logInfo(`Size Mapping V2:: Ad Unit: ${adUnit.code}(${instance}), Bidder: ${bid.bidder} => 'relevantMediaTypes' does not match with any of the active mediaTypes at the Ad Unit level. This bidder is disabled.`);
}
} else {
logInfo(`Size Mapping V2:: Ad Unit: ${adUnit.code}(${instance}), Bidder: ${bid.bidder} => 'relevantMediaTypes' is set to 'none' in sizeConfig for current viewport size. This bidder is disabled.`);
}
} else {
bids.push(bid);
}
} else {
logInfo(`Size Mapping V2:: Ad Unit: ${adUnit.code}(${instance}), Bidder: ${bid.bidder} => Label check for this bidder has failed. This bidder is disabled.`);
}
return bids;
}, []);
result.push(adUnit);
}
} else {
logInfo(`Size Mapping V2:: Ad Unit: ${adUnit.code}(${instance}) => Ad unit is disabled due to failing label check.`);
}
} else {
logWarn(`Size Mapping V2:: Ad Unit: ${adUnit.code} => Ad unit has declared invalid 'mediaTypes' or has not declared a 'mediaTypes' property`);
}
return result;
}, [])
}