Developer Guide to Document Authoring with Edge Delivery Services. Part 4

Author: Tom Cranstoun
Building an SPA app with Json

This is part 4 of my Developer’s Guide to Document Authoring with Edge Delivery Services. This time, we will create an SPA app.

Previously

We demonstrated how spreadsheets can serve as a JSON source for websites and Edge Delivery Services, as well as for notifications and configuration purposes.

Part 4 - Building an App with Json

I will first create an application in Edge Delivery Services and then extend this to a React application. Edge Delivery Services supports react and vue; one does not get the perfect 100,100,100,100 lighthouse scoring when we move to Single Page Applications (SPAs), though we get enhanced user interactions. This article shows how Edge Delivery Services can assist customers in building the best of both worlds. Architects should ensure that the home, campaign, and direct landing pages are built with pure Edge Delivery Services code; secondary pages can be more interactive, keeping the website's performance high. Like all rules, you can choose to disregard this one.

The app we build will demonstrate that Edge Delivery Services is not just for static pages.

Specification of the App

The app is a slideshow designed for content authors who create individual pages.

In a ‘traditional CMS, ’ this would probably be a pool of images in an extendable component, which is difficult for a content author to use. We will build a design focused on the content author.

Each page will also serve as a standalone entity, with a visually appealing slide image complemented by detailed textual content. The code's primary objective is to extract metadata from each page, represented as a JSON object. This metadata is then employed to showcase the slides in a continuous scrolling format, providing users with an immersive experience; each slide is linked to its corresponding page, enabling seamless navigation for users seeking additional information or exploring the content in greater depth.

This feature enhances the user experience by allowing easy access to supplementary materials, fostering a more interactive and informative environment.

Creating the documents

Create a folder ‘slides’ in the root folder of our Google Drive, and create our page in this folder.

As a subject, I will create slides about York, the English city where I live. I will create five pages with images and information. The code will not be restricted to five pages; use as many.

My pages will be simple to show the context of creating an SPA, and the layout will be

Title, Image, Descriptive text, Metadata

Similar to this, each page is different, and it will expand on the text.

Accessing the pages

Query-index

Have all pages been created? Add a query-index spreadsheet in the folder. We covered this in part three when we created a query-index in the root folder. As a reminder, we want a blank like this.

Now, preview and publish the pages.

Edge Delivery Services populates the query-index

We can look at the json

{
    total: 5,
    offset: 0,
    limit: 5,
    data: [
        {
            path: "/slides/york-minster",
            title: "York Minster",
            image: "/slides/media_188fa5bcd003e5a2d56e7ad3ca233300c9e52f1e5.png?width=1200&format=pjpg&optimize=medium",
            description: "A magnificent Gothic cathedral with centuries of history and breathtaking architecture",
            lastModified: "1719573871"
},
        {
            path: "/slides/the-shambles",
            title: "The Shambles",
            image: "/slides/media_1b0dfc9893bff97d7d5043efd4e51ba659d6279fd.png?width=1200&format=pjpg&optimize=medium",
            description: "A picturesque medieval street with overhanging buildings and quaint shops.",
            lastModified: "1719574976"
},
        {
            path: "/slides/city-walls",
            title: "York City Walls",
            image: "/slides/media_12dbcc0d4cb3832a1db799800442d9b9a0fffd322.png?width=1200&format=pjpg&optimize=medium",
            description: "Ancient fortifications circling the city, offering scenic walks and historical insights.",
            lastModified: "1719575149"
},
        {
            path: "/slides/jorvik-viking-centre",
            title: "Jorvik Viking Centre",
            image: "/slides/media_1f55dd77ff6e081220ed2d95ddd55f33efb1cebdd.png?width=1200&format=pjpg&optimize=medium",
            description: "An interactive museum showcasing York's Viking heritage through reconstructions and artifacts.",
            lastModified: "1719575607"
},
        {
            path: "/slides/national-railway-museum",
            title: "National Railway Museum",
            image: "/slides/media_1251e262eade67c1f9c8e0ccffa6d35945487140c.png?width=1200&format=pjpg&optimize=medium",
            description: "A world-class museum celebrating Britain's railway heritage with an impressive collection of locomotives and carriages",
            lastModified: "1719576556"
}
],
    : type: "sheet"
}

We should create the landing page.


“Five things to do in York, England”

In this page we added a single block ‘slide builder’; which will contain the JavaScript and styling for our SPA.

Remember that Edge Delivery Services replace non-printing and strange characters with hyphens; we need to create our files in

/blocks/slide-builder/slide-builder.css

/blocks/slide-builder/slide-builder.js


The JS is the first thing I will create. We use ‘fetch’ to obtain the json from our query-index in the slides folder of the Google Drive, iterate over every entry and make the initial page markup.

We will develop the code step by step.

As usual, we start with our export statement; it is good practice to do this

export default async function decorate(block) {
  const supportsWebP = window.createImageBitmap && window.createImageBitmap.toString().includes("native code");

}

We have started with a query to check if WebP, high-speed images, is supported.

We will layer this up as we go through the tutorial.

Fetch the slides

async function fetchSlides() {
    const response = await fetch("/slides/query-index.json");
    const json = await response.json();
    
    const slides = [];
    for (const slide of json.data) {
      if (window.innerWidth > 799) {
        slide.html = await fetchSlideHtml(slide.path);
      } else {
        slide.html = null;
      }
      slides.push(slide);
    }
    return slides;
  }

async function fetchSlideHtml(path) {
    try {
      const response = await fetch(`${path}.plain.html`);
      if (!response.ok) {
        throw new Error(`Failed to fetch HTML for slide: ${path}`);
      }
      return await response.text();
    } catch (error) {
      console.error(error);
      return null;
    }
  }

There are two functions here; the first fetch retrieves the query-index inside the /slides/ folder, which lists the published slide we saw in the json above.

However, each slide page has information we want for the SPA, a hero-type image with variations for mobile, etc., as well as the intro paragraph and detailed paragraphs; we need to pick those up too. We bypass this in a mobile view (viewport <800). In the mobile view, the html is replaced by a null. The fetchSlideHtml function appends ‘.plain.html’ to the path; this signals to Edge Delivery Services to treat the page as a fragment and only return plain html without a header footer or any javascript - just plain html.

If returned, it is stored in the json for later use.

Next step, add the slides.

  const container = document.querySelector(".slide-builder");
  const slides = await fetchSlides();

  const observer = new IntersectionObserver(
    (entries, observer) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          const slideItem = entry.target;
          const imageUrl = slideItem.dataset.bg;
          setSlideBackground(slideItem, imageUrl);
          observer.unobserve(slideItem);
        }
      });
    },
    { rootMargin: "100px" }
  );

  for (let i = 0; i < slides.length; i++) {
    const slideItem = await createSlideItem(slides[i], i);
    observer.observe(slideItem);
    container.appendChild(slideItem);
  }

We Iterate over the json and call functions to build our app; we use an intersection observer feature to lazy load our images. That bit that says rootMargin: 100px is the wiggle room for our image coming into view.

Create each slide

  async function createSlideItem(slideData, index) {
    const { image, title, description, path } = slideData;
    const imageUrl = image.split("?")[0];

    const slideItem = document.createElement("div");
    slideItem.classList.add("slide-builder-item");
    slideItem.setAttribute("data-bg", imageUrl);
    slideItem.setAttribute("data-slidenum", index + 1);

    slideItem.innerHTML = `
      <div class="text-container">
        <h2>${title}</h2>
        <p><strong>${description}</strong></p>
      </div>
    `;

    slideItem.addEventListener("click", () => createPanel(slideData));

    // Fetch and append supporting text if available
    if (!slideData.html && window.innerWidth <= 799) {
      slideData.html = await fetchSlideHtml(path);
    }

    if (slideData.html) {
      const supportingText = fetchSupportingText(slideData.html);
      if (supportingText) {
        const textContainer = slideItem.querySelector('.text-container');
        textContainer.insertAdjacentHTML('beforeend', `
          <p class="supporting-text">${supportingText}</p>
        `);
      }
    }

    return slideItem;
  }

The createSlideItem function builds all of the html necessary to display an individual slide, adding a click handler, which creates a panel; if we are running on a desktop, it adds additional supporting text (the first paragraph from the HTML) to the slide.

This technique speeds up mobile whilst keeping the desktop view enriched. The design is mobile-first, desktop-enriched.

The CreatePanel Function

async function createPanel(slideData) {
    let html = slideData.html;
    if (!html) {
      html = await fetchSlideHtml(slideData.path);
      if (!html) {
        console.error("Failed to fetch HTML content for this slide");
        return;
      }
    }

    const panel = document.createElement('div');
    panel.classList.add('slide-panel');
    panel.innerHTML = `
      <div class="slide-panel-content">
        <div class="slide-panel-body"></div>
      </div>
      <button class="slide-panel-close" aria-label="Close panel">&times;</button>
    `;
    panel.querySelector('.slide-panel-body').innerHTML = html;
    
    const closeButton = panel.querySelector('.slide-panel-close');
    closeButton.addEventListener('click', () => {
      panel.remove();
    });

    document.body.appendChild(panel);

    // Ensure the close button is visible and above other content
    setTimeout(() => {
      closeButton.style.display = 'block';
      closeButton.style.zIndex = '1001'; // Ensure this is higher than other elements
    }, 0);
  }

In the createPanel we check to see if the HTML is present to display; if not we fetch it. Remember it is not present on the mobile view; keeping this responsive and fast. We add the controls to dismiss the panel.

FetchSupportingText Function

  function fetchSupportingText(html) {
    if (!html) return null;
    const parser = new DOMParser();
    const doc = parser.parseFromString(html, "text/html");
    const h2 = doc.querySelector("h2");
    let firstParagraph = h2 ? h2.nextElementSibling : doc.querySelector("p");
    while (firstParagraph && firstParagraph.tagName.toLowerCase() !== "p") {
      firstParagraph = firstParagraph.nextElementSibling;
    }
    return firstParagraph?.textContent.trim() || null;
  }
 

If present, the FetchSupportingText function reads the HTML from the html node of the Json, parses it with the Dom Parser, and extracts the first paragraph after the first h2 element. This is an essential technique in Edge Delivery Services; the web uses modern JavaScript, and the browsers fully support parsing DOM structures. Edge Delivery services treat the DOM as a first-class citizen, tying this with ‘plain.html’, and developers have a rich resource set.

Setting the background image

 function setSlideBackground(slideItem, imageUrl) {
    const finalImageUrl = supportsWebP
      ? `${imageUrl}?width=2000&format=webply&optimize=medium`
      : imageUrl;

    const img = new Image();
    img.src = finalImageUrl;

    img.onload = () => {
      slideItem.style.backgroundImage = `url(${finalImageUrl})`;
      slideItem.classList.add("loaded");
    };

    img.onerror = () => {
      console.error(`Failed to load image: ${finalImageUrl}`);
    };
  }

Two things are happening here: the url is constructed from the original image url, which was present in the metadata, and therefore, the image node of the json. We use the supportsWebP variable to adjust the image for WebP serving from Edge Delivery, set width and optimise level through a query string. We go for the original image if the browser does not support WebP.

Notice that we have img.onload and image.onerror functions to support lazy loading.

That was a lot to cover

Here is the complete listing

export default async function decorate(block) {
  const supportsWebP = window.createImageBitmap && window.createImageBitmap.toString().includes("native code");

  async function fetchSlideHtml(path) {
    try {
      const response = await fetch(`${path}.plain.html`);
      if (!response.ok) {
        throw new Error(`Failed to fetch HTML for slide: ${path}`);
      }
      return await response.text();
    } catch (error) {
      console.error(error);
      return null;
    }
  }

  async function fetchSlides() {
    const response = await fetch("/slides/query-index.json");
    const json = await response.json();
    
    const slides = [];
    for (const slide of json.data) {
      if (window.innerWidth > 799) {
        slide.html = await fetchSlideHtml(slide.path);
      } else {
        slide.html = null;
      }
      slides.push(slide);
    }
    return slides;
  }

  function fetchSupportingText(html) {
    if (!html) return null;

    const parser = new DOMParser();
    const doc = parser.parseFromString(html, "text/html");

    const h2 = doc.querySelector("h2");
    let firstParagraph = h2 ? h2.nextElementSibling : doc.querySelector("p");

    while (firstParagraph && firstParagraph.tagName.toLowerCase() !== "p") {
      firstParagraph = firstParagraph.nextElementSibling;
    }

    return firstParagraph?.textContent.trim() || null;
  }

  function setSlideBackground(slideItem, imageUrl) {
    const finalImageUrl = supportsWebP
      ? `${imageUrl}?width=2000&format=webply&optimize=medium`
      : imageUrl;

    const img = new Image();
    img.src = finalImageUrl;

    img.onload = () => {
      slideItem.style.backgroundImage = `url(${finalImageUrl})`;
      slideItem.classList.add("loaded");
    };

    img.onerror = () => {
      console.error(`Failed to load image: ${finalImageUrl}`);
    };
  }

  async function createPanel(slideData) {
    let html = slideData.html;
    if (!html) {
      html = await fetchSlideHtml(slideData.path);
      if (!html) {
        console.error("Failed to fetch HTML content for this slide");
        return;
      }
    }

    const panel = document.createElement('div');
    panel.classList.add('slide-panel');
    panel.innerHTML = `
      <div class="slide-panel-content">
        <div class="slide-panel-body"></div>
      </div>
      <button class="slide-panel-close" aria-label="Close panel">&times;</button>
    `;
    panel.querySelector('.slide-panel-body').innerHTML = html;
    
    const closeButton = panel.querySelector('.slide-panel-close');
    closeButton.addEventListener('click', () => {
      panel.remove();
    });

    document.body.appendChild(panel);

    // Ensure the close button is visible and above other content
    setTimeout(() => {
      closeButton.style.display = 'block';
      closeButton.style.zIndex = '1001'; // Ensure this is higher than other elements
    }, 0);
  }

  async function createSlideItem(slideData, index) {
    const { image, title, description, path } = slideData;
    const imageUrl = image.split("?")[0];

    const slideItem = document.createElement("div");
    slideItem.classList.add("slide-builder-item");
    slideItem.setAttribute("data-bg", imageUrl);
    slideItem.setAttribute("data-slidenum", index + 1);

    slideItem.innerHTML = `
      <div class="text-container">
        <h2>${title}</h2>
        <p><strong>${description}</strong></p>
      </div>
    `;

    slideItem.addEventListener("click", () => createPanel(slideData));

    // Fetch and append supporting text if available
    if (!slideData.html && window.innerWidth <= 799) {
      slideData.html = await fetchSlideHtml(path);
    }

    if (slideData.html) {
      const supportingText = fetchSupportingText(slideData.html);
      if (supportingText) {
        const textContainer = slideItem.querySelector('.text-container');
        textContainer.insertAdjacentHTML('beforeend', `
          <p class="supporting-text">${supportingText}</p>
        `);
      }
    }

    return slideItem;
  }

  const container = document.querySelector(".slide-builder");
  const slides = await fetchSlides();

  const observer = new IntersectionObserver(
    (entries, observer) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          const slideItem = entry.target;
          const imageUrl = slideItem.dataset.bg;
          setSlideBackground(slideItem, imageUrl);
          observer.unobserve(slideItem);
        }
      });
    },
    { rootMargin: "100px" }
  );

  for (let i = 0; i < slides.length; i++) {
    const slideItem = await createSlideItem(slides[i], i);
    observer.observe(slideItem);
    container.appendChild(slideItem);
  }
}

Completing the ask, the CSS

.slide-builder {
    width: 100%; 
  }
  
  .slide-builder-item {
    height: 600px; 
    object-fit: cover; 
    position: relative; /* For roundel positioning */
    margin-bottom: 25px; /* Add margin to the bottom of each slide */
  }
  
  /* Roundel Styling */
  .slide-builder-item::before { 
    content: attr(data-slidenum);
    position: absolute;
    top: 20px;
    left: 20px;
    width: 40px;
    height: 40px;
    background-color: rgba(0, 0, 0, 0.7);
    color: white;
    border-radius: 50%;
    display: flex; /* We still need this for centering the roundel content */
    align-items: center;
    justify-content: center;
    font-weight: bold;
  }
  
  .slide-builder-item div { 
    background: rgba(0, 0, 0, 0.5); 
    color: white;
    padding: 1em;
    margin: 1em;
    position: absolute; /* Position the div at the bottom left */
    bottom: 1em;
    left: 1em;
  }
  
  .slide-builder-item h2 {
    margin: 0;
    font-weight: bold;
  }
  
  /* Specific styling for the supporting text paragraph */
  .slide-builder-item p.supporting-text {
    margin: 0;
  }
  
  /* Media query for screens smaller than 800px */
  @media (max-width: 800px) {
    .slide-builder-item p.supporting-text {
      display: none; /* Hide supporting text on smaller screens */
    }
  }

  .slide-panel {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, 0.8);
    display: flex;
    justify-content: center;
    align-items: center;
    z-index: 1000;
  }
  
  .slide-panel-content {
    background-color: white;
    padding: 20px;
    border-radius: 5px;
    max-width: 80%;
    max-height: 80%;
    overflow-y: auto;
    position: relative;
  }
  
  .slide-panel-close {
    position: absolute;
    top: 10px;
    right: 10px;
    font-size: 24px;
    background: none;
    border: none;
    cursor: pointer;
    color: white; /* Make the button visible against dark background */
    z-index: 1001; /* Ensure it's above the content */
  }

Phew, that is complicated; by breaking it into smaller pieces, I hope you see that the design fits together and you will be able to build SPA Applications with query-index.json and .plain.html

We kept excellent lighthouse scores despite using lots of fetch and stitching together. Edge Delivery Services is a beautiful tool.


The finished Spa App is here Five things to do in York, England (allabout.network)

In contrast, we can convert this into a ReactJS application and serve it directly from Edge Delivery Services, which will be the next part of this series.

/fragments/ddt/proposition

Related Articles

guide
Back to Top