Managing content for an art website

managing content as data

Content Management

Any product, experience, or artwork – anything you build – is made up of pieces. And content always sits at the center. Content is the fleshy part of media.

The other pieces include structure, style, and functionality.  These parts layout a skeleton, decorates the aesthetic, and adds usefulness. This  model translates well to modern web development. HTML defines the structure. CSS describes the style. JavaScript adds interactivity. But always, content is King.

That’s why a robust content management system (CMS) is critical. Most clients prefer to have one. It makes updates easy. WordPress is the modern choice. It’s what this blog is built on.

WordPress Website

A website I built featured the work of visual artist Ron Markman – paintings, etchings, photos. It had a lot of content. A lot of content that needed massaging. As you may have guessed, I chose WordPress to manage it. I choose the HTML5 Blank WordPress Theme as our starting point.

I was recommended to Ericka by a previous client. Her late relative left behind a corpus of work that needed a  new digital home. They already had a website, but needed it revamped and rebuilt from scratch.

ron markman old website

This was my proposal:

The look and feel will be modern, sleek, and adaptive. The homepage will feature a header section highlighting selected work. The website’s menu will link to the various category pages (as well as any ancillary pages). The menu, along with a footer, will persist throughout all pages to create a cohesive experience and brand identity. The website will be built responsively, adapting to all screen-sizes and devices. As discussed, select content will feature “zooming” functionality.”

art website

This was a situation where I had to be a project manager, and deliver results. Although the content itself was impressive, it was delivered as image files in various formats and different sizes. Filenames were not consistent. And the meta-data – descriptions, titles, notes – was listed is excel files that didn’t always match-up to the image’s filename. This required a lot of spot checking, and manual work. I did my best to automate as much as I could, and make things uniform.

Phases of Work

I broke the work out into four phases. This is how I described it to the client:

Layout and hierarchy

I will provide wire-frame layouts describing the essential structure, layout and hierarchy of the website overall.

Look and feel

I will finalize aesthetic details such as color palette, typography, user interface, and stylization.

Implementation

I will build and deploy the website with the content provided.

Content input

You’ll need to provide all copy, images, media, etc. before a first build of the website can be deployed. I’ll be implementing a standard content-management system that will allow you to add additional content, categories, pages, etc. Often times, content delivery can be a bottleneck for projects like this. After the finalized website is deployed live, with the initial content provided, you’ll be responsible for adding any more additionally.

Image Gallery

The UI to show each piece of art was powered by Galleria. It was out-of-the-box responsive. At first, each gallery page had so many large image files that things would crash or load very slowly. I was able to leverage the framework’s AJAX lazy loading to mitigate that issue.

Resizing Multiple Images

Resizing a batch of images can be done directly in Mac OS by selecting the files, and opening them in Preview. From the ‘Edit’ menu, I clicked ‘Select All’. Then, in the ‘Tool’ menu I found ‘Adjust Size’. Windows has a similar feature, as does other image manipulation apps.

Renaming Multiple Files

I had to make the filenames match what was listed in the meta-data spreadsheet. Here’s the command I used, in Mac OS, to truncate filenames to the first eight characters:

rename -n 's/(.{8}).*(\.jpg)$/$1$2/' *.jpg

Batch Uploading WordPress Posts

Each piece of art was a WordPress post, with a different title, meta-values, and image. Once all of the files were sized and named properly, I uploaded them to the server via sFTP. Each category of art (paintings, photos, etc.) was a folder. I created a temporary database table that matched the columns from the meta-data spreadsheet I was given.

CREATE TABLE `content` (
  `content_id` int,
  `title` varchar(250) NOT NULL,
  `medium` varchar(250) NOT NULL,
  `category_id` varchar(250) NOT NULL,
  `size` varchar(250) NOT NULL,
  `date` varchar(250) NOT NULL,
  `filename` varchar(100) NOT NULL,
  `processed` int
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
COMMIT;

I wrote a PHP script that would loop through all records, and create a new post for each. I had to make sure to include core WordPress functionality,  so that I would be able to use the wp_insert_post() method.

require_once('/var/www/html/wp-load.php');

Once I connected to the database, I queried my temporary table, excluding any records that have been marked as already uploaded:

$query = "SELECT * FROM `content` where `processed` != 1"; 
$result = mysqli_query($mysql_link, $query);

While looping through each record, I would look up the WordPress category ID and slug based on the provided category name. This would allow my code to assign the post to the correct category, and to know which folder the image file was in. Once the post is inserted, I take that post ID and assign meta-values. At the end of the loop, I mark this record as processed.

while ($row = mysqli_fetch_assoc($result)) {

    $category = $row['category'];
    $content_id = $row['content_id'];
    $term_id = "";
    $slug = "";
    $category_query = $mysqli->prepare("SELECT * FROM `wp_terms` where name = :name");
    $category_query->bind_param(array(':name' => $category));
    $category_result = $category_query->execute();
    if (mysqli_num_rows($category_result) > 0) {
        while($cat_row = mysqli_fetch_assoc($category_result)) {
           $term_id = $cat_row['term_id'];
           $slug = $cat_row['slug'];
        }
    }
    $post_id = wp_insert_post(array(
        'post_status' => 'publish',
        'post_title' => $row['title'],
        'post_content' => " ",
        'post_category' => $term_id
        
    ));
    
    if ($post_id) { 
        //meta-values
        add_post_meta($post_id, 'medium', $row['medium']);
        add_post_meta($post_id, 'size', $row['size']);
        add_post_meta($post_id, 'date', $row['date']);
        $img = $slug . $row['image'];
        add_post_meta($post_id, 'image_file', $img);
    }

    $update = $mysqli->prepare("UPDATE `content` SET processed = 1 where content_id = :content_id");
    $update->bind_param(array(':content_id' => $content_id));
    $update = $category_query->execute(); 
}

Managing clients, and their content, can be the most challenging  part of web development. Using the right software for the job makes it easier. So does having a toolbox of techniques, and being clever.

Managing server operations

This website was hosted on AWS. It used EC2. At first, the instance size we selected was too small and led to repeated website crashes. That experience led me to coming up with a hacky work-around for restarting the server when it crashed – read about it here.

Image carousel – update

Image carousel update

In a previous post, I wrote about creating an image carousel using basic web tech: HTML, CSS, and vanilla JavaScript. No frameworks, no jQuery. This is an update to that. The major difference is that it supports multiple carousels on the same page. I also added a try/catch, in case no carousel data is found in the database. I recently used this implementation on a WordPress site. Each carousel was a post (of a custom carousel post-type), that had each image attached. On that post-type archive page, I looped through the posts, and created a separate carousel for each.

Here is the updated JavaScript.

try{
    var galleries = document.getElementsByClassName("carousel-class");
    for(var i = 0; i < galleries.length; i++){
        showGalleries(galleries.item(i), 0);    
    }
}catch(e){
    console.log(e);
}

function showGalleries(gallery, galleryIndex){
    var galleryDots = gallery.getElementsByClassName("dot-button");
    var gallerySlides = gallery.getElementsByClassName("my-slide");
    if (galleryIndex < 0){galleryIndex = gallerySlides.length-1}
    galleryIndex++;
    for(var ii = 0; ii < gallerySlides.length; ii++){ gallerySlides[ii].style.display = "none"; galleryDots[ii].classList.remove('active-dot'); } if (galleryIndex > gallerySlides.length){galleryIndex = 1}
    gallerySlides[galleryIndex-1].style.display = "block";
    var resizeEvent = new Event('resize');
    window.dispatchEvent(resizeEvent);
    galleryDots[galleryIndex-1].classList.add('active-dot');
    //hide gallery navigation, if there is only 1
    if(gallerySlides.length < 2){
        var dotContainer = gallery.getElementsByClassName("dots");
        var arrowContainer = gallery.getElementsByClassName("gallery-arrows");
        dotContainer[0].style.display = "none";
        arrowContainer[0].style.display = "none";
    }
    gallery.setAttribute("data", galleryIndex);
}

//gallery dots
document.addEventListener('click', function (event) {
    if (!event.target.matches('.carousel-class .dot-button')){ return; }
    var index = event.target.getAttribute("data"); 
    var parentGallery = event.target.closest(".carousel-class")
    showGalleries(parentGallery, index);

}, false);

//gallery arrows

//left arrow
document.addEventListener('click', function (event) {
    if (!event.target.matches('.fa-arrow-left')){ return; }
    var parentGallery = event.target.closest(".carousel-class")
    var galleryIndex = parentGallery.getAttribute("data");
    galleryIndex = galleryIndex - 2;
    
    showGalleries(parentGallery, galleryIndex);
}, false);

//right arrow
document.addEventListener('click', function (event) {
    if (!event.target.matches('.fa-arrow-right')){ return; }
    var parentGallery = event.target.closest(".carousel-class")
    var galleryIndex = parentGallery.getAttribute("data");

    showGalleries(parentGallery, galleryIndex);
}, false);

You’ll notice that each carousel section has a data attribute assigned, so our JS knows which one to affect. This version also includes left and right navigation arrows, in addition to the navigation dots we already had.

HTML:

<div class="ap-carousel" data="0">

<?php $num_slides = 0; foreach($posts as $post){ $num_slides++; ?>

	<div class="ap-slide">
		<a href="<?php the_permalink($post->ID); ?>" title="<?php the_title(); ?>">
			<img src="<?php echo esc_url(get_the_post_thumbnail_url($post->ID)); ?>" class="zoom">
		</a>
	</div>

<?php } ?>
<div class="nav-dots">
	<?php $active = "active-dot"; for($x = 0; $x < $num_slides; $x++){ ?>
		<div class="dot"><button data="<?php echo $x; ?>" type="button" class="dot-button <?php echo $active; $active = ''; ?>">b</button></div>
	<?php } ?>
</div>
<div class="gallery-arrows">
    <i class="fas fa-arrow-left"></i>
    <i class="fas fa-arrow-right"></i>
</div>


</div>

I emphasize simplicity when building solutions. I avoid including superfluous code libraries when a vanilla technique works. It’s helpful to keep track of solutions I engineer, and try to reuse them where they fit. And when they need to be adjusted to work with a new problem, I enhance them while still trying to avoid complexity.

Easy image carousel

image software

On a recent project, I needed a simple image carousel on the homepage. And then, on the gallery page I needed a fully polished solution. Sometimes, using a framework is the right choice. Others, a fully built out toolkit can be overkill.

The Vanilla Option

First, here is the home-rolled version that I came up with. It was integrated into a custom WordPress template. I loop through a set of posts within my carousel wrapper, creating a slide div with that record’s featured image. I keep track of how many slides get built. Beneath the carousel wrapper I create a navigation div, and build a dot button for each slide. Each dot gets an index assigned to it, saved to its button’s data attribute.

HTML:

<div class="ap-carousel">

<?php $num_slides = 0; foreach($posts as $post){ $num_slides++; ?>

	<div class="ap-slide">
		<a href="<?php the_permalink($post->ID); ?>" title="<?php the_title(); ?>">
			<img src="<?php echo esc_url(get_the_post_thumbnail_url($post->ID)); ?>" class="zoom">
		</a>
	</div>

<?php } ?>
<div class="nav-dots">
	<?php $active = "active-dot"; for($x = 0; $x < $num_slides; $x++){ ?>
		<div class="dot"><button data="<?php echo $x; ?>" type="button" class="dot-button <?php echo $active; $active = ''; ?>">b</button></div>
	<?php } ?>
</div>


</div>

CSS:

I used CSS animation to create a fade effect between slides. I position the navigation dots using CSS flexbox layout.

.ap-carousel{
	position: relative;
}
.ap-slide{
	display: none;
	margin: 0 auto;
}	 
.ap-slide img{
	width: auto;
	display: block;
	margin: 0 auto;
	max-height: 90vh;
	-webkit-animation-name: fade;
	-webkit-animation-duration: 1.5s;
	animation-name: fade;
	animation-duration: 1.5s;
}
@-webkit-keyframes fade {
	from {opacity: .4} 
	to {opacity: 1}
}
@keyframes fade {
	from {opacity: .4} 
	to {opacity: 1}
}
.nav-dots{
	display: flex;
	justify-content: center;
}
.dot button{
	display: block;
	border-radius: 100%;
	width: 12px;
	height: 12px;
	margin-right: 10px;
	padding: 0;
	border: none;
	text-indent: -9999px;
	background: black;
	cursor: pointer;
}
.dot button.active-dot{
	background: red;
}

JavaScript:

Finally, I create a JS function to change the slide and active dot based on a timer. I attach an event listener to the dots that will change the active slide based on the saved index data.

var slideIndex = 0;
showSlides();

function showSlides() {
	var i;
	var slides = document.getElementsByClassName("ap-slide");
	var dots = document.getElementsByClassName("dot-button");
	for (i = 0; i < slides.length; i++) { slides[i].style.display = "none"; dots[i].classList.remove("active-dot"); } slideIndex++; if (slideIndex > slides.length) {slideIndex = 1} 
	slides[slideIndex-1].style.display = "block"; 
	dots[slideIndex-1].classList.add("active-dot")
	setTimeout(showSlides, 5000); // Change image every 5 seconds
}

document.addEventListener('click', function(event){
	if(!event.target.matches('.dot-button')) return;

	slideIndex = event.target.getAttribute("data");
	showSlides();
}, false);

That’s a simple and lite solution. It worked fine for the homepage of this recent project, but the main gallery page needed something more complex. I choose Galleria, a JavaScript framework.

The Framework Option

Carousel showcasing artwork
Carousel showcasing artwork

I implemented this option onto the WordPress category archive page. For this project, each piece of artwork is its own post. In my category template file I loop through posts, and populate a JSON object with the data about each slide. Initially, I had built HTML elements for each slide, but that caused slow page load times. The JSON data option is significantly faster. Here’s what my code setup looked like:

<div id="galleria"></div>
<script type="text/javascript">
	window.galleryData = [];
</script>
<?php if (have_posts()): while (have_posts()) : the_post(); 

$featured_img_url = get_the_post_thumbnail_url(); 

?>

<script>
window.galleryData.push({ image: "<?php echo esc_url($featured_img_url); ?>", artinfo: "<div class='galleria-img-info'><h3 class='title'><a href='<?php the_permalink(); ?>'><?php the_title(); ?></a></h3><?php $size=get_post_meta(get_the_ID(), 'size', true);$size=addslashes($size);$date=get_post_meta(get_the_ID(), 'date', true);$materials=get_post_meta(get_the_ID(), 'materials', true);if(! empty ( $size ) ){echo '<p><strong>Dimensions:</strong> ' . $size . '</p>';}if(! empty ( $date ) ){echo '<p><strong>Date:</strong> ' . $date . '</p>';}if(! empty ( $materials ) ){echo '<p><strong>Materials:</strong> ' . $materials . '</p>';} ?><p class='you-can-mouse'>You can click the image to enlarge it. </p></div></div>" })
</script>

<?php } ?>

<script src="/galleria/galleria-1.5.7.js"></script>
<script type="text/javascript">
// Load the classic theme
Galleria.loadTheme('/galleria/galleria.classic.min.js');
//https://docs.galleria.io/collection/25-options
Galleria.configure({
    imageCrop: false,
    transitionSpeed:1000,
    maxScaleRatio:1,
    swipe:true,
    thumbnails: 'none',
    transition: 'fade',
    lightbox: true
});
// Initialize Galleria
Galleria.run('#galleria', {dataSource: window.galleryData, autoplay: 5000, extend: function() {
            // var gallery = this; // "this" is the gallery instance
            // gallery.play(); // call the play method
        }
});

Galleria.ready(function() {
        	
	$(".loading").hide();
		this.bind('image', function(e) {
	});

});
 </script>

Easy hamburger (menu) recipe

I think it’s best to avoid using plug-ins when possible. It reduces bloat and “black-box” code.

The mobile “hamburger” menu is a staple of responsive user interface design. Users know that clicking on that three-lined icon will show a menu. It’s a modern solution to displaying long navigation lists on smaller screens.

A ‘hamburger’ menu is a button (usually in the corner of a screen) that toggles a menu or list of hyperlinks.

Below is a simple rendition using basic web technology. I used this recently as part of  a website that showcases the work of a graphic artist.

mobile menu example

HTML:

Drop this code in your header file for the menu (list of links) itself.

<div class="mobile-menu">
	<span class="close-mobile-menu"><i class="far fa-times-circle"></i></span>

	<ul>
			 
			<li><a href="/biography">Biography</a></li>
			<li><a href="/education">Education & Awards</a></li>
			<li><a href="/reviews?order=asc">Reviews</a></li>
			<li><a href="/etchings">Etchings</a></li>
			<li><a href="/category/paintings/1960s/">Paintings</a></li>
			<li><a href="/mukfa-about">Mukfa</a></li>
			<li><a href="/category/drawings/human-comedy/">Drawings</a></li>
			<li><a href="/exhibitions-and-collections">Exhibitions & Collections</a></li>
			 
	</ul>

</div>

Next, add this to your existing navigation, or wherever you’d like the hamburger button to show.

<div class="mobile-hamburger mobile-only"><i class="fas fa-bars"></i></div>

I used FontAwesome to generate the hamburger icon itself (and the close icon). Alternatively, you can use an image file.

hamburger menu

CSS:

This code sets the hamburger button to only show on mobile devices. Mobile devices are specified at 787px or less by a media query.

.mobile-hamburger{
	font-size: 36px;
	color: #005FAA;
	float: right;
	cursor: pointer;
	margin-right: 16px;
	margin-top: 5px;

}
.mobile-menu{
	display: none;
	width: 100%;
	background: #DCC7AA;
	position: fixed;
	height: 100%;
	right: 0;
	top: 0;
	z-index: 20;
}
.mobile-menu ul{
	list-style-type: none;
	font-size: 16px;
	text-align: left;
	padding: 25px;
	margin: 50px 0px;
}

.mobile-menu ul li{
	margin-top: 15px;
}

.close-mobile-menu{
	position: absolute;
	top: 5px;
	right: 16px;
	font-size: 36px;
	cursor: pointer;
}

@media only screen and (min-width:787px) {
	.mobile-only{display: none;}
}

JavaScript:

With jQuery:

(function ($, root, undefined) {
	
	$(function () {
		
		'use strict';
		
		// DOM ready, take it away
		$(".mobile-hamburger").click(function(){
			$(".mobile-menu").show();
		});

		$(".close-mobile-menu").click(function(){
			$(".mobile-menu").hide();
		});

		 
		
	});
	
})(jQuery, this);

Or, plain vanilla JS:

document.addEventListener('click', function (event) {

	if (!event.target.matches('.mobile-hamburger')){
		return;
	}

	document.getElementsByClassName('mobile-menu')[0].style.display = 'block';

}, false);

document.addEventListener('click', function (event) {

	if (!event.target.matches('.close-mobile-menu')){
		return;
	}

	document.getElementsByClassName('mobile-menu')[0].style.display = 'none';	

}, false);

 


By the way, this is the tool I’ve been using to encode the HTML I paste into my WordPress posts (that way, it doesn’t actually render on page): https://github.com/mathiasbynens/mothereff.in/tree/master/html-entities

html entities encoded and decoded

Develop Apps and Explore the World Wide Web

BJJ Tracker

World Wide Web

The web, as a platform, is open and free. Unlike native app markets, we don’t have to wait for software to be approved by any third-party. It works across any device or operating system that has a web browser. (Which is why standards across browsers is so important). But, until recently web-apps faced limitations. Not having full access to a device’s hardware and operating system was an issue – but that’s being fixed as more native APIs are being added to modern web browsers.

A disadvantage of having a web-only app was losing out on the discoverability that comes with having it listed in a searchable marketplace. Adding a web-app to your device home screen, from a web browser, is not intuitive to average users. Fortunately, the Google Play Market allows us to upload an app file that links to a progressive web app.

This involves a new protocol, Trusted Web Activities, as “a way to integrate your web-app content such as your PWA with your Android app“. The PWA leverages Digital Asset Links to “declare that it is associated with a specific Android app.

Progressive web apps

I decided to try this out with one of my web-apps, BJJ Tracker. You can read about how I first built it on another blog post.

I had to make sure it qualified as a PWA. It needed offline support, as well as any other features that would make it feel like a native app. Google Chrome’s developer tools has a section called “Audits” that helped me identify such opportunities.

progressive web app audit

The first step was to create a “service worker” JavaScript file, and register it when BJJ Tracker loads.

if('serviceWorker' in navigator) {
  navigator.serviceWorker
           .register('/serviceWorker.js')
           .then(function() { console.log("Service Worker Registered"); })
           .catch(error => {
	        	console.log(error.message)
	    	})
}

I added the above code to a shared file that loads on every page of my app.  Below is an example service worker file. This file downloads any vital assets to a user’s device, and later loads them from the cache. Including a polyfill ensures that the cache methods exist (in case the browser does not support them natively). “We need to use the polyfill because the Cache API is not yet fully supported in all browsers.

importScripts('/cache-polyfill.js');

self.addEventListener('install', function(e) {
 e.waitUntil(
   caches.open('bjjtracker').then(function(cache) {
    return cache.addAll([
       '/',
       '/index',
       '/index?login',
       '/create-record?class',
       '/create-record',
       '/create-record?competition',
       '/view-record',
       '/view-month',
       '/privacy-policy',
       '/contact',
       '/view-more-data',
       '/account',
       '/css/bootstrap.min.css',
       '/css/bootstrap.min.css',
       '/css/bootstrap-theme.min.css',
       '/css/main.css',
       '/simpleMobileMenu/styles/jquery-simple-mobilemenu.css',
       'https://use.fontawesome.com/releases/v5.3.1/css/all.css',
       'https://fonts.googleapis.com/css?family=Roboto|Eczar&display=swap',
       'https://ajax.googleapis.com/ajax/libs/jquery/1.11.2/jquery.min.js',
     ]);
    }).catch(error => {
        console.log(error.message)
    })
 );
});

self.addEventListener('fetch', function(event) {
	event.respondWith(
		caches.match(event.request).then(function(response) {
			return response || fetch(event.request);
		}).catch(error => {
	        console.log(error.message)
	    })
	);
});

Read the documentation on Google’s developer portal.

Next, I created a “manifest” file. This file is written in JSON format. It helps describe how the web-app behaves once “installed”. It handles things such as app icon images and meta data.

{
  "name": "BJJ Tracker",
  "lang": "en-US",
  "short_name": "BJJ Tracker",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#2a4d69",
  "theme_color": "#2a4d69",
  "description": "Track Brazilian Jiu Jitsu progress and fitness goals.",
  "icons": [{
    "src": "img/homescreen48.png",
    "sizes": "48x48",
    "type": "image/png"
  }, {
    "src": "img/homescreen72.png",
    "sizes": "72x72",
    "type": "image/png"
  }, {
    "src": "img/homescreen96.png",
    "sizes": "96x96",
    "type": "image/png"
  }, {
    "src": "img/homescreen144.png",
    "sizes": "144x144",
    "type": "image/png"
  }, {
    "src": "img/homescreen168.png",
    "sizes": "168x168",
    "type": "image/png"
  }, {
    "src": "img/homescreen192.png",
    "sizes": "192x192",
    "type": "image/png"
  }, {
    "src": "img/homescreen512.png",
    "sizes": "512x512",
    "type": "image/png"
  }]
}

I created the image assets using open source software.

Image assets created with GIMP
Image assets created with GIMP

The manifest needs to be referenced by the app. I added a link tag to a shared <head> file. Additionally, I included a few other meta tags that let browsers know to treat this website as an app.

<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#005b96"/>
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="msapplication-starturl" content="/">

Android Studio

A signed app bundle is generated from Android Studio.  I use a sample project from Google Chrome Labs as a template. We can clone that repository, and update the “/svgomg-twa/app/build.gradle” settings to point to our PWA.

app gradle settings
TWA to wrap SVGOMG in an Android App

The app’s icon files can be generated using an online tool. The downloadable bundle can be dropped into “/svgomg-twa/app/src/main/res/“.

icon generator
https://romannurik.github.io/AndroidAssetStudio/icons-launcher.html

When creating the app bundle (“Build > Generate Signed Bundle/APK”) we’ll need a signing key. I created a new one, and named the file mykeystore.keystore.

key signing on mac

An “assetlinks.json” file needs to be uploaded to the web app’s host to satisfy the Digital Asset Links requirement.  “The Digital Asset Links protocol and API enable an app or website to make public, verifiable statements about other apps or websites.” This confirms ownership of the PWA so that it can be linked to our app in the Play Store. To generate this file, first we’ll need to get the fingerprint from the signing key we used:

keytool -list -v -keystore mykeystore.keystore -alias mykeystore -storepass password-here  -keypass password-here

That command shows us the certificate fingerprints. Copy the SHA256 value. It is used with Google’s Statement List Generator to create the contents of the assetlinks.json file. The statement file is then placed in a “.well-known” directory on the root of our PWA domain (eg. https://www.bjjtracker.com/.well-known/assetlinks.json)

Finally, I visited the Google Play Console. Besides uploading the .apk file, I also needed to include screenshots, featured image files, and complete a content rating survey – amongst other things. Since my app has been approved, you can now find it in the Google Play Market.

BJJ Tracker in the Google Play Store.

This app is a side project I use to toy with new web technologies. I’m trying to drive traffic to it so that I can experiment with optimizing conversions. I’m using it as a trial grounds for another software service called SplitWit. SplitWit is focused on optimizing conversions for the web, and helping digital marketers reach their goals. You can read about it on another post from this blog.

bjj tracker app

Building a SAAS for A/B testing

splitwit app

Product development and SAAS

SplitWit is a digital product. It is a “software as a service” platform that helps split test websites and apps. That means it allows us to make changes to a website, that only half of visitors will see, and then determine which version has better results (sales, sign-ups, etc.).

Foundational code and design

I used a template to quickly get things prototyped and working. It came with a user account engine to handle registration, login, and more.

The front-end design utilizes basic principles that focus on user experience. I iterated through various color pallets, and ended with a blue-shaded scheme. Subtle textured patterns applied to background sections help add a finished look. And of course, FontAwesome is my go-to icon set.

SplitWit.com
https://www.SplitWit.com

I used a CSS rule on the main container of each page to have a minimum height of 100% of the viewport. This ensures that the page footer doesn’t end up in the middle of the screen if there is not enough content.

.main-content.container{
  min-height: 100vh;
}

The contact form at the bottom of the homepage is powered by AWS SES.

Visual optimizer and editor

After setting up an account, users can create experiments that target certain pages of a website. The visual optimizer lets changes be made easily between the control and variation versions.

visual editor

The editor loads up a website as an iFrame on the right side of the page. Once a page is loaded, SplitWit adds an overlay to the iFrame. This way, instead of interacting with the page, clicks can be intercepted. Any elements that get clicked are loaded up as HTML into the “make a change” section of the editor. Any changes made are saved to that variation, and will be displayed to half of visitors.

Here is an example of the code that powers the overlay and connects it to the editor:

pageIframe.contents().find("body").prepend(overlay);
 
pageIframe.contents().find("body *").css("z-index", 1).mouseenter(function(){
  $(this).addClass('highlighted'); 
  testSelectorEl = $(this);
  
}).mouseout(function(){

  $(this).removeClass('highlighted');   

}).click(function(e){

  e.preventDefault();
  var value = testSelectorEl.getPath()
  selectNewElement(value);
  //scroll user to selector input
  $([document.documentElement, document.body]).animate({
    scrollTop: $(".page-editor-info").offset().top
  }, 1000);

});

function selectNewElement(value){
  
    testSelectorElPath = value;
    testSelectorEl = pageIframe.contents().find(value);
    $(".change-indicator").hide()
    $(".el-input").removeAttr("disabled");
    $(".element-change-save-btn").attr("disabled", "disabled");
    $(".find-selector").hide();
    $(".element-change-wrap .selector-input").val(testSelectorElPath);

    $(".toggable-section").hide();
    $(".element-change-wrap").show();
    $(".multiple-elements").hide();

    if(testSelectorEl.attr("src") && testSelectorEl.attr("src").length > 0){
      $(".img-url").val(testSelectorEl.attr("src"));
      $(".img-url-wrap").show();
      testSelectorElImage = testSelectorEl.attr("src");
    }else{
      testSelectorElImage = "";
      $(".img-url").val("");
      $(".img-url-wrap").hide();
    }
    if(testSelectorEl.attr("href") && testSelectorEl.attr("href").length > 0){
      $(".link-url").val(testSelectorEl.attr("href"));
      $(".link-url-wrap").show();
      testSelectorElLink = testSelectorEl.attr("href");
    }else{
      testSelectorElLink = "";
      $(".link-url").val("");
      $(".link-url-wrap").hide();
    }

    if(testSelectorEl.html() && testSelectorEl.html().length > 0){
      $(".html-input").val(testSelectorEl.html());
      $(".html-input-wrap").show();
      testSelectorElHtml = testSelectorEl.html();
    }else{
      testSelectorElHtml = "";
      $(".html-input").val("");
      $(".html-input-wrap").hide();
    }

    $(".elem-css-group").show();
    if(testSelectorEl.is(":visible")){
      originalVisibilityState = "visible";
      $("#visible-radio").attr("checked", "checked");
      $("#hidden-radio").removeAttr("checked");
    }else{
      originalVisibilityState = "hidden";
      $("#hidden-radio").attr("checked", "checked");
      $("#visible-radio").removeAttr("checked");

    }
    originalValues['height'] = testSelectorEl.css("height");
    $(".height").val(originalValues['height']);
    originalValues['width'] = testSelectorEl.css("width");
    $(".width").val(originalValues['width']);
    originalValues['border'] = testSelectorEl.css("border");
    $(".border").val(originalValues['border']);
    originalValues['font-family'] = testSelectorEl.css("font-family");
    $(".font-family").val(originalValues['font-family']);
    originalValues['font-size'] = testSelectorEl.css("font-size");
    $(".font-size").val(originalValues['font-size']);
    originalValues['font-weight'] = testSelectorEl.css("font-weight");
    $(".font-weight").val(originalValues['font-weight']);
    originalValues['font-style']= testSelectorEl.css("font-style");
    $(".font-style").val(originalValues['font-style'])
    originalValues['text-decoration'] = testSelectorEl.css("text-decoration")
    $(".text-decoration").val(originalValues['text-decoration'])
    originalValues['background'] = "";
    $(".background").val(originalValues['background'])

} //end selectNewElement()

The editor has lots of built in options, so users can change the style and behavior of a page without needing to know how to code. A marketer can use this tool without the help of a developer.

Metrics and statistical significance

A key feature of SplitWit is to measure conversion metrics and performance indicators. The platform determines which variation is a winner based on the metrics set. Three types of metrics are offered: page views, click events, and custom API calls.

bounce rate metric

Algorithms calculate statistical significance based on the number of visitors an experiment receives and the conversion metrics configured. This makes sure that the result is very unlikely to have occurred coincidently.

The code snippet

Each project setup in SplitWit generates a code snippet. Once this snippet is added to a website, SplitWit is able to do its magic. Using JavaScript, it applies variation changes, splits user traffic between versions, and measures key metrics about the experiments running.

The platform uses a relational database structure. As changes are made to experiments, the details are saved and written to a unique snippet file. When the snippet file loads, the first thing is does is check to see if there are any experiments that should be running on the current page. Each experiment can be configured to run on various URLs. The configuration rules contain three parts: a URL pattern, a type (target or exclude), and a match type (exact, basic, or substring). You can read SplitWit documentation to find an explanation of these match types.

experiment settings

Here is the code used to test a URL against an experiment’s configuration rules:

function testUrl(testurl, conditions){
			
	if(testurl.search(/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]/) < 0){
		return window.inputError($(".test-url-input"), "Please test a valid URL.");
	}
	var valid = false;
	var arr  = [],
	keys = Object.keys(conditions);

	for(var i=0,n=keys.length;i<n;i++){
		var key  = keys[i];
		arr[i] = conditions[key];
	}

	conditions = arr;
	for (i = 0; i < arr.length; i++) { 
		var url = conditions[i].url;
		var matchtype = conditions[i].matchtype;
		var conditiontype = conditions[i].conditiontype;

		if(matchtype == "exact" && conditiontype == "target" && url == testurl){
			valid = true;
		}
		if(matchtype == "exact" && conditiontype == "exclude" && url == testurl){
			valid = false;
		}

		if(matchtype == "basic"){
			var cleanTestUrl = testurl.toLowerCase();
			var cleanUrl = url.toLowerCase();

			if(cleanTestUrl.indexOf("?") > 0) {
				cleanTestUrl = cleanTestUrl.substring(0, cleanTestUrl.indexOf("?"));
			}
			if(cleanUrl.indexOf("?") > 0) {
				cleanUrl = cleanUrl.substring(0, cleanUrl.indexOf("?"));
			}
			if(cleanTestUrl.indexOf("&") > 0) {
				cleanTestUrl = cleanTestUrl.substring(0, cleanTestUrl.indexOf("&"));
			}
			if(cleanUrl.indexOf("&") > 0) {
				cleanUrl = cleanUrl.substring(0, cleanUrl.indexOf("&"));
			}
			if(cleanTestUrl.indexOf("#") > 0) {
				cleanTestUrl = cleanTestUrl.substring(0, cleanTestUrl.indexOf("#"));
			}
			if(cleanUrl.indexOf("#") > 0) {
				cleanUrl = cleanUrl.substring(0, cleanUrl.indexOf("#"));
			}
			cleanTestUrl = cleanTestUrl.replace(/^(?:https?:\/\/)?(?:www\.)?/i, "");
			cleanUrl = cleanUrl.replace(/^(?:https?:\/\/)?(?:www\.)?/i, "");
			cleanTestUrl = cleanTestUrl.replace(/\/$/, "");
			cleanUrl = cleanUrl.replace(/\/$/, ""); 

			if(conditiontype == "target" && cleanUrl == cleanTestUrl){
				valid = true;
			}
			if(conditiontype == "exclude" && cleanUrl == cleanTestUrl){
				valid = false;
			}

		}
		if(matchtype == "substring"){
			if(testurl.includes(url) && conditiontype == "target"){
				valid = true;
			}
			if(testurl.includes(url) && conditiontype == "exclude"){
				valid = false;
			}
		} 
	}
	
	return valid;

}

Subscription billing workflow

Stripe is used to bill customers. In the billing dashboard we can create a product, and assign it a monthly pricing plan.

Subscription products

The payment processor handles re-billing customers each month. Our software is responsible for keeping track of each account’s payment status. In the database we record the date of when an account will be considered delinquent. Upon registration each account has this field set to 15 days in the future, affording a two week trial. At this point, users have not entered any credit card information.

Initial payment

Stripe’s JavaScript SDK is used during initial payment to tokenize credit card information before passing it along to the server.

activate your subscription
Stripe’s JS library handles card validation and tokenization.

Below is the HTML used for a Stripe payment element:

<div id="stripe-payment-modal" class="modal stripe-payment-modal" style="display: none;">

	<!-- Modal content -->
	<div class="modal-content">
		<p>
		  <button type="button" class="dismiss-modal close" >&times;</button>
		</p>
		<p>Activate your account subscription.</p>
		<form id="payment-form">
		  <div class="form-row">
		    <!-- <label for="card-element">
		      Credit or debit card
		    </label> -->
		    <div id="card-element">
		      <!-- A Stripe Element will be inserted here. -->
		    </div>

		    <!-- Used to display Element errors. -->
		    <div id="card-errors" role="alert"></div>
		  </div>

		  <button type="button" class="btn submit-payment">Submit Payment</button>
		</form>

  	</div>

</div>

And the JavaScript:

<script src="https://js.stripe.com/v3/"></script>
<script type="text/javascript">
var stripe = Stripe('your-public-key-goes-here');

var elements = stripe.elements();

// Custom styling can be passed to options when creating an Element.
var style = {
  base: {
    color: '#32325d',
    fontFamily: '"Helvetica Neue", Helvetica, sans-serif',
    fontSmoothing: 'antialiased',
    fontSize: '16px',
    '::placeholder': {
      color: '#aab7c4'
    }
  },
  invalid: {
    color: '#fa755a',
    iconColor: '#fa755a'
  }
};

// Create an instance of the card Element.
var card = elements.create('card', {style: style});

// Add an instance of the card Element into the `card-element` div.
card.mount('#card-element');

// Handle real-time validation errors from the card Element.
card.addEventListener('change', function(event) {
  var displayError = document.getElementById('card-errors');
  if (event.error) {
    displayError.textContent = event.error.message;
  } else {
    displayError.textContent = '';
  }
});

// Handle form submission.
var form = document.getElementById('payment-form');
form.addEventListener('submit', function(event) {
  event.preventDefault();

  stripe.createToken(card).then(function(result) {
    if (result.error) {
      // Inform the user if there was an error.
      var errorElement = document.getElementById('card-errors');
      errorElement.textContent = result.error.message;
    } else {
      // Send the token to your server.
      stripeTokenHandler(result.token);
    }
  });
});

// Submit the form with the token ID.
function stripeTokenHandler(token) {
  // Insert the token ID into the form so it gets submitted to the server
  var form = document.getElementById('payment-form');
  var hiddenInput = document.createElement('input');
  hiddenInput.setAttribute('type', 'hidden');
  hiddenInput.setAttribute('name', 'stripeToken');
  hiddenInput.setAttribute('value', token.id);
  form.appendChild(hiddenInput);
  
  var data = $("#payment-form").serialize();
  $.ajax({
  	url:"stripe-payment-service.php",
  	method: "POST",
  	data: data,
  	complete: function(response){
  		console.log(response);
  		window.location.reload();
  	}
  })
}

$(".submit-payment").click(function(){
	stripe.createToken(card).then(function(result) {
    if (result.error) {
    	// Inform the customer that there was an error.
    	var errorElement = document.getElementById('card-errors');
    	errorElement.textContent = result.error.message;
    } else {
	$(".submit-payment").attr("disabled", "disabled").html('Working...');
      	// Send the token to your server.
      	stripeTokenHandler(result.token);
    }
  });
});
</script>

The above code creates a new Stripe object using a public API key. That object injects a credit card form into our ‘#card-element’ div, with custom styles attached. It listens for any changes, and displays validation errors. When the form is submitted, the Stripe object creates a token from the payment information. That token is passed to our back-end. Stripe’s PHP library is used to finish the transaction:

<?php
function subscribe(){
        require_once('stripe-php-6.43.0/init.php');
        \Stripe\Stripe::setApiKey('sk_XXXX');
	$stripe_token = $_POST['stripeToken'];
	$conn = $this->connection;
	
	if(isset($_SESSION['email'])){
		$email = $_SESSION['email'];
	}else{
		die("No email found.");
	}
	
	if(strlen($email)>0){
		$sql = "SELECT * FROM `account` WHERE email = ?"; 
		$result = $conn->prepare($sql); 
		$result->execute(array($email));
		$row = $result->fetch(PDO::FETCH_ASSOC);
	}
	$customer_id = $row['stripe_customer_id'];
	//check if this account already has a stripe_customer_id
	if(strlen($customer_id) < 1){
		//if not, create the customer
		$customer = \Stripe\Customer::create([
		  'email' => $email,
		  'source' => $stripe_token,
		]);
		$customer_id = $customer['id'];
		//write stripe ID to db
		$sql = "UPDATE `account_table` SET stripe_customer_id = ? WHERE email = ?"; 
		$result = $conn->prepare($sql); 
		$result->execute(array($customer_id, $email));
	}

	// Create the subscription
	$subscription = \Stripe\Subscription::create([
	  'customer' => $customer_id,
	  'items' => [
	    [
	      'plan' => 'plan_XXX', //setup in Stripe dashboard.
	    ],
	  ],
	  'expand' => ['latest_invoice.payment_intent'],
	  'billing_cycle_anchor' => time()
	]);
	$subscription_status = $subscription['status'];
	$subscription_id = $subscription['id'];
	if($subscription_status == "active"){
		//set current_period_end to 32 days (1 month plus some leeway) in the future. set past_due as false 
		$sql = "UPDATE `account_table` SET stripe_subscription_id = ?, current_period_end = ?, past_due = 0 WHERE email = ?"; 
		$result = $conn->prepare($sql);
		$past_due = false;
		$current_period_end = new DateTime;  
		$current_period_end->modify( '+32 day' );
		$current_period_end = $current_period_end->format('Y-m-d H:i:s'); 
		$result->execute(array($subscription_id, $current_period_end, $email));
	}

}
?>

On the server side our secret API key is used. A customer record is created in Stripe using the payment token and user’s email. The Stripe customer ID is then used to create a subscription. We record the the customer ID and subscription ID to our database. The account’s new subscription period end is updated to 32 days in the future.

Cancel a subscription

The user is able to cancel their subscription from the SplitWit account dashboard.

cancel subscription

We retrieve their subscription from Stripe, and cancel it, using their subscription ID. They will no longer be billed. We update our database to turn off the account’s experiments, delete any Stripe details, mark their subscription as delinquent, and re-write their snippet file.

<?php

function cancelSubscription(){
	require_once('stripe-php-6.43.0/init.php');
        \Stripe\Stripe::setApiKey('sk_XXXX');

	$conn = $this->connection;
	if(isset($_SESSION['userid'])){
		$accountid = $_SESSION['userid'];
	}else{
		die("No userid found.");
	}
	
	if(strlen($accountid)>0){
		$sql = "SELECT * FROM `account` WHERE accountid = ?"; 
		$result = $conn->prepare($sql); 
		$result->execute(array($accountid));
		$row = $result->fetch(PDO::FETCH_ASSOC);
	}
	$stripe_subscription_id = $row['stripe_subscription_id'];
	$subscription = \Stripe\Subscription::retrieve($stripe_subscription_id);
	$subscription->cancel();
	
	//turn off experiments and update snippets. clear stripe IDs. set current_period_end to yesterday. set past_due = 1
	$current_period_end   = new DateTime;  
	$current_period_end->modify( '-1 day' );
	$current_period_end = $current_period_end->format('Y-m-d H:i:s'); 
	$sql = "UPDATE `account` SET stripe_customer_id = '', stripe_subscription_id = '', past_due = 1, current_period_end = ? WHERE accountid = ?"; 
	$result = $conn->prepare($sql); 
	$result->execute(array($current_period_end, $accountid));

	//turn off all experiments
	$status = "Not running";
	$sql = "UPDATE `experiment` set status = ? where accountid = ?";
	$result2 = $conn->prepare($sql); 
	$result2->execute(array($status, $accountid));

	//update all snippets for this account (1 snippet per project)
	$sql = "SELECT * FROM `project` WHERE accountid = ?";
	$result3 = $conn->prepare($sql); 
	$result3->execute(array($accountid));
	$rows3 = $result3->fetchAll(PDO::FETCH_ASSOC);
	foreach ($rows3 as $key3 => $value3) {
		$projectid = $value3['projectid'];
    	        $databaseProjectService = new DatabaseProjectService();
		$databaseProjectService -> writeSnippetFile(false, false, $projectid);
	}

	$this->status = "complete";
}

?>

Re-billing subscriptions

As long as an account has an active subscription in Stripe, they will be automatically re-billed each month. When this event takes place, Stripe can deliver data about it to an end-point of our choice (commonly known as a webhook).

stripe webhooks

SplitWit listens for an event called “invoice.payment_succeeded”, which occurs when a customer’s monthly payment is successful. When that happens the account’s subscription period end is updated to 32 days in the future.

<?php
function webhookPaymentSuccess(){
	require_once('stripe-php-6.43.0/init.php');
	\Stripe\Stripe::setApiKey('sk_XXX');
	$payload = @file_get_contents("php://input");
	
	$endpoint_secret = "whsec_XXX";

	$sig_header = $_SERVER["HTTP_STRIPE_SIGNATURE"];
	$event = null;

	try {
	  $event = \Stripe\Webhook::constructEvent(
	    $payload, $sig_header, $endpoint_secret
	  );
	} catch(\UnexpectedValueException $e) {
	  // Invalid payload
	  http_response_code(400); // PHP 5.4 or greater
	  exit();
	} catch(\Stripe\Error\SignatureVerification $e) {
	  // Invalid signature
	  http_response_code(400); // PHP 5.4 or greater
	  exit();
	}
	
	if($event->type == 'invoice.payment_succeeded'){

		$invoice = $event->data->object;
		$customer_id = $invoice['customer'];
		//update their accocunt current_period_end
		$conn = $this->connection;
		$sql = "UPDATE `account` SET  current_period_end = ?, past_due = 0 WHERE stripe_customer_id = ?"; 
		$result = $conn->prepare($sql);
		$past_due = false;
		$current_period_end = new DateTime;  
		$current_period_end->modify( '+32 day' );
		$current_period_end = $current_period_end->format('Y-m-d H:i:s'); 
		$result->execute(array($current_period_end, $customer_id));
	}else{
		http_response_code(400);
	        exit();
	}
	
	http_response_code(200);
}

?>

What if payment fails or never happens? The account’s subscription period end never gets updated.

A daily scheduled task checks each active account’s subscription period end date. If that date is in the past, we mark the account as past due, turn off all experiments, and update its snippet files.

The value of experimentation

Driving digital conversions is a science. Experimentation should be a constant exercise in this respect. Take any field and we can benefit from testing the waters and adjusting our sail. Our ability to interpret that data is the bottle neck to making good decisions. The best lesson I’ve learned is that intuition is usually not enough. It’s better to look at the numbers and trust data.

Influencing users through a funnel of action, finally leading to a conversion, is a challenge. Optimizing conversions, sales, and leads can be broken down into a system based approach.  SplitWit focuses on that point.

www.SplitWit.com

My experience building digital products

digital product

When writing about digital problem solving, I tell stories about past projects. On top of a tech perspective, I also dig into the business, design, marketing, and inter-personal aspects. I’ve been fortunate enough to have a wide breadth of tech experience through my career. It has helped me dive deep into principles and ideas about building digital products. This range of experience was afforded by continually pursuing new work. Finding room for side projects and extra gigs is a great way to grow.

After a daily, hour-long commute I could barely sneak an hour or two for my creative projects and side gigs. But I always did. Side projects were often for paying clients, but sometimes just for fun. They would include not just programming, but also design, marketing, networking, and infrastructure. On top of this, I always made sure my hobbies would serve my overall goals. Reading good books, playing quality games, and being physically competitive all lead to a better life and career.

The key take-away is to work on a variety of projects. Be ready to try different technologies and new platforms. In general, keep trying new things.

Understand infrastructure. Survive some horror stories.

Web infrastructure and hosting setup are skills often missed out on by both casual programmers and professionals. Configuring domain names, email, web hosting, and load balancers is usually reserved for system administrators. Working as one-stop-shop, on your own or in a company, can give you the opportunity to manage all of these details.

I’ve gotten to work with many third-party services and vendors. I’ve seen the good and the bad, and even worse. AWS (Amazon Web Services) has been the best infrastructure provider that I have used. I’ve had horrible experiences from other companies.

Once having had my web servers infected by ransom-ware, HostGator wanted to charge nearly a thousand dollars, to solve the problem. This was the only solution they offered while multiple web properties were infected and out of commission. I fixed the issue myself in less than a few hours by purging all data from the servers and redeploying source code from version control. That was a nightmare.

Another time servers provided by OLM went down for multiple days. This was in 2014. During this time, they wouldn’t answer telephones, letting them ring. I stood on hold for at least 30 minutes, multiple times per day trying to get through. After nearly a week, things started working again, with no explanation provided. That was one of the most unprofessional experiences of my career. I will forever shout them out about this.

Get your hands dirty

Looking forward, I’m excited to explore more of AWS. I’m currently learning through online courses from Udemy: “Certified Solutions Architect” and “Certified Developer”, and plan to take the certification tests. Next, I want to jump into their “Machine Learning” and “Internet of Things” services.

I regularly use AWS services for cloud computing, storage, and databases. My go-to for a new project is to spin up a EC2 instance. If I know I’m using WordPress, I may use a Bitnami AMI. Other times, I’ll create a basic Linux box, and setup Apache, MySql, and PHP myself. Here is the documentation I regularly reference: Install a LAMP Web Server on Amazon Linux 2. This process usually includes configuring a domain name, and setting up SSL: Configure SSL/TLS on Amazon Linux 2.

I’ll continue this post as a series. I plan tell stories about my experiences in building digital products. I’ll cover topics such as design, marketing software, customization, and APIs. Follow me on Instagram for regular updates: @AntPace87

Remove subdirectories from a URL string

javascript

I use GitHub to manage code that I’ll want to re-use. I had trouble finding a canned function to remove the subdirectory path from a URL string – so I wrote one and added it to my latest public repository: https://github.com/pacea87/ap-utils

I’ll keep adding useful code to it – and feel free to make a pull request and contribute yourself. This code should focus on utility functions for manipulating data in interesting ways. Below is the JavaScript code for removing the subdirectories from a URL string. This will also strip away any query string parameters.

function removeSubdirectoryFromUrlString(url){
  
  var ssl = false;
  if(url.indexOf("https://")){
    ssl = true;
  }

  url = url.replace("http://", "");
  url = url.replace("https://", "");
  var pathArray = url.split("/")
  url = pathArray[0];
  if(ssl){
    url = "https://" + url;
  }else{
    url = "http://" + url;
  }

  return url;
}

Now, you can get the current page’s URL, and strip off everything after the host name:

var url = window.location.href;
var baseUrl = removeSubdirectoryFromUrlString(url);
console.log(baseUrl);

Another example:

var url = "https://www.antpace.com/blog/index.php/2018/12/";
var baseUrl = removeSubdirectoryFromUrlString(url);

//This will return "https://www.antpace.com"
console.log(baseUrl); 

I used this code to re-write all URL references in an iFrame to be absolute. My implementation loops through all image, anchor, and script tags on the source site. It determines if each uses an absolute reference, and if not re-writes it as one. This was part of a project that uses a visual editor to allow users to manipulate a remote site. Check out my source code below.

pageIframe.contents().find("img").each(function(){
  var src = $(this).attr("src");
  if(src && src.length > 0 && src.indexOf("//") == -1){  //if not absolute reference
    var url = iframeUrlString;
    if(src.charAt(0) == "/"){ //only do this if the src does not start with a slash
      url = removeSubdirectoryFromUrlString(url); 
    }
    src = url+"/"+src
  }
  $(this).attr("src", src);
});

pageIframe.contents().find("script").each(function(){
  var src = $(this).attr("src");
  if(src && src.length > 0 && src.indexOf("//") == -1){
    var url = iframeUrlString;
    if(src.charAt(0) == "/"){
      url = removeSubdirectoryFromUrlString(url); 
    }
    src = url+"/"+src
  }
  $(this).attr("src", src);
});

pageIframe.contents().find("link").each(function(){
  var src = $(this).attr("href");
  if(src && src.length > 0 && src.indexOf("//") == -1){
    var url = iframeUrlString;
    if(src.charAt(0) == "/"){
      url = removeSubdirectoryFromUrlString(url); 
    }
    src = url+"/"+src
  }
  $(this).attr("href", src);
});

If you liked this, check out my other post about my reusable code framework for web apps, A framework for web apps and startups.