chunk-030-related-posts.js

I’ll build this as a standalone advanced related-posts JavaScript module for your RX Theme path, with safe defaults, performance features, accessibility, AJAX-ready hooks, and graceful fallback behavior.

My dear friend, create this file:

assets/static-js/static-chunks-js/chunk-030-related-posts.js

Then paste this advanced code inside it.

/*!
 * RX Theme - Related Posts Advanced Chunk
 * File: assets/static-js/static-chunks-js/chunk-030-related-posts.js
 * Purpose: Advanced related posts loader, renderer, carousel/grid, cache, accessibility, REST API support.
 * Author: RX Theme
 */

(function () {
  "use strict";

  /**
   * RX Related Posts
   * -------------------------------------------------------
   * Works with:
   * 1. Static HTML related posts already printed by PHP
   * 2. AJAX/REST API related posts
   * 3. Category/tag based related post query
   * 4. Grid, slider, compact list, sidebar widget layout
   *
   * Required container example:
   *
   * <section
   *   class="rx-related-posts"
   *   data-rx-related-posts
   *   data-post-id="123"
   *   data-categories="12,14"
   *   data-tags="5,9"
   *   data-limit="6"
   *   data-layout="grid"
   *   data-fetch="true">
   * </section>
   */

  var RXRelatedPosts = {
    version: "1.0.0",

    defaults: {
      selector: "[data-rx-related-posts]",
      itemSelector: ".rx-related-post",
      fetchEnabled: true,
      endpoint: "/wp-json/wp/v2/posts",
      customEndpoint: "",
      limit: 6,
      excludeCurrent: true,
      layout: "grid",
      columns: 3,
      mobileColumns: 1,
      tabletColumns: 2,
      imageSize: "medium_large",
      showImage: true,
      showCategory: true,
      showDate: true,
      showAuthor: false,
      showExcerpt: true,
      showReadingTime: true,
      showViewMore: true,
      viewMoreText: "View more related articles",
      loadingText: "Loading related articles...",
      emptyText: "No related articles found.",
      errorText: "Related articles could not be loaded.",
      cache: true,
      cacheType: "sessionStorage",
      cacheTTL: 1000 * 60 * 20,
      lazyImages: true,
      enableCarousel: false,
      enableKeyboard: true,
      enableAnalyticsEvents: true,
      scrollReveal: true,
      minTitleLength: 3,
      excerptLength: 140,
      requestTimeout: 9000,
      debug: false
    },

    state: {
      containers: [],
      initialized: false,
      memoryCache: {},
      resizeTimer: null
    },

    init: function () {
      if (this.state.initialized) {
        return;
      }

      this.state.initialized = true;
      this.state.containers = Array.prototype.slice.call(
        document.querySelectorAll(this.defaults.selector)
      );

      if (!this.state.containers.length) {
        return;
      }

      this.injectBaseStyle();
      this.bindGlobalEvents();

      this.state.containers.forEach(
        function (container, index) {
          this.prepareContainer(container, index);
        }.bind(this)
      );

      this.log("RX Related Posts initialized.");
    },

    prepareContainer: function (container, index) {
      if (!container || container.dataset.rxRelatedInitialized === "true") {
        return;
      }

      container.dataset.rxRelatedInitialized = "true";
      container.dataset.rxRelatedIndex = String(index);

      var config = this.getContainerConfig(container);

      this.setAccessibility(container, config);
      this.applyLayoutClass(container, config);

      var existingItems = container.querySelectorAll(config.itemSelector);

      if (existingItems.length) {
        this.enhanceExistingPosts(container, config);
        this.finishContainer(container, config);
        return;
      }

      if (config.fetchEnabled) {
        this.loadRelatedPosts(container, config);
      } else {
        this.renderEmpty(container, config);
      }
    },

    getContainerConfig: function (container) {
      var data = container.dataset || {};
      var config = this.extend({}, this.defaults);

      config.fetchEnabled = this.toBoolean(data.fetch, config.fetchEnabled);
      config.endpoint = data.endpoint || config.endpoint;
      config.customEndpoint = data.customEndpoint || config.customEndpoint;
      config.limit = this.toNumber(data.limit, config.limit);
      config.layout = data.layout || config.layout;
      config.columns = this.toNumber(data.columns, config.columns);
      config.mobileColumns = this.toNumber(data.mobileColumns, config.mobileColumns);
      config.tabletColumns = this.toNumber(data.tabletColumns, config.tabletColumns);
      config.postId = this.toNumber(data.postId, 0);
      config.categories = this.parseNumberList(data.categories);
      config.tags = this.parseNumberList(data.tags);
      config.imageSize = data.imageSize || config.imageSize;
      config.showImage = this.toBoolean(data.showImage, config.showImage);
      config.showCategory = this.toBoolean(data.showCategory, config.showCategory);
      config.showDate = this.toBoolean(data.showDate, config.showDate);
      config.showAuthor = this.toBoolean(data.showAuthor, config.showAuthor);
      config.showExcerpt = this.toBoolean(data.showExcerpt, config.showExcerpt);
      config.showReadingTime = this.toBoolean(data.showReadingTime, config.showReadingTime);
      config.showViewMore = this.toBoolean(data.showViewMore, config.showViewMore);
      config.enableCarousel = this.toBoolean(data.carousel, config.enableCarousel);
      config.lazyImages = this.toBoolean(data.lazyImages, config.lazyImages);
      config.cache = this.toBoolean(data.cache, config.cache);
      config.emptyText = data.emptyText || config.emptyText;
      config.errorText = data.errorText || config.errorText;
      config.loadingText = data.loadingText || config.loadingText;
      config.viewMoreText = data.viewMoreText || config.viewMoreText;
      config.excerptLength = this.toNumber(data.excerptLength, config.excerptLength);

      return config;
    },

    loadRelatedPosts: function (container, config) {
      var cacheKey = this.getCacheKey(config);
      var cached = config.cache ? this.getCache(cacheKey, config) : null;

      if (cached && Array.isArray(cached.posts)) {
        this.renderPosts(container, cached.posts, config);
        this.finishContainer(container, config);
        this.dispatch(container, "rx-related-posts-cache-hit", {
          posts: cached.posts,
          config: config
        });
        return;
      }

      this.renderLoading(container, config);

      var url = this.buildRequestUrl(config);

      this.fetchWithTimeout(url, {
        method: "GET",
        credentials: "same-origin",
        headers: {
          "Accept": "application/json"
        }
      }, config.requestTimeout)
        .then(
          function (response) {
            if (!response.ok) {
              throw new Error("HTTP error " + response.status);
            }
            return response.json();
          }
        )
        .then(
          function (data) {
            var posts = this.normalizePosts(data, config);

            if (config.excludeCurrent && config.postId) {
              posts = posts.filter(function (post) {
                return Number(post.id) !== Number(config.postId);
              });
            }

            posts = posts.slice(0, config.limit);

            if (config.cache) {
              this.setCache(cacheKey, { posts: posts }, config);
            }

            if (!posts.length) {
              this.renderEmpty(container, config);
            } else {
              this.renderPosts(container, posts, config);
            }

            this.finishContainer(container, config);

            this.dispatch(container, "rx-related-posts-loaded", {
              posts: posts,
              config: config
            });
          }.bind(this)
        )
        .catch(
          function (error) {
            this.log("Related posts error:", error);
            this.renderError(container, config);
            this.dispatch(container, "rx-related-posts-error", {
              error: error,
              config: config
            });
          }.bind(this)
        );
    },

    buildRequestUrl: function (config) {
      var endpoint = config.customEndpoint || config.endpoint;
      var url;

      try {
        url = new URL(endpoint, window.location.origin);
      } catch (error) {
        url = new URL(this.defaults.endpoint, window.location.origin);
      }

      url.searchParams.set("per_page", String(Math.max(config.limit + 2, config.limit)));
      url.searchParams.set("_embed", "1");

      if (config.postId) {
        url.searchParams.set("exclude", String(config.postId));
      }

      if (config.categories.length) {
        url.searchParams.set("categories", config.categories.join(","));
      }

      if (config.tags.length) {
        url.searchParams.set("tags", config.tags.join(","));
      }

      return url.toString();
    },

    normalizePosts: function (data, config) {
      if (!Array.isArray(data)) {
        if (data && Array.isArray(data.posts)) {
          data = data.posts;
        } else {
          return [];
        }
      }

      return data.map(
        function (item) {
          var embedded = item._embedded || {};
          var media = embedded["wp:featuredmedia"] && embedded["wp:featuredmedia"][0];
          var terms = embedded["wp:term"] || [];
          var author = embedded.author && embedded.author[0];

          var image = this.getImageFromMedia(media, config.imageSize);
          var categoryName = this.getFirstCategoryName(terms);

          return {
            id: item.id || 0,
            title: this.cleanText(item.title && item.title.rendered ? item.title.rendered : item.title || ""),
            url: item.link || "#",
            excerpt: this.cleanText(item.excerpt && item.excerpt.rendered ? item.excerpt.rendered : item.excerpt || ""),
            date: item.date || "",
            modified: item.modified || "",
            image: image,
            category: categoryName,
            author: author && author.name ? author.name : "",
            readingTime: this.estimateReadingTime(
              this.cleanText(item.content && item.content.rendered ? item.content.rendered : item.excerpt || "")
            )
          };
        }.bind(this)
      ).filter(
        function (post) {
          return post.title && post.title.length >= config.minTitleLength;
        }
      );
    },

    getImageFromMedia: function (media, imageSize) {
      if (!media) {
        return "";
      }

      if (
        media.media_details &&
        media.media_details.sizes &&
        media.media_details.sizes[imageSize] &&
        media.media_details.sizes[imageSize].source_url
      ) {
        return media.media_details.sizes[imageSize].source_url;
      }

      if (media.source_url) {
        return media.source_url;
      }

      return "";
    },

    getFirstCategoryName: function (terms) {
      if (!Array.isArray(terms)) {
        return "";
      }

      for (var i = 0; i < terms.length; i++) {
        var group = terms[i];

        if (!Array.isArray(group)) {
          continue;
        }

        for (var j = 0; j < group.length; j++) {
          if (group[j] && group[j].taxonomy === "category" && group[j].name) {
            return group[j].name;
          }
        }
      }

      return "";
    },

    renderLoading: function (container, config) {
      container.innerHTML = "";

      var wrapper = document.createElement("div");
      wrapper.className = "rx-related-posts__loading";
      wrapper.setAttribute("role", "status");
      wrapper.setAttribute("aria-live", "polite");

      var text = document.createElement("span");
      text.className = "rx-related-posts__loading-text";
      text.textContent = config.loadingText;

      wrapper.appendChild(text);

      for (var i = 0; i < Math.min(config.limit, 6); i++) {
        var skeleton = document.createElement("div");
        skeleton.className = "rx-related-posts__skeleton";
        skeleton.innerHTML =
          '<span class="rx-related-posts__skeleton-image"></span>' +
          '<span class="rx-related-posts__skeleton-line"></span>' +
          '<span class="rx-related-posts__skeleton-line rx-related-posts__skeleton-line--short"></span>';
        wrapper.appendChild(skeleton);
      }

      container.appendChild(wrapper);
    },

    renderPosts: function (container, posts, config) {
      container.innerHTML = "";

      var heading = this.getOrCreateHeading(container);
      var list = document.createElement("div");

      list.className = "rx-related-posts__list";
      list.setAttribute("data-rx-related-list", "true");
      list.style.setProperty("--rx-related-columns", String(config.columns));
      list.style.setProperty("--rx-related-tablet-columns", String(config.tabletColumns));
      list.style.setProperty("--rx-related-mobile-columns", String(config.mobileColumns));

      posts.forEach(
        function (post, index) {
          list.appendChild(this.createPostCard(post, config, index));
        }.bind(this)
      );

      container.appendChild(heading);
      container.appendChild(list);

      if (config.showViewMore && posts.length >= config.limit) {
        container.appendChild(this.createViewMoreButton(config));
      }
    },

    getOrCreateHeading: function () {
      var heading = document.createElement("div");
      heading.className = "rx-related-posts__header";
      heading.innerHTML =
        '<h2 class="rx-related-posts__title">Related Articles</h2>' +
        '<p class="rx-related-posts__subtitle">Carefully selected articles that may help you continue reading.</p>';
      return heading;
    },

    createPostCard: function (post, config, index) {
      var article = document.createElement("article");
      article.className = "rx-related-post";
      article.setAttribute("data-rx-related-item", "true");
      article.setAttribute("data-post-id", String(post.id || ""));
      article.style.setProperty("--rx-related-index", String(index));

      var html = "";

      if (config.showImage) {
        html += this.createImageHtml(post, config);
      }

      html += '<div class="rx-related-post__body">';

      if (config.showCategory && post.category) {
        html += '<span class="rx-related-post__category">' + this.escapeHtml(post.category) + "</span>";
      }

      html +=
        '<h3 class="rx-related-post__title">' +
        '<a class="rx-related-post__link" href="' + this.escapeAttribute(post.url) + '">' +
        this.escapeHtml(post.title) +
        "</a>" +
        "</h3>";

      html += this.createMetaHtml(post, config);

      if (config.showExcerpt && post.excerpt) {
        html +=
          '<p class="rx-related-post__excerpt">' +
          this.escapeHtml(this.truncate(post.excerpt, config.excerptLength)) +
          "</p>";
      }

      html +=
        '<a class="rx-related-post__readmore" href="' + this.escapeAttribute(post.url) + '" aria-label="Read more about ' + this.escapeAttribute(post.title) + '">' +
        "Read more" +
        "</a>";

      html += "</div>";

      article.innerHTML = html;

      return article;
    },

    createImageHtml: function (post, config) {
      var image = post.image || "";
      var title = post.title || "";

      if (!image) {
        return (
          '<a class="rx-related-post__media rx-related-post__media--placeholder" href="' + this.escapeAttribute(post.url) + '" aria-label="' + this.escapeAttribute(title) + '">' +
          '<span class="rx-related-post__placeholder-icon" aria-hidden="true">+</span>' +
          "</a>"
        );
      }

      var loadingAttr = config.lazyImages ? ' loading="lazy" decoding="async"' : ' decoding="async"';

      return (
        '<a class="rx-related-post__media" href="' + this.escapeAttribute(post.url) + '" aria-label="' + this.escapeAttribute(title) + '">' +
        '<img class="rx-related-post__image" src="' + this.escapeAttribute(image) + '" alt="' + this.escapeAttribute(title) + '"' + loadingAttr + ">" +
        "</a>"
      );
    },

    createMetaHtml: function (post, config) {
      var parts = [];

      if (config.showDate && post.date) {
        parts.push(
          '<time datetime="' + this.escapeAttribute(post.date) + '">' +
          this.escapeHtml(this.formatDate(post.date)) +
          "</time>"
        );
      }

      if (config.showAuthor && post.author) {
        parts.push(
          '<span class="rx-related-post__author">' +
          this.escapeHtml(post.author) +
          "</span>"
        );
      }

      if (config.showReadingTime && post.readingTime) {
        parts.push(
          '<span class="rx-related-post__reading-time">' +
          this.escapeHtml(post.readingTime) +
          "</span>"
        );
      }

      if (!parts.length) {
        return "";
      }

      return '<div class="rx-related-post__meta">' + parts.join("<span aria-hidden='true'>•</span>") + "</div>";
    },

    createViewMoreButton: function (config) {
      var wrapper = document.createElement("div");
      wrapper.className = "rx-related-posts__footer";

      var link = document.createElement("a");
      link.className = "rx-related-posts__view-more";
      link.href = this.getArchiveUrl(config);
      link.textContent = config.viewMoreText;

      wrapper.appendChild(link);

      return wrapper;
    },

    getArchiveUrl: function (config) {
      if (config.categories && config.categories.length) {
        return "/?cat=" + encodeURIComponent(config.categories[0]);
      }

      if (config.tags && config.tags.length) {
        return "/?tag_id=" + encodeURIComponent(config.tags[0]);
      }

      return "/blog/";
    },

    renderEmpty: function (container, config) {
      container.innerHTML =
        '<div class="rx-related-posts__empty" role="status">' +
        this.escapeHtml(config.emptyText) +
        "</div>";
    },

    renderError: function (container, config) {
      container.innerHTML =
        '<div class="rx-related-posts__error" role="alert">' +
        this.escapeHtml(config.errorText) +
        "</div>";
    },

    enhanceExistingPosts: function (container, config) {
      var items = Array.prototype.slice.call(container.querySelectorAll(config.itemSelector));

      items.forEach(
        function (item, index) {
          item.setAttribute("data-rx-related-item", "true");
          item.style.setProperty("--rx-related-index", String(index));

          var image = item.querySelector("img");

          if (image && config.lazyImages) {
            image.loading = "lazy";
            image.decoding = "async";
          }

          var link = item.querySelector("a");

          if (link) {
            link.addEventListener(
              "click",
              function () {
                this.dispatch(container, "rx-related-post-click", {
                  url: link.href,
                  index: index
                });
              }.bind(this)
            );
          }
        }.bind(this)
      );
    },

    finishContainer: function (container, config) {
      container.classList.add("rx-related-posts--ready");

      this.observeImages(container);
      this.bindItemEvents(container, config);

      if (config.enableCarousel) {
        this.setupCarousel(container, config);
      }

      if (config.scrollReveal) {
        this.setupReveal(container);
      }
    },

    bindItemEvents: function (container) {
      var links = Array.prototype.slice.call(
        container.querySelectorAll(".rx-related-post__link, .rx-related-post__readmore, .rx-related-post__media")
      );

      links.forEach(
        function (link, index) {
          link.addEventListener(
            "click",
            function () {
              this.dispatch(container, "rx-related-post-click", {
                url: link.href,
                index: index,
                text: link.textContent.trim()
              });
            }.bind(this)
          );
        }.bind(this)
      );
    },

    setupCarousel: function (container) {
      var list = container.querySelector("[data-rx-related-list]");

      if (!list) {
        return;
      }

      container.classList.add("rx-related-posts--carousel");

      var controls = document.createElement("div");
      controls.className = "rx-related-posts__controls";

      var prev = document.createElement("button");
      prev.type = "button";
      prev.className = "rx-related-posts__control rx-related-posts__control--prev";
      prev.setAttribute("aria-label", "Previous related post");
      prev.textContent = "‹";

      var next = document.createElement("button");
      next.type = "button";
      next.className = "rx-related-posts__control rx-related-posts__control--next";
      next.setAttribute("aria-label", "Next related post");
      next.textContent = "›";

      controls.appendChild(prev);
      controls.appendChild(next);
      container.appendChild(controls);

      prev.addEventListener("click", function () {
        list.scrollBy({
          left: -Math.max(280, list.clientWidth * 0.75),
          behavior: "smooth"
        });
      });

      next.addEventListener("click", function () {
        list.scrollBy({
          left: Math.max(280, list.clientWidth * 0.75),
          behavior: "smooth"
        });
      });
    },

    setupReveal: function (container) {
      var items = Array.prototype.slice.call(container.querySelectorAll("[data-rx-related-item]"));

      if (!("IntersectionObserver" in window)) {
        items.forEach(function (item) {
          item.classList.add("rx-related-post--visible");
        });
        return;
      }

      var observer = new IntersectionObserver(
        function (entries) {
          entries.forEach(function (entry) {
            if (entry.isIntersecting) {
              entry.target.classList.add("rx-related-post--visible");
              observer.unobserve(entry.target);
            }
          });
        },
        {
          rootMargin: "0px 0px -8% 0px",
          threshold: 0.1
        }
      );

      items.forEach(function (item) {
        observer.observe(item);
      });
    },

    observeImages: function (container) {
      var images = Array.prototype.slice.call(container.querySelectorAll("img"));

      images.forEach(function (image) {
        image.addEventListener("load", function () {
          image.classList.add("rx-related-post__image--loaded");
        });

        image.addEventListener("error", function () {
          image.classList.add("rx-related-post__image--error");
          image.setAttribute("alt", "");
        });
      });
    },

    setAccessibility: function (container) {
      if (!container.getAttribute("role")) {
        container.setAttribute("role", "region");
      }

      if (!container.getAttribute("aria-label")) {
        container.setAttribute("aria-label", "Related posts");
      }
    },

    applyLayoutClass: function (container, config) {
      container.classList.add("rx-related-posts--" + config.layout);

      if (config.enableCarousel) {
        container.classList.add("rx-related-posts--has-carousel");
      }
    },

    bindGlobalEvents: function () {
      window.addEventListener(
        "resize",
        this.debounce(
          function () {
            document.documentElement.style.setProperty(
              "--rx-related-window-width",
              String(window.innerWidth)
            );
          },
          150
        )
      );

      document.addEventListener(
        "keydown",
        function (event) {
          if (!this.defaults.enableKeyboard) {
            return;
          }

          var active = document.activeElement;

          if (!active || !active.closest) {
            return;
          }

          var container = active.closest(this.defaults.selector);

          if (!container) {
            return;
          }

          if (event.key === "ArrowRight") {
            this.focusNextRelatedLink(container, active);
          }

          if (event.key === "ArrowLeft") {
            this.focusPrevRelatedLink(container, active);
          }
        }.bind(this)
      );
    },

    focusNextRelatedLink: function (container, active) {
      var links = Array.prototype.slice.call(container.querySelectorAll("a, button"));
      var index = links.indexOf(active);

      if (index > -1 && links[index + 1]) {
        links[index + 1].focus();
      }
    },

    focusPrevRelatedLink: function (container, active) {
      var links = Array.prototype.slice.call(container.querySelectorAll("a, button"));
      var index = links.indexOf(active);

      if (index > 0 && links[index - 1]) {
        links[index - 1].focus();
      }
    },

    fetchWithTimeout: function (url, options, timeout) {
      if ("AbortController" in window) {
        var controller = new AbortController();
        var timer = setTimeout(function () {
          controller.abort();
        }, timeout);

        options.signal = controller.signal;

        return fetch(url, options).finally(function () {
          clearTimeout(timer);
        });
      }

      return Promise.race([
        fetch(url, options),
        new Promise(function (_, reject) {
          setTimeout(function () {
            reject(new Error("Request timeout"));
          }, timeout);
        })
      ]);
    },

    getCacheKey: function (config) {
      return [
        "rx_related_posts",
        "v" + this.version,
        "post_" + config.postId,
        "cat_" + config.categories.join("-"),
        "tag_" + config.tags.join("-"),
        "limit_" + config.limit,
        "layout_" + config.layout
      ].join(":");
    },

    getCache: function (key, config) {
      try {
        if (config.cacheType === "memory") {
          return this.state.memoryCache[key] || null;
        }

        var raw = window.sessionStorage.getItem(key);

        if (!raw) {
          return null;
        }

        var cached = JSON.parse(raw);

        if (!cached || !cached.createdAt) {
          return null;
        }

        if (Date.now() - cached.createdAt > config.cacheTTL) {
          window.sessionStorage.removeItem(key);
          return null;
        }

        return cached.value;
      } catch (error) {
        return null;
      }
    },

    setCache: function (key, value, config) {
      try {
        if (config.cacheType === "memory") {
          this.state.memoryCache[key] = value;
          return;
        }

        window.sessionStorage.setItem(
          key,
          JSON.stringify({
            createdAt: Date.now(),
            value: value
          })
        );
      } catch (error) {
        this.state.memoryCache[key] = value;
      }
    },

    injectBaseStyle: function () {
      if (document.getElementById("rx-related-posts-base-style")) {
        return;
      }

      var style = document.createElement("style");
      style.id = "rx-related-posts-base-style";

      style.textContent = `
        .rx-related-posts {
          --rx-related-gap: 24px;
          --rx-related-radius: 18px;
          --rx-related-border: rgba(15, 23, 42, 0.10);
          --rx-related-bg: #ffffff;
          --rx-related-soft-bg: rgba(15, 23, 42, 0.035);
          --rx-related-text: #0f172a;
          --rx-related-muted: #64748b;
          --rx-related-accent: #0f766e;
          margin-block: 32px;
          position: relative;
        }

        .rx-related-posts__header {
          margin-bottom: 18px;
        }

        .rx-related-posts__title {
          margin: 0 0 6px;
          font-size: clamp(1.35rem, 2vw, 1.8rem);
          line-height: 1.25;
          color: var(--rx-related-text);
        }

        .rx-related-posts__subtitle {
          margin: 0;
          color: var(--rx-related-muted);
          font-size: 0.96rem;
          line-height: 1.65;
        }

        .rx-related-posts__list {
          display: grid;
          grid-template-columns: repeat(var(--rx-related-columns, 3), minmax(0, 1fr));
          gap: var(--rx-related-gap);
        }

        .rx-related-post {
          overflow: hidden;
          border: 1px solid var(--rx-related-border);
          border-radius: var(--rx-related-radius);
          background: var(--rx-related-bg);
          transform: translateY(12px);
          opacity: 0;
          transition: transform 280ms ease, opacity 280ms ease, box-shadow 280ms ease, border-color 280ms ease;
          transition-delay: calc(var(--rx-related-index, 0) * 35ms);
        }

        .rx-related-post--visible,
        .rx-related-posts:not(.rx-related-posts--ready) .rx-related-post {
          transform: translateY(0);
          opacity: 1;
        }

        .rx-related-post:hover {
          border-color: rgba(15, 118, 110, 0.35);
          box-shadow: 0 18px 46px rgba(15, 23, 42, 0.12);
        }

        .rx-related-post__media {
          display: block;
          position: relative;
          aspect-ratio: 16 / 9;
          overflow: hidden;
          background: var(--rx-related-soft-bg);
          text-decoration: none;
        }

        .rx-related-post__image {
          width: 100%;
          height: 100%;
          display: block;
          object-fit: cover;
          transform: scale(1.01);
          transition: transform 380ms ease, opacity 260ms ease;
        }

        .rx-related-post:hover .rx-related-post__image {
          transform: scale(1.06);
        }

        .rx-related-post__media--placeholder {
          display: flex;
          align-items: center;
          justify-content: center;
        }

        .rx-related-post__placeholder-icon {
          width: 42px;
          height: 42px;
          border-radius: 999px;
          display: inline-flex;
          align-items: center;
          justify-content: center;
          background: rgba(15, 118, 110, 0.12);
          color: var(--rx-related-accent);
          font-size: 1.4rem;
          font-weight: 700;
        }

        .rx-related-post__body {
          padding: 18px;
        }

        .rx-related-post__category {
          display: inline-flex;
          margin-bottom: 10px;
          font-size: 0.78rem;
          font-weight: 700;
          letter-spacing: 0.02em;
          color: var(--rx-related-accent);
        }

        .rx-related-post__title {
          margin: 0 0 10px;
          font-size: clamp(1.05rem, 1.5vw, 1.25rem);
          line-height: 1.35;
        }

        .rx-related-post__link {
          color: var(--rx-related-text);
          text-decoration: none;
        }

        .rx-related-post__link:hover,
        .rx-related-post__link:focus {
          color: var(--rx-related-accent);
          text-decoration: underline;
          text-underline-offset: 3px;
        }

        .rx-related-post__meta {
          display: flex;
          flex-wrap: wrap;
          gap: 7px;
          align-items: center;
          margin-bottom: 10px;
          color: var(--rx-related-muted);
          font-size: 0.83rem;
          line-height: 1.5;
        }

        .rx-related-post__excerpt {
          margin: 0 0 14px;
          color: var(--rx-related-muted);
          font-size: 0.94rem;
          line-height: 1.7;
        }

        .rx-related-post__readmore {
          display: inline-flex;
          align-items: center;
          gap: 6px;
          color: var(--rx-related-accent);
          font-weight: 700;
          font-size: 0.9rem;
          text-decoration: none;
        }

        .rx-related-post__readmore:hover,
        .rx-related-post__readmore:focus {
          text-decoration: underline;
          text-underline-offset: 3px;
        }

        .rx-related-posts__footer {
          margin-top: 24px;
          text-align: center;
        }

        .rx-related-posts__view-more {
          display: inline-flex;
          align-items: center;
          justify-content: center;
          min-height: 44px;
          padding: 10px 18px;
          border-radius: 999px;
          background: var(--rx-related-accent);
          color: #ffffff;
          font-weight: 700;
          text-decoration: none;
        }

        .rx-related-posts__view-more:hover,
        .rx-related-posts__view-more:focus {
          filter: brightness(0.95);
        }

        .rx-related-posts__loading {
          display: grid;
          grid-template-columns: repeat(3, minmax(0, 1fr));
          gap: var(--rx-related-gap);
        }

        .rx-related-posts__loading-text {
          grid-column: 1 / -1;
          color: var(--rx-related-muted);
        }

        .rx-related-posts__skeleton {
          border-radius: var(--rx-related-radius);
          border: 1px solid var(--rx-related-border);
          padding: 14px;
          background: var(--rx-related-bg);
        }

        .rx-related-posts__skeleton-image,
        .rx-related-posts__skeleton-line {
          display: block;
          border-radius: 12px;
          background: linear-gradient(90deg, rgba(15,23,42,.05), rgba(15,23,42,.10), rgba(15,23,42,.05));
          background-size: 200% 100%;
          animation: rxRelatedShimmer 1.4s infinite;
        }

        .rx-related-posts__skeleton-image {
          aspect-ratio: 16 / 9;
          margin-bottom: 14px;
        }

        .rx-related-posts__skeleton-line {
          height: 14px;
          margin-bottom: 10px;
        }

        .rx-related-posts__skeleton-line--short {
          width: 68%;
        }

        .rx-related-posts__empty,
        .rx-related-posts__error {
          padding: 18px;
          border-radius: var(--rx-related-radius);
          background: var(--rx-related-soft-bg);
          color: var(--rx-related-muted);
        }

        .rx-related-posts--carousel .rx-related-posts__list {
          display: flex;
          overflow-x: auto;
          scroll-snap-type: x mandatory;
          scrollbar-width: thin;
          padding-bottom: 8px;
        }

        .rx-related-posts--carousel .rx-related-post {
          min-width: min(86vw, 330px);
          scroll-snap-align: start;
        }

        .rx-related-posts__controls {
          display: flex;
          justify-content: flex-end;
          gap: 10px;
          margin-top: 14px;
        }

        .rx-related-posts__control {
          width: 42px;
          height: 42px;
          border-radius: 999px;
          border: 1px solid var(--rx-related-border);
          background: var(--rx-related-bg);
          color: var(--rx-related-text);
          cursor: pointer;
          font-size: 1.5rem;
          line-height: 1;
        }

        .rx-related-posts__control:hover,
        .rx-related-posts__control:focus {
          border-color: var(--rx-related-accent);
          color: var(--rx-related-accent);
        }

        @keyframes rxRelatedShimmer {
          0% { background-position: 200% 0; }
          100% { background-position: -200% 0; }
        }

        @media (max-width: 900px) {
          .rx-related-posts__list {
            grid-template-columns: repeat(var(--rx-related-tablet-columns, 2), minmax(0, 1fr));
          }

          .rx-related-posts__loading {
            grid-template-columns: repeat(2, minmax(0, 1fr));
          }
        }

        @media (max-width: 640px) {
          .rx-related-posts__list {
            grid-template-columns: repeat(var(--rx-related-mobile-columns, 1), minmax(0, 1fr));
          }

          .rx-related-posts__loading {
            grid-template-columns: 1fr;
          }

          .rx-related-post__body {
            padding: 16px;
          }
        }

        @media (prefers-reduced-motion: reduce) {
          .rx-related-post,
          .rx-related-post__image,
          .rx-related-posts__skeleton-image,
          .rx-related-posts__skeleton-line {
            animation: none !important;
            transition: none !important;
          }
        }
      `;

      document.head.appendChild(style);
    },

    estimateReadingTime: function (text) {
      if (!text) {
        return "";
      }

      var words = text.trim().split(/\s+/).filter(Boolean).length;
      var minutes = Math.max(1, Math.ceil(words / 220));

      return minutes + " min read";
    },

    formatDate: function (dateString) {
      if (!dateString) {
        return "";
      }

      try {
        var date = new Date(dateString);

        return date.toLocaleDateString(undefined, {
          year: "numeric",
          month: "short",
          day: "numeric"
        });
      } catch (error) {
        return dateString;
      }
    },

    cleanText: function (html) {
      if (!html) {
        return "";
      }

      var div = document.createElement("div");
      div.innerHTML = String(html);

      return (div.textContent || div.innerText || "")
        .replace(/\s+/g, " ")
        .trim();
    },

    truncate: function (text, length) {
      text = String(text || "").trim();

      if (text.length <= length) {
        return text;
      }

      return text.slice(0, length).replace(/\s+\S*$/, "") + "…";
    },

    parseNumberList: function (value) {
      if (!value) {
        return [];
      }

      return String(value)
        .split(",")
        .map(function (item) {
          return parseInt(item.trim(), 10);
        })
        .filter(function (num) {
          return !isNaN(num) && num > 0;
        });
    },

    toNumber: function (value, fallback) {
      var number = parseInt(value, 10);

      return isNaN(number) ? fallback : number;
    },

    toBoolean: function (value, fallback) {
      if (typeof value === "undefined") {
        return fallback;
      }

      if (value === true || value === "true" || value === "1" || value === "yes") {
        return true;
      }

      if (value === false || value === "false" || value === "0" || value === "no") {
        return false;
      }

      return fallback;
    },

    escapeHtml: function (value) {
      return String(value || "")
        .replace(/&/g, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;")
        .replace(/'/g, "&#039;");
    },

    escapeAttribute: function (value) {
      return this.escapeHtml(value).replace(/`/g, "&#096;");
    },

    extend: function () {
      var output = {};

      for (var i = 0; i < arguments.length; i++) {
        var source = arguments[i] || {};

        Object.keys(source).forEach(function (key) {
          output[key] = source[key];
        });
      }

      return output;
    },

    debounce: function (callback, delay) {
      var timer;

      return function () {
        var context = this;
        var args = arguments;

        clearTimeout(timer);

        timer = setTimeout(function () {
          callback.apply(context, args);
        }, delay);
      };
    },

    dispatch: function (container, name, detail) {
      if (!this.defaults.enableAnalyticsEvents) {
        return;
      }

      var event;

      try {
        event = new CustomEvent(name, {
          bubbles: true,
          detail: detail || {}
        });
      } catch (error) {
        event = document.createEvent("CustomEvent");
        event.initCustomEvent(name, true, true, detail || {});
      }

      container.dispatchEvent(event);
    },

    log: function () {
      if (!this.defaults.debug) {
        return;
      }

      if (window.console && console.log) {
        console.log.apply(console, arguments);
      }
    }
  };

  window.RXRelatedPosts = RXRelatedPosts;

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", function () {
      RXRelatedPosts.init();
    });
  } else {
    RXRelatedPosts.init();
  }
})();

Use this HTML/PHP output area where related posts should appear:

<section
  class="rx-related-posts"
  data-rx-related-posts
  data-post-id="123"
  data-categories="12,14"
  data-tags="5,9"
  data-limit="6"
  data-layout="grid"
  data-columns="3"
  data-tablet-columns="2"
  data-mobile-columns="1"
  data-fetch="true"
  data-carousel="false">
</section>

For WordPress dynamic single post, your PHP can print like this:

<section
  class="rx-related-posts"
  data-rx-related-posts
  data-post-id="<?php echo esc_attr( get_the_ID() ); ?>"
  data-categories="<?php echo esc_attr( implode( ',', wp_get_post_categories( get_the_ID() ) ) ); ?>"
  data-tags="<?php echo esc_attr( implode( ',', wp_get_post_tags( get_the_ID(), array( 'fields' => 'ids' ) ) ) ); ?>"
  data-limit="6"
  data-layout="grid"
  data-columns="3"
  data-tablet-columns="2"
  data-mobile-columns="1"
  data-fetch="true">
</section>

Enqueue it in functions.php:

function rx_theme_enqueue_related_posts_chunk() {
    if ( is_single() ) {
        wp_enqueue_script(
            'rx-related-posts-chunk',
            get_template_directory_uri() . '/assets/static-js/static-chunks-js/chunk-030-related-posts.js',
            array(),
            wp_get_theme()->get( 'Version' ),
            true
        );
    }
}
add_action( 'wp_enqueue_scripts', 'rx_theme_enqueue_related_posts_chunk' );

This file is now advanced enough for your RX Theme: REST API loading, category/tag matching, cache, skeleton loading, responsive grid, optional carousel, lazy images, keyboard support, accessibility, event hooks, and fallback handling.

Leave a Reply

Your email address will not be published. Required fields are marked *