UNPKG

@builder.io/sdk

Version:

711 lines (710 loc) 24.4 kB
var __assign = (this && this.__assign) || Object.assign || function(t) { for (var s, i = 1, n = arguments.length; i < n; i++) { s = arguments[i]; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p]; } return t; }; console.log('loaded?'); import sortBy from 'lodash-es/sortBy'; import omit from 'lodash-es/omit'; import throttle from 'lodash-es/throttle'; import includes from 'lodash-es/includes'; import queryString from 'query-string'; import parser from 'ua-parser-js'; // import { ContentModelType } from '../../app/models/content.model'; import { Observable } from 'rxjs/Observable'; import { BehaviorSubject } from 'rxjs/BehaviorSubject'; import stringify from 'json-stable-stringify'; // import { humanCase } from '../../app/functions/human-case.function'; import Cookies from 'js-cookie'; import url from 'url'; import 'whatwg-fetch'; import { EventCapturer } from './classes/event-capturer.class'; // Annoying workaround for module loading var stableStringify = stringify.default || stringify; var anyParser = parser; export var isBrowser = typeof window !== 'undefined'; export var isIframe = isBrowser && window.top !== window.self; var fetch = (isBrowser && window.fetch) || require('node-fetch').default; // Workaround for oddly random module loading issues var UaParser = typeof anyParser.default === 'function' ? anyParser.default : anyParser; export function BuilderComponent(info) { if (info === void 0) { info = {}; } return function(component) { var spec = __assign({}, info, { class: component }); if (!spec.name) { spec.name = component.name; } if ( !Builder.components.find(function(item) { return item.name === spec.name; }) ) { Builder.components.push(spec); // TODO: serialize component name and inputs if (isBrowser) { window.top.postMessage( { type: 'builder.registerComponent', data: omit(spec, 'class'), }, '*' ); } } }; } var Builder = /** @class */ (function() { function Builder(apiKey) { if (apiKey === void 0) { apiKey = null; } var _this = this; this.apiKey = apiKey; this.eventsQueue = []; this.throttledClearEventsQueue = throttle(function() { _this.processEventsQueue(); }, 100); this.isUsed = false; this.editingMode$ = new BehaviorSubject(isIframe); // TODO: decorator to do this stuff with the get/set (how do with typing too? compiler?) this.editingModel$ = new BehaviorSubject(null); this.userAgent = (typeof navigator === 'object' && navigator.userAgent) || ''; this.blockContentLoading = ''; this.observersByModelType = {}; this.getContentQueue = null; this.priorContentQueue = null; // TODO: how prune deprecated tests this.testCookiePrefix = 'builder.tests'; // in the browser ensure this is a singleton // FIXME: why are there attempts to load this twice? if (isBrowser) { // TODO: ensure certain events only bound one time as static property } if (isBrowser) { this.bindMessageListeners(); } if (isIframe) { this.loadFullStory(); } } Builder.loadIncrementalDom = function() { if (!this.isBrowser || this.incrementalDomLoaded) { return; } console.info('Loading incremental DOM...'); var url = 'https://ajax.googleapis.com/ajax/libs/incrementaldom/0.5.1/incremental-dom-min.js'; var script = document.createElement('script'); script.src = url; document.head.appendChild(script); this.incrementalDomLoaded = true; }; Builder.patchDom = function(selector, vtree) { var _this = this; if (!this.isBrowser) { return; } var element = document.querySelector(selector); if (!element) { return; } if (!IncrementalDOM) { console.warn('IncrementalDOM not loaded!'); return; } IncrementalDOM.patch(element, function() { _this.vTreeToIncrementalDom(vtree); }); }; Builder.patchHtml = function(selector, html) { if (!this.isBrowser) { return; } var element = document.querySelector(selector); if (!element) { return; } element.innerHTML = html; }; Builder.vTreeToIncrementalDom = function(tree) { var _this = this; if (tree.type === 'VirtualNode') { var node = tree; // TODO: handle other properties/attributes var properties = []; for (var key in node.properties) { var value = node.properties[key]; if (key === 'attributes') { for (var attrKey in value) { properties.push(attrKey); properties.push(value[attrKey]); } } else { properties.push(key); properties.push(value); } } IncrementalDOM.elementOpen.apply( IncrementalDOM, [node.tagName, undefined, undefined].concat(properties) ); node.children.forEach(function(node) { return _this.vTreeToIncrementalDom(tree); }); IncrementalDOM.elementClose(node.tagName); } else { IncrementalDOM.text(tree.text); } }; Object.defineProperty(Builder, 'editingPage', { get: function() { return this._editingPage; }, set: function(editingPage) { this._editingPage = editingPage; if (isBrowser && isIframe) { if (editingPage) { // this.loadIncrementalDom(); document.body.classList.add('builder-editing-page'); } else { document.body.classList.remove('builder-editing-page'); } } }, enumerable: true, configurable: true, }); // TODO: style guide, etc off this system as well? Builder.component = function(info) { var _this = this; if (info === void 0) { info = {}; } return function(component) { var spec = __assign({}, info, { class: component }); if (!spec.name) { spec.name = component.name; } if ( !_this.components.find(function(item) { return item.name === spec.name; }) ) { _this.components.push(spec); // TODO: serialize component name and inputs if (isBrowser) { window.top.postMessage( { type: 'builder.registerComponent', data: omit(spec, 'class'), }, '*' ); } } }; }; Object.defineProperty(Builder, 'Component', { get: function() { return this.component; }, enumerable: true, configurable: true, }); Builder.prototype.processEventsQueue = function() { if (!this.eventsQueue.length) { return; } var events = this.eventsQueue; this.eventsQueue = []; // TODO: centralize this var host = this.getLocation().host === 'localhost:4205' ? 'http://localhost:5000' : 'https://builder.io'; fetch(host + '/api/v1/track', { method: 'POST', body: JSON.stringify({ events: events }), headers: { 'content-type': 'application/json', }, mode: 'cors', }); }; Object.defineProperty(Builder.prototype, 'editingMode', { get: function() { return this.editingMode$.value; }, set: function(value) { if (value !== this.editingMode) { this.editingMode$.next(value); } }, enumerable: true, configurable: true, }); Object.defineProperty(Builder.prototype, 'editingModel', { get: function() { return this.editingModel$.value; }, set: function(value) { if (value !== this.editingModel) { this.editingModel$.next(value); } }, enumerable: true, configurable: true, }); Builder.prototype.setUserAgent = function(userAgent) { this.userAgent = userAgent; }; Builder.prototype.track = function(eventName, properties) { if (properties === void 0) { properties = {}; } if (isIframe || !isBrowser) { return; } // batch events this.eventsQueue.push({ type: 'impression', data: __assign({}, properties), }); this.throttledClearEventsQueue(); }; Builder.prototype.trackImpression = function(contentId, variationId) { if (isIframe || !isBrowser) { return; } // TODO: use this.track method this.eventsQueue.push({ type: 'impression', data: { contentId: contentId, variationId: variationId !== contentId ? variationId : undefined, ownerId: this.apiKey, }, }); this.throttledClearEventsQueue(); }; Builder.prototype.trackInteraction = function(contentId, variationId) { if (isIframe || !isBrowser) { return; } // TODO: use this.track method this.eventsQueue.push({ type: 'click', data: { contentId: contentId, variationId: variationId !== contentId ? variationId : undefined, ownerId: this.apiKey, }, }); this.throttledClearEventsQueue(); }; Builder.prototype.component = function(info) { if (info === void 0) { info = {}; } return Builder.component(info); }; Builder.prototype.bindMessageListeners = function() { var _this = this; // TODO: handle race condition of content already loading // TODO: move to another file if (isBrowser) { addEventListener('message', function(event) { if (!_this.isUsed) { return; } var url = new URL(event.origin); var allowedHosts = ['builder.io', 'localhost']; if (!includes(allowedHosts, url.hostname)) { return; } var data = event.data; if (data) { switch (data.type) { case 'builder.patchDom': { Builder.patchDom(data.data.selector, data.data.tree); break; } case 'builder.patchHtml': { Builder.patchHtml(data.data.selector, data.data.html); break; } case 'builder.contentUpdate': var model = data.data.modelName; var contentData = data.data.data; // hmmm... var observer = _this.observersByModelType[model]; if (observer) { observer.next([contentData]); } break; case 'builder.getComponents': // TODO: serialize component name and inputs window.top.postMessage( { type: 'builder.components', data: Builder.components.map(function(item) { return omit(item, 'class'); }), }, '*' ); break; case 'builder.editingModel': _this.editingModel = data.data.model; break; case 'builder.registerComponent': var componentData = data.data; Builder.components.push(componentData); break; case 'builder.blockContentLoading': if (typeof data.data.model === 'string') { _this.blockContentLoading = data.data.model; } break; case 'builder.editingMode': var editingMode = data.data; if (editingMode) { _this.editingMode = true; document.body.classList.add('builder-editing'); } else { _this.editingMode = false; document.body.classList.remove('builder-editing'); } break; case 'builder.editingPageMode': var editingPageMode = data.data; Builder.editingPage = editingPageMode; break; case 'builder.overrideUserAttributes': var userAttributes = data.data; Object.assign(Builder.overrideUserAttributes, data.data); _this.flushGetContentQueue(true); // TODO: refetch too break; case 'builder.overrideTestGroup': var _a = data.data, variationId = _a.variationId, contentId = _a.contentId; if (variationId && contentId) { _this.setTestCookie(contentId, variationId); _this.flushGetContentQueue(true); } case 'builder.evaluate': { var text = data.data.text; var args = data.data.arguments || []; var id = data.data.id; // tslint:disable-next-line:no-function-constructor-with-string-args var fn = new Function(text); var result = fn.apply(_this, args); window.top.postMessage( { type: 'builder.evaluateResult', data: { result: result, id: id, text: text, }, }, '*' ); } } } }); } }; Builder.prototype.init = function(apiKey) { this.apiKey = apiKey; return this; }; Builder.prototype.getLocation = function() { return (typeof location === 'object' && url.parse(location.href)) || {}; }; Builder.prototype.getUserAttributes = function(userAgent) { if (userAgent === void 0) { userAgent = this.userAgent; } this.isUsed = true; if (!userAgent) { console.warn( 'No user agent set! For help on how to set this please contact steve@builder.io' ); } var ua = new UaParser(userAgent); // FIXME var url = this.getLocation(); var device = ua.getDevice(); // TODO: get these from exension as well return __assign( { queryString: url.search, urlPath: url.pathname, // Removinf for now because of cache keys // referrer: document.referrer, // language: navigator.language.split('-')[0], device: device.type || 'desktop', operatingSystem: (ua.getOS().name || '').toLowerCase() || undefined, browser: (ua.getBrowser().name || '').toLowerCase() || undefined, }, Builder.overrideUserAttributes ); }; Builder.prototype.setUserAttributes = function(options) { Object.assign(Builder.overrideUserAttributes, options); }; Builder.prototype.loadFullStory = function() { // TODO: check that the iframe's parent is buidler.io if (!Builder.isIframe) { return; } // If fullstory already loaded return if (window['_fs_org']) { return; } var script = document.createElement('script'); script.innerHTML = "\n window['_fs_run_in_iframe'] = true\n window['_fs_debug'] = false;\n window['_fs_host'] = 'fullstory.com';\n window['_fs_org'] = 'B9193';\n window['_fs_namespace'] = 'FS';\n (function(m,n,e,t,l,o,g,y){\n if (e in m) {if(m.console && m.console.log) { m.console.log('FullStory namespace conflict. Please set window[\"_fs_namespace\"].');} return;}\n g=m[e]=function(a,b){g.q?g.q.push([a,b]):g._api(a,b);};g.q=[];\n o=n.createElement(t);o.async=1;o.src='https://'+_fs_host+'/s/fs.js';\n y=n.getElementsByTagName(t)[0];y.parentNode.insertBefore(o,y);\n g.identify=function(i,v){g(l,{uid:i});if(v)g(l,v)};g.setUserVars=function(v){g(l,v)};\n y=\"rec\";g.shutdown=function(i,v){g(y,!1)};g.restart=function(i,v){g(y,!0)};\n y=\"consent\";g[y]=function(a){g(y,!arguments.length||a)};\n g.identifyAccount=function(i,v){o='account';v=v||{};v.acctId=i;g(o,v)};\n g.clearUserCookie=function(){};\n })(window,document,window['_fs_namespace'],'script','user');\n "; document.head.appendChild(script); }; // TODO: also take priority or group name/number so can // group fast stuff to show right away and slow or lower priority after' // how does graphql defer work? // TODO: add defaultContent to regular getContent method Builder.prototype.queueGetContent = function(modelName, options) { var _this = this; if (options === void 0) { options = {}; } var initialContent = options.initialContent; if (!initialContent) { if (!this.getContentQueue) { this.getContentQueue = []; setTimeout(function() { _this.flushGetContentQueue(); }); } this.getContentQueue.push(modelName); } return new Observable(function(observer) { _this.observersByModelType[modelName] = observer; if (initialContent) { // setImmediate(() => { observer.next(initialContent); // }); } }); }; Builder.prototype.requestUrl = function(url) { return fetch(url).then(function(res) { return res.json(); }); }; Builder.prototype.flushGetContentQueue = function(usePastQueue) { var _this = this; if (usePastQueue === void 0) { usePastQueue = false; } if (!this.apiKey) { throw new Error('Builder needs to be initialized with an API key!'); } if (!usePastQueue && !this.getContentQueue) { return; } var queryParams = {}; var pageQueryParams = typeof location !== 'undefined' ? queryString.parse(location.search) : undefined || {}; // TODO: merge in the attribute from query string ones queryParams.userAttributes = stableStringify(this.getUserAttributes()); var queue = (usePastQueue ? this.priorContentQueue : this.getContentQueue) || []; if (!usePastQueue) { this.priorContentQueue = queue; this.getContentQueue = null; } // TODO: cachebust if bd.noCache in request, also perhaps if in iframe // if (options.cachebust) { // queryParams.t = Date.now().toString(); // } var cachebust = isIframe || pageQueryParams.cachebust; if (cachebust) { queryParams.cachebuster = Date.now().toString(); } var hasParams = Object.keys(queryParams).length > 0; var host = this.getLocation().host === 'localhost:4205' ? 'http://localhost:5000' : 'https://builder.io'; var modelNames = queue.join(','); // FIXME: have a "core" SDK that doesn't implement http, // so SDKs like angular can use it's own http method var promise = this.requestUrl( host + '/api/v1/content/' + this.apiKey + '/' + modelNames + (queryParams && hasParams ? '?' + queryString.stringify(queryParams) : '') ) .then(function(result) { for (var _i = 0, queue_1 = queue; _i < queue_1.length; _i++) { var modelName = queue_1[_i]; if (modelName === _this.blockContentLoading) { continue; } var observer = _this.observersByModelType[modelName]; if (!observer) { return; } var data = result[modelName]; var sorted = sortBy(data, function(item) { return item.priority; }); var testModifiedResults = _this.processResultsForTests(sorted); observer.next(testModifiedResults); // observer.next(sorted); } }) .catch(function(err) { for (var _i = 0, queue_2 = queue; _i < queue_2.length; _i++) { var modelName = queue_2[_i]; var observer = _this.observersByModelType[modelName]; if (!observer) { return; } observer.error(err); } }); }; Builder.prototype.processResultsForTests = function(results) { var _this = this; var mappedResults = results.map(function(item) { if (!item.variations) { return item; } var cookieValue = _this.getTestCookie(item.id); var cookieVariation = cookieValue === item.id ? item : item.variations[cookieValue]; if (cookieVariation) { return __assign({}, item, { data: cookieVariation.data, variationId: cookieValue }); } if (item.variations) { var n = 0; var random = Math.random(); for (var id in item.variations) { var variation = item.variations[id]; var testRatio = variation.testRatio; n += testRatio; if (random < n) { _this.setTestCookie(item.id, variation.id); return __assign({}, item, { data: variation.data, variationId: variation.id }); } } } _this.setTestCookie(item.id, item.id); return item; }); if (isIframe) { window.top.postMessage( { type: 'builder.contentResults', data: { results: mappedResults } }, '*' ); } return mappedResults; }; Builder.prototype.getTestCookie = function(contentId) { return this.getCookie(this.testCookiePrefix + '.' + contentId); }; Builder.prototype.setTestCookie = function(contentId, variationId) { return this.setCookie(this.testCookiePrefix + '.' + contentId, variationId, { expires: 30, }); }; Builder.prototype.getCookie = function(name) { return Cookies.get(name); }; Builder.prototype.setCookie = function(name, value, options) { return Cookies.set(name, value, options); }; // TODO:˝ param overrides // TODO: gather user attributes // Forward query to a server call so params like no cache or overrides can be extraced and applied Builder.prototype.getContent = function(modelName, options) { var _this = this; if (options === void 0) { options = {}; } if (!this.apiKey) { throw new Error('Builder needs to be initialized with an API key!'); } var queryParams = (typeof options.queryString === 'string' ? queryString.parse(options.queryString) : typeof location !== 'undefined' ? queryString.parse(location.search) : undefined) || {}; // TODO: merge in the attribute from query string ones queryParams.userAttributes = stableStringify(this.getUserAttributes()); // TODO: cachebust if bd.noCache in request, also perhaps if in iframe if (options.cachebust || isIframe) { queryParams.cachebuster = Date.now().toString(); } var hasParams = Object.keys(queryParams).length > 0; // TODO: check for query params or global variable for overriding content (e.g. what to use for preview) // TODO: query params and sorting // TODO: with extension set up live connection to this query and send updates // TODO: return observable with subscribe function that returns unsubscriber? // TODO: (shorter than uuid) org namespace // /api/v1/everlane-1/homepage-2 // "space" names? everlane-qa everlane-prod etc // TODO: if in preview (maybe any iframe?) always no cache // HACK: have a global option for setting dev var host = options.dev || this.getLocation().host === 'localhost:4205' ? 'http://localhost:5000' : 'https://builder.io'; return new Observable(function(observer) { // TODO: will there by use cases of multiple separate requests for same model type? _this.observersByModelType[modelName] = observer; // FIXME: have a "core" SDK that doesn't implement http, // so SDKs like angular can use it's own http method var promise = _this .requestUrl( host + '/api/v1/content/' + _this.apiKey + '/' + modelName + (queryParams && hasParams ? '?' + queryString.stringify(queryParams) : '') ) .then(function(data) { return data[modelName]; }) .then(function(list) { return sortBy(list, function(item) { return item.priority; }); }) .then(function(result) { if (modelName === _this.blockContentLoading) { return; } var testModifiedResults = _this.processResultsForTests(result); observer.next(testModifiedResults); }) .catch(function(err) { observer.error(err); }); }); }; Builder.eventCapturer = isIframe ? new EventCapturer().listen() : null; Builder.components = []; Builder.incrementalDomLoaded = false; Builder._editingPage = false; Builder.isIframe = isIframe; Builder.isBrowser = isBrowser; Builder.overrideUserAttributes = {}; return Builder; })(); export { Builder };