Archive and Category Index pages

Pipe Dream (Extension Ridge)
Pipe Dream (Extension Ridge)

❗ This is not a tutorial ❗

The next step is to link each of the top level headings on the /blog page to index pages that display all of the posts that fall under the heading.

Before adding the new index pages, I’m going to update the blog.$slug.tsx route to add Open Graph meta tags to the blog post page’s headers.

Open Graph meta tags

I’m doing this now because it’s similar to what I did yesterday (this links to the WordPress site for now) to add title and description meta tags to the blog post headers.

The first task is to move the truncateText and stripHtml functions from the blog.$slug.tsx file to a utilities file:

$ touch app/utils/utilities.ts
// app/utils/utilities.ts

export function truncateText(text: string, maxLength: number): string {
  if (text.length <= maxLength) return text;
  return text.slice(0, maxLength - 1) + "…";
}

export function stripHtml(html: string): string {
  return html.replace(/<[^>]*>/g, "");
}
// app/routes/blog.$slug.tsx

import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import invariant from "tiny-invariant";

import { createApolloClient } from "lib/createApolloClient";
import { POST_BY_SLUG_QUERY } from "~/models/wp_queries";
import type { Post } from "~/graphql/__generated__/graphql";
import { stripHtml, truncateText } from "~/utils/utilities";
...

Now update the meta function in blog.$slug.tsx to generate meta tags with the following properties:

  • og:title
  • og:description
  • og:image

The meta function is already setting title and description variables. I’ll update it to set an imageUrl variable for posts that have a featuredImage property and add the Open Graph properties:

// app/routes/blog.$slug.tsx

...

export const meta: MetaFunction = ({ data }) => {
  const post = data as Post;
  const title = post?.title ? post.title : "Simon's blog";
  let description = post?.excerpt
    ? stripHtml(post.excerpt)
    : `Read more about ${post.title}`;
  description = truncateText(description, 160);
  const imageUrl = post?.featuredImage?.node?.sourceUrl
    ? post.featuredImage.node.sourceUrl
    : "";
  return [
    { title: title },
    { description: description },
    { property: "og:title", content: title },
    { property: "og:description", content: description },
    { property: "og:image", content: imageUrl },
  ];
};

That seems to do the trick:

I’ll push the changes to the production server to confirm.

I pushed the changes to the live server and checked the results on the Facebook Sharing Debugger (https://developers.facebook.com/tools/debug/,) the title, description, and image properties are being pulled in, but there’s a warning about the following missing properties: og:url, og:type, fb:app_id. I won’t bother with the fb:app_id property (I just checked this on Facebook for convenience,) but I’ll add the other missing properties. (The Open Graph documentation is here: https://ogp.me/.)

// app/routes/blog.$slug.tsx

...

export const meta: MetaFunction = ({ data }) => {
  const post = data as Post;
  const title = post?.title ? post.title : "Simon's blog";
  let description = post?.excerpt
    ? stripHtml(post.excerpt)
    : `Read more about ${post.title}`;
  description = truncateText(description, 160);
  // todo: set BASE_URL as an environental variable so that it doesn't have to be hard coded here:
  const url = post?.slug
    ? `https://hello.zalgorithm.com/blog/${post.slug}`
    : "";

  let metaTags = [
    { title: title },
    { description: description },
    { property: "og:title", content: title },
    { property: "og:description", content: description },
    { property: "og:type", content: "website" },
    { property: "og:url", content: url },
  ];

  if (post?.featuredImage?.node?.sourceUrl) {
    metaTags.push({
      property: "og:image",
      content: post.featuredImage.node.sourceUrl,
    });
  }

  return metaTags;
};
...

Posts shared on social media (or on any applications that support Open Graph tags) are looking good:

A brief diversion

After pushing the latest changes to the production site, I ran npm run dev on the production server. I’d meant to run npm run build!!! That messed things up. Visiting https://hello.zalgorithm.com returned an “unexpected server error” message.

It’s this kind of thing that can make professional programming a stressful job.

I think that what fixed it was stopping the process that was running on port 3000:

$ sudo lsof -i :3000
COMMAND    PID    USER   FD   TYPE  DEVICE SIZE/OFF NODE NAME
node    163865 scossar   21u  IPv6 1927037      0t0  TCP *:3000 (LISTEN)

$ kill -9 163865

Then running:

$ pm2 stop 0
$ pm2 start 0

The issue was likely related to a port conflict. The production app is expected to be running on port 3000. (As outlined here, I’ve configured an Nginx reverse proxy to route traffic to the app on port 3000.) Running npm run dev attempted to start another process on port 3000.

After getting the things back online, I figured I should refine the deploy process.

Running pm2 list was returning confusing data:

$ pm2 list

│ id │ name         │ namespace   │ version │ mode    │ pid      │ uptime │ ↺    │ status    │ cpu      │ mem      │ user     │ watching │
├────┼──────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
│ 0  │ npm start    │ default     │ N/A     │ fork    │ 163422   │ 16m    │ 28   │ online    │ 0%       │ 58.3mb   │ scossar  │ disabled 

The confusing part was that the process name was “npm start.” That didn’t seem right. It was caused by having initially started the process with `pm2 start “npm start” -i max`. That command didn’t explicitly set a name for the process, so the command that was supplied ("npm start") was set as the process name.

To fix this, I stopped the process with:

$ pm2 stop 0

I then started the process with an explicit name:

$ pm2 start "npm start" --name "zalgorithm" -i max

pm2 list now returns more meaningful data:

$ pm2 list
│ id │ name          │ namespace   │ version │ mode    │ pid      │ uptime │ ↺    │ status    │ cpu      │ mem      │ user     │ watching │
├────┼───────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
│ 0  │ zalgorithm    │ default     │ N/A     │ fork    │ 163853   │ 112s   │ 28   │ online    │ 0%       │ 58.8mb   │ scossar  │ disabled 

Now I can run pm2 reload zalgorithm to reload the app after building it.

I’m going to see if I can automate the build/deploy process a bit. I’m pushing changes to the app from my local computer to the production server with:

$ git push live main

live" is a git repo that I’ve configured on the production server. In the live repo’s /hooks directory, I’ve created a post-receive file:

#!/bin/sh                                                                                                                                                                                                                                                                                                                                                                  

git --work-tree=/var/www/hello_zalgorithm_com --git-dir=/var/repo/site.git checkout -f

(See https://www.digitalocean.com/community/tutorials/how-to-set-up-automatic-deployment-with-git-with-a-vps for details.)

It seems that the post-receive script should be able to handle the following commands:

  • npm install
  • npm run build
  • pm2 reload zalgorithm

Technically, something like this should work for the post-receive file:

#!/bin/sh

# Define the directory where the app lives
APP_DIR=/var/www/hello_zalgorithm_com
REPO_DIR=/var/repo/site.git

# Checkout the latest code
git --work-tree=$APP_DIR --git-dir=$REPO_DIR checkout -f

# Change to the app directory
cd $APP_DIR

# Install dependencies and build the project
npm install
npm run build

# Restart the specific pm2 process
pm2 restart zalgorithm

but it doesn’t. When I log the output, it’s failing at the npm install, npm run build and pm2 restart zalgorithm commands:

Running npm install
hooks/post-receive: 13: npm: not found
Running npm run build
hooks/post-receive: 16: npm: not found
Reloading pm2 process
hooks/post-receive: 20: pm2: not found

This is probably related to the environment that the post-receive hook is running in being different from the environment that I’m in when I run the npm and pm2 commands directly from the /var/www/hello_zalgorithm_com directory. I’m going to leave this for now.

After trying that, I’d like to do something that’s easily achievable. How about…

Styling block quotes in WordPress posts

Currently, WordPress block quotes on the Remix app look like regular text:

That section has the following markup:

<blockquote class="wp-block-quote">
  <p>WPGraphQL is a free, open-source WordPress plugin that provides an extendable GraphQL schema and API for any WordPress site.</p>
  <cite>From the plugin’s docs</cite>
</blockquote>

I’ll go with the following styles for now:

// app/tailwind.css

...

  .wp-post .wp-block-quote {
    @apply border-l border-slate-600 pl-4 font-medium;
  }
  .wp-post .wp-block-quote cite {
    @apply font-normal text-sm italic;
  }
...

That’s an improvement:

I’ll make a start on what was supposed to be today’s task:

Add an Archive index route

The “Latest Posts” section of the blog’s index page only lists the 5 most recent posts. I’m going to put a link to an “Archive” route at the bottom of that section:

I’m not sure what the link’s text will be. “Archive” seems too obscure. Maybe “Read All Posts”? In any case, the route’s going to be called archive in the code. It will be a child of the blog route:

$ touch app/routes/blog.archive.tsx
// app/routes/blog.archive.tsx

export default function Archive() {
  return (
    <div>
      <h2>Latest</h2>
    </div>
  );
}

blog._index.tsx needs to be updated to link to that page:

// app/routes/blog._index.tsx

// I've added the Link import here:
import { json, MetaFunction } from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";

...

// then added the link here:
export default function BlogIndex() {
  const { latestPostsEdges, categoryEdges } = useLoaderData<typeof loader>();
  return (
    <div className="px-6 mx-auto max-w-screen-lg">
      <h2 className="text-3xl text-slate-900 mt-3 font-serif font-bold">
        Latest Posts
      </h2>
      <hr className="mt-2 mb-3 border-solid border-slate-900" />
      <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
        {latestPostsEdges.map((edge: PostConnectionEdge) => (
          <PostExcerptCard
            title={edge.node?.title}
            date={edge.node?.date}
            featuredImage={edge.node?.featuredImage?.node?.sourceUrl}
            excerpt={edge.node?.excerpt}
            authorName={edge.node?.author?.node?.name}
            slug={edge.node?.slug}
            key={edge.node.id}
          />
        ))}
        <Link
          className="text-2xl text-sky-700 font-medium hover:underline"
          prefetch="intent"
          to="archive"
        >
          See All Latest Posts
        </Link>
        <hr className="mt-2 mb-2 border-solid border-slate-900" />
      </div>
...

The design here is questionable, but I’ll go with it for now:

Query for the latest posts

Pulling in the latest posts is going to require multiple queries. Posts will be loaded in batches of 15. After loading the first 15 posts, if there are more posts in the database, a “Load More” button will be displayed at the bottom of the list. Clicking that button will load the next batch of posts.

Details about how to paginate queries with the WPGraphQL plugin are here: https://www.wpgraphql.com/docs/connections#solution-for-pagination. Note that (I think) there’s a typo in the example queries on that page. When I try to run this query in the GraphiQL IDE on my WordPress site:

{
  posts {
    pageInfo {
      hasNextPage
      endCursor
    }
    edges {
      cursor
      nodes {
        id
        title
      }
    }
  }
}

I get the error: "Cannot query field \"nodes\" on type \"RootQueryToPostConnectionEdge\". Did you mean \"node\"?"

I think the query is meant to use node (the post node) not nodes:

{
  posts {
    pageInfo {
      hasNextPage
      endCursor
    }
    edges {
      cursor
      node {
        id
        title
      }
    }
  }
}

I’ll start with this:

{
  posts (first: 15, where: {orderby: {field:DATE, order: DESC}}) {
    pageInfo {
      hasNextPage
    }
    edges {
      cursor
      node {
        id
        title
        date
        slug
        excerpt
        author {
          node {
            name
          }
        }
        featuredImage {
          node {
            altText
            sourceUrl
          }
        }
      }
    }
  }
}

The data that’s returned looks like this:

{
  "data": {
    "posts": {
      "pageInfo": {
        "hasNextPage": true
      },
      "edges": [
        {
          "cursor": "YXJyYXljb25uZWN0aW9uOjEzMw==",
          "node": {
            "id": "cG9zdDoxMzM=",
            "title": "link to posts from the index page (local)",
            "date": "2024-02-27T02:21:06",
            "slug": "link-to-posts-from-the-index-page-local",
            "excerpt": "<p>A small update from yesterday. Viewing the production site on mobile, I noticed that long links found in excerpts could cause content to extend past its container. I&#8217;ve fixed that by adding a .wp-excerpt class to the excerpt&#8217;s outer div element, and then adding the following to the tailwind.css file: The Tailwind @layer and @apply [&hellip;]</p>\n",
            "author": {
              "node": {
                "name": "scossar"
              }
            },
            "featuredImage": null
          }
        },

...
          "cursor": "YXJyYXljb25uZWN0aW9uOjExOA==",
          "node": {
            "id": "cG9zdDoxMTg=",
            "title": "Mountain Biking in Nanaimo: Gear Essentials for the Trails",
            "date": "2024-02-25T01:33:23",
            "slug": "mountain-biking-in-nanaimo-gear-essentials-for-the-trails",
            "excerpt": "<p>Mountain biking in Nanaimo offers an exhilarating blend of adventure and natural beauty. Nestled on Vancouver Island, Nanaimo&#8217;s diverse landscapes provide the perfect backdrop for mountain biking enthusiasts. From challenging technical trails and steep descents to scenic forest routes and coastal paths, there&#8217;s something for riders of all levels. The area is renowned for its [&hellip;]</p>\n",
            "author": {
              "node": {
                "name": "scossar"
              }
            },
            "featuredImage": null
          }
        }
      ]
    }
  },
  "extensions": {
    "debug": [],
    "queryAnalyzer": {
      "keys": "2df0fa5ece90ea37aed3e19d7b08cb459e6aeb48f77a078805bef04e89bee6ac graphql:Query list:post cG9zdDoxMzM= cG9zdDoxMjQ= cG9zdDoxMjI= cG9zdDoxMjA= cG9zdDoxMTg= dXNlcjox cG9zdDoxMQ== cG9zdDoxMjY= cG9zdDoxMjc=",
      "keysLength": 201,
      "keysCount": 12,
      "skippedKeys": "",
      "skippedKeysSize": 0,
      "skippedKeysCount": 0,
      "skippedTypes": []
    }
  }
}

pageInfo.hasNextPage will be set to true if there are more posts in the database. The next batch of posts can then be obtained by using the value of the cursor from the last post in the batch. For example:

{
  posts (first: 15, after: "YXJyYXljb25uZWN0aW9uOjExOA==", where: {orderby: {field:DATE, order: DESC}}) {
    pageInfo {
      hasNextPage
    }
    edges {
      cursor
      node {
        id
        title
        date
        slug
        excerpt
        author {
          node {
            name
          }
        }
        featuredImage {
          node {
            altText
            sourceUrl
          }
        }
      }
    }
  }
}

The query that’s added to the Remix app at app/models/wp_queries.ts is going to need to accept an after variable. Testing the query in the GraphiQL IDE, it seems that the value of after can be set to an empty string. That will simplify things for dealing with the initial query.

Maybe this will work:

// app/models/wp_queries.ts

export const ARCHIVE_POSTS_QUERY = gql(`
query ArchivePosts($after: String!) {
    posts (first: 15, after: $after, where: {orderby: {field:DATE, order: DESC}}) {
        pageInfo {
          hasNextPage
        }
        edges {
          cursor
          node {
            id
            title
            date
            slug
            excerpt
            author {
              node {
                name
              }
            }
            featuredImage {
              node {
                altText
                sourceUrl
              }
            }
          }
        }
      }
}
`);

I’ll see if I can copy some of the code from blog._index.tsx to get this to work:

// app/routes/blog.archive.tsx

import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

import { createApolloClient } from "lib/createApolloClient";
import { ARCHIVE_POSTS_QUERY } from "~/models/wp_queries";
import type { PostConnectionEdge } from "~/graphql/__generated__/graphql";
import PostExcerptCard from "~/components/PostExcerptCard";

export async function loader() {
  const client = createApolloClient();
  const response = await client.query({
    query: ARCHIVE_POSTS_QUERY,
    variables: {
      after: "",
    },
  });

  if (response.errors) {
    // todo: handle the errors
    console.log("An error was returned from the ARCHIVE_POSTS_QUERY");
  }

  const data = response?.data;
  const pageInfo = data?.posts?.pageInfo;
  const postEdges = data?.posts?.edges;

  // don't worry about errors for now
  return json({
    pageInfo: pageInfo,
    postEdges: postEdges,
  });
}

export default function Archive() {
  const { pageInfo, postEdges } = useLoaderData<typeof loader>();
  return (
    <div className="px-6 mx-auto max-w-screen-lg">
      <h2 className="text-3xl text-slate-900 mt-3 font-serif font-bold">
        Archive
      </h2>
      <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
        {postEdges.map((edge: PostConnectionEdge) => (
          <PostExcerptCard
            title={edge.node?.title}
            date={edge.node?.date}
            featuredImage={edge.node?.featuredImage?.node?.sourceUrl}
            excerpt={edge.node?.excerpt}
            authorName={edge.node?.author?.node?.name}
            slug={edge.node?.slug}
            key={edge.node.id}
          />
        ))}
      </div>
    </div>
  );
}

That was easy:

I’d better stop for the night. Tomorrow’s project will be to figure out how to add a “Load More Posts” button to the page if PageInfo.hasNextPage is true. The next batch of posts will be loaded from the value of the cursor from the current batch’s last post.

I’m going to push today’s work to the production site.

https://hello.zalgorithm.com/blog/archive