Featured posts

❗ This is not a tutorial ❗

With pagiation out of the way (https://zalgorithm.com/loading-more-posts-from-an-archive-route/, https://zalgorithm.com/pagination-take-two/, https://zalgorithm.com/pagination-take-three/, https://zalgorithm.com/pagination-take-four/, https://zalgorithm.com/pagination-take-five-success/) it’s time to clean up the home page. Instead of displaying the 5 most recent posts at the top of the page, I want to display the site’s “featured” posts. A featured post will be a post that has the “featured” tag.

In the past I’ve used post meta data for this kind of thing. Meta queries can be expensive though:

Meta Queries can be expensive and have been known to actually take sites down, which is why they are not part of the core WPGraphQL plugin.

If you need meta queries for your WPGraphQL system, this plugin enables them, but use with caution. It might be better to hook into WPGraphQL and define specific meta queries that you know you need and are not going to take your system down instead of allowing just any meta_query via this plugin, but you could use this plugin as an example of how to hook into WPGraphQL to add inputs and map those inputs to the WP_Query that gets executed.

https://github.com/wp-graphql/wp-graphql-meta-query?tab=readme-ov-file#why-is-this-an-extension-and-not-part-of-wpgraphql

Mark some posts as featured

That’ll do the trick.

I’ll add the “featured” tag to some posts on my development site as well.

Query for the featured tag

query getHomepagePosts {
      tags (where: {name: "featured"}) {
      edges {
        node {
          posts(first: 5, where: {orderby: {field: DATE, order: DESC}}) {
            edges {
              node {
                id
                title
                slug
                excerpt
                date
                author {
                  node {
                    name
                  }
                }
                categories {
                  nodes {
                    name
                  }
                }
                featuredImage {
                  node {
                    caption
                    description
                    id
                    sourceUrl
                  }
                }
              }
            }
          }
        }
      }
    }
    categories {
      edges {
        node {
          name
          posts(first: 5, where: {orderby: {field: DATE, order: DESC}}) {
            edges {
              node {
                id
                title
                slug
                excerpt
                date
                author {
                  node {
                    name
                  }
                }
                featuredImage {
                  node {
                    caption
                    description
                    id
                    sourceUrl
                  }
                }
              }
            }
          }
        }
      }
    }
}

That’s a modified version of the existing INDEX_PAGE_POSTS_QUERY. I’m hard coding “featured” into the request for now. Eventually I’ll add an admin page and settings section to the app so that the code is reusable for other sites.

With the updated query, loading the blog’s index page (/blog) should trigger some kind of error:

TypeError: Cannot read properties of undefined (reading 'map')
    at BlogIndex (file:///home/scossar/remix/starting-over/hello_world/app/routes/blog._index.tsx:49:27)

The cause of the error:

// app/routes/blog._index.tsx

...
  const data = response?.data;
  const latestPostsEdges = data?.posts?.edges;
...

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) => (
...

The ErrorBoundary did it’s job, but maybe there should be a condition in the loader function that catches those types of errors earlier?

Here’s the fix:

// app/routes/blog._index.tsx

import { json, MetaFunction } from "@remix-run/node";
import { Link, useRouteError, useLoaderData } from "@remix-run/react";

import { createApolloClient } from "lib/createApolloClient";
import type {
  RootQueryToCategoryConnectionEdge,
  PostConnectionEdge,
  TagToPostConnectionEdge,
} from "~/graphql/__generated__/graphql";
import { INDEX_PAGE_POSTS_QUERY } from "~/models/wp_queries";
import PostExcerptCard from "~/components/PostExcerptCard";

export const meta: MetaFunction = () => {
  return [
    { title: "Zalgorithm" },
    { name: "description", content: "Simon's blog" },
  ];
};

export async function loader() {
  const client = createApolloClient();
  const response = await client.query({
    query: INDEX_PAGE_POSTS_QUERY,
  });

  if (response.errors) {
    throw new Error("Unable to load posts.");
  }

  const data = response?.data;
  const featuredPosts = data?.tags?.edges?.[0]?.node?.posts?.edges ?? [];
  const categoryEdges = data?.categories?.edges ?? [];

  if (featuredPosts.length === 0 && categoryEdges.length === 0) {
    throw new Error("No posts were returned for the homepage.");
  }

  return json({
    featuredPosts: featuredPosts,
    categoryEdges: categoryEdges,
  });
}

export default function BlogIndex() {
  const { featuredPosts, 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 text-center">
        Simon's Blog
      </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">
        {featuredPosts.map((postEdge: TagToPostConnectionEdge) => (
          <PostExcerptCard
            title={postEdge.node?.title}
            date={postEdge.node?.date}
            featuredImage={postEdge.node?.featuredImage?.node?.sourceUrl}
            excerpt={postEdge.node?.excerpt}
            authorName={postEdge.node.author?.node?.name}
            slug={postEdge.node?.slug}
            key={postEdge.node.id}
          />
        ))}
      </div>
      <Link
        className="text-2xl text-sky-700 font-medium hover:underline pt-3 block text-center"
        prefetch="intent"
        to="archive"
      >
        Read all Posts
      </Link>
      <hr className="mt-2 mb-2 border-solid border-slate-900" />

      {categoryEdges.map((categoryEdge: RootQueryToCategoryConnectionEdge) => (
        <div key={categoryEdge.node.name}>
          <h2 className="text-3xl text-slate-900 mt-3 font-serif font-bold">
            {categoryEdge.node.name}
          </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">
            {categoryEdge.node?.posts?.edges.map(
              (postEdge: PostConnectionEdge) => (
                <PostExcerptCard
                  title={postEdge.node?.title}
                  date={postEdge.node?.date}
                  featuredImage={postEdge.node?.featuredImage?.node?.sourceUrl}
                  excerpt={postEdge.node?.excerpt}
                  authorName={postEdge.node.author?.node?.name}
                  slug={postEdge.node?.slug}
                  key={postEdge.node.id}
                />
              )
            )}
          </div>
        </div>
      ))}
    </div>
  );
}

export function ErrorBoundary() {
  const error = useRouteError();

  const errorMessage = error instanceof Error ? error.message : "Unknown error";
  return (
    <div className="mx-auto max-w-2xl px-20 py-4 my-10 bg-red-200 border-2 border-red-700 rounded">
      <h1>App Error</h1>
      <pre>{errorMessage}</pre>
    </div>
  );
}

This is a new pattern for me:

const featuredPosts = data?.tags?.edges?.[0]?.node?.posts?.edges ?? [];

It’s using the nullish coalescing operator (??). I’ve been assigning variables like this:

  const featuredPosts = data?.tags?.edges?.[0]?.node?.posts?.edges
    ? data.tags.edges[0].node.posts.edges
    : [];

The old version was safe, but overly complex.

I’ve added a condition to the loader to return an error if there are no featured posts and no category posts returned by the query:

  if (featuredPosts.length === 0 && categoryEdges.length === 0) {
    throw new Error("No posts were returned for the homepage.");
  }

The UI is assuming there will be both featured posts and category posts though. I’ll deal with that later.

I should note that this is the sleepiest programming I’ve done in a while. I was stuck for a long time on why I couldn’t iterate through data.tags.edges.node. It’s because node is an object, not an array.

Category archive routes

While I’m cleaning up the blog’s homepage, I’d like to add archive routes for each category that’s listed on the page. I’ll give it a shot now. I’ll get some sleep and come back to it tomorrow if it doesn’t go smoothly.

$ git checkout -b add_category_archive_routes

I’ll start by adding an optional segment (https://remix.run/docs/en/main/file-conventions/routes#optional-segments) to the blog.archive.tsx route:

// the escaping seems to be necessary here
$ mv app/routes/blog.archive.tsx app/routes/blog.\(\$categorySlug\).archive.tsx

If I’m understanding things correctly, the blog’s archive page should still load at /blog/archive

And it does 🙂

With no changes made to handle the optional path segment, I think the archive page will also load at /blog/anystring/archive.

That also works!

I can capture the categorySlug in the blog.($categorySlug).archive.tsx loader:

// app/routes/blog.($categorySlug).archive.tsx

...
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
  const { searchParams } = new URL(request.url);
  const categorySlug = params?.categorySlug ?? null;
  console.log(`categorySlug: ${categorySlug}`);
...
}

// returns "categorySlug: anystring"

Links from the index page to category archive pages are going to need the category slug. I’ll add it to the INDEX_PAGE_POSTS_QUERY:

// models/wp_queries.tsx

...
export const INDEX_PAGE_POSTS_QUERY = gql(`
query getHomepagePosts {
    tags (where: {name: "featured"}) {
... 
  categories {
    edges {
      node {
        name
        slug
        posts(first: 5, where: {orderby: {field: DATE, order: DESC}}) {
          edges {
            node {
              id
              title
              slug
              excerpt
              date
              author {
                node {
                  name
                }
              }
              featuredImage {
                node {
                  caption
                  description
                  id
                  sourceUrl
                }
              }
            }
          }
        }
      }
    }
  }
}
`);

Add the category archive links to the index page:

// app/routes/blog._index.tsx

...
export default function BlogIndex() {
  const { featuredPosts, categoryEdges } = useLoaderData<typeof loader>();
...
          <div className="my-3 flex justify-center items-center h-full">
            <Link
              to={`${categoryEdge.node.slug}/archive`}
              className="text-2xl text-sky-700 font-medium hover:underline pt-3"
            >
              Read all {categoryEdge.node.name} posts
            </Link>
          </div>
        </div>
      ))}
    </div>
  );
}
...

Clicking the category links on the index page now passes the category slug to the blog.($categorySlug).archive.tsx loader function.

The GraphiQL IDE surfaced this bit of helpful information:

I can use the category slug and not its name. That makes things easy.

Update the ARCHIVE_QUERY to accept an optional categorySlug variable:

// models/wp_queries.ts

...
export const ARCHIVE_QUERY = gql(`
query ArchiveQuery (
    $first: Int
    $last: Int
    $after: String
    $before: String
    $categorySlug: String
  ) {
    posts(first: $first, last: $last, after: $after, before: $before, where: {categoryName: $categorySlug}) {
      pageInfo {
        hasNextPage
        hasPreviousPage
        startCursor
        endCursor
      }
      edges {
        cursor
        node {
          id
          databaseId
          title
          date
          slug
          excerpt
          author {
            node {
              name
            }
          }
          featuredImage {
            node {
              altText
              sourceUrl
            }
          }
        }
      }
    }
  }
`);
...

Update the loader function to add the categorySlug param to the its query’s variables:

// app/routes/blog.($categorySlug).archive.tsx

...
interface ArchiveQueryVariables {
  first: Maybe<number>;
  last: Maybe<number>;
  after: Maybe<string>;
  before: Maybe<string>;
  categorySlug: Maybe<string>;
}

export const loader = async ({ params, request }: LoaderFunctionArgs) => {
  const { searchParams } = new URL(request.url);
  const categorySlug = params?.categorySlug ?? null;
  const startCursor = searchParams.get("startCursor") ?? null;
  const endCursor = searchParams.get("endCursor") ?? null;
  const chunkSize = Number(process.env?.ARCHIVE_CHUNK_SIZE) || 15;

  let queryVariables: ArchiveQueryVariables = {
    first: null,
    last: null,
    after: null,
    before: null,
    categorySlug: null,
  };

  if (categorySlug) {
    queryVariables.categorySlug = categorySlug;
  }
...

This is working 🙂

I’d like to display the category name in the heading for each archive category. The loader doesn’t have access to the name, it’s only got the slug. I should be able to deal with that.

Oops!

I’d called toUpperCase with the parenthesis. This fixes it:

let categoryName;
if (categorySlug) {
  categoryName = categorySlug
    .split("-")
    .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
    .join(" ");
}

That’s enough for tonight:

$ git add .
$ git commit -m "add category archive route; do some css cleanup"
$ git checkout main
$ git merge add_category_archive_routes

Throwing caution to the wind:

$ git push origin main
$ git push live main

I’m wondering what path should be used when a post from an archive route is selected. Currently, the blog.$slug route is used. That’s for another day…

The changes are live here: https://hello.zalgorithm.com/blog.

The code is here: https://github.com/scossar/wp-remix.